Files
sap-erp/public/batch-issuance.html
T
John eead8f5ffd
SAP-ERP Portal CI/CD / build (push) Successful in 3m57s
sale order
2026-10-05 18:45:17 +05:30

1387 lines
88 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<script src="/auth-guard.js?v=4"></script>
<script src="/company-list.js"></script>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1"/>
<title>Batch Issuance Intimation — 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>
:root{
--blue:#2563eb;--blue2:#1d4ed8;--blue-light:#eff6ff;
--text:#111827;--text2:#374151;--text3:#6b7280;--text4:#9ca3af;
--bg:#f9fafb;--surface:#fff;--surface2:#f9fafb;--surface3:#f3f4f6;--border:#e5e7eb;--border2:#d1d5db;
--font:'Space Grotesk',sans-serif;--mono:'JetBrains Mono',ui-monospace,monospace;
--red:#dc2626;--green:#16a34a;--amber:#d97706;--radius:10px;
--shadow:0 1px 3px rgba(0,0,0,.06),0 1px 2px rgba(0,0,0,.04);
}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:var(--font);background:var(--bg);color:var(--text);min-height:100vh;-webkit-font-smoothing:antialiased}
main{padding:24px;max-width:1150px;margin:0 auto}
.page-head{margin-bottom:18px}
.page-title{font-size:22px;font-weight:800;color:var(--text);display:flex;align-items:center;gap:10px}
.page-sub{font-size:13px;color:var(--text3);margin-top:3px}
.tabs{display:inline-flex;gap:4px;background:var(--surface3);padding:4px;border-radius:10px;margin-bottom:16px}
.tab{padding:8px 18px;border-radius:8px;font-size:13px;font-weight:700;color:var(--text3);cursor:pointer;border:none;background:none;font-family:var(--font);transition:all .15s}
.tab.on{background:var(--surface);color:var(--blue);box-shadow:var(--shadow)}
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow);margin-bottom:16px;overflow:visible}
.card-hd{padding:14px 18px;border-bottom:1px solid var(--border);font-size:14px;font-weight:700;background:var(--surface2);border-radius:10px 10px 0 0;display:flex;align-items:center;gap:8px}
.card-bd{padding:18px}
.ref-pill{font-family:var(--mono);font-size:14px;font-weight:700;color:var(--blue);background:var(--blue-light);border:1px solid #bfdbfe;padding:6px 14px;border-radius:8px;display:inline-block}
.grid3{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin-bottom:18px}
.fg{display:flex;flex-direction:column;gap:6px}
.fl{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:var(--text3)}
.fi{width:100%;padding:9px 12px;border:1.5px solid var(--border);border-radius:8px;font-size:13px;font-family:var(--font);background:var(--surface);color:var(--text);outline:none;height:38px}
.fi:focus{border-color:var(--blue);box-shadow:0 0 0 3px rgba(37,99,235,.12)}
.fi::placeholder{color:var(--text4)}
/* Product block */
.prod-block{border:1px solid var(--border);border-radius:10px;margin-bottom:14px;overflow:hidden}
.prod-hd{display:flex;align-items:center;gap:10px;padding:10px 14px;background:var(--surface2);border-bottom:1px solid var(--border);flex-wrap:wrap}
.prod-code{font-family:var(--mono);font-size:12px;font-weight:700;color:var(--blue)}
.prod-name{font-size:13px;font-weight:600;color:var(--text)}
.prod-req{margin-left:auto;font-size:11px;color:var(--text3);background:var(--surface3);padding:3px 10px;border-radius:6px}
.prod-bd{padding:10px 14px}
.tbl-wrap{overflow-x:auto}
table.bt-tbl{width:100%;border-collapse:collapse;font-size:13px;min-width:640px}
.bt-tbl th{color:var(--text3);font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.4px;text-align:left;padding:6px 8px}
.bt-tbl td{padding:4px 6px;vertical-align:middle}
.bt-tbl .fi{height:34px;font-size:12px}
.td-act{width:40px;text-align:center}
.b-del{background:rgba(220,38,38,.06);color:var(--red);border:1px solid rgba(220,38,38,.2);border-radius:7px;width:30px;height:30px;cursor:pointer;display:inline-flex;align-items:center;justify-content:center}
.b-solvol{margin-top:4px;width:100%;padding:4px 6px;border-radius:6px;border:1px dashed var(--blue);background:none;color:var(--blue);font-size:10px;font-weight:700;cursor:pointer;font-family:inherit;white-space:nowrap}
.b-solvol:hover{background:var(--blue-light)}
.solvol-ov{position:fixed;inset:0;z-index:9999;background:rgba(15,23,42,.5);display:flex;align-items:flex-start;justify-content:center;padding:60px 12px}
.solvol-modal{width:100%;max-width:440px;background:var(--surface);border-radius:12px;box-shadow:0 20px 60px rgba(0,0,0,.25);overflow:hidden}
.solvol-hd{padding:14px 16px;border-bottom:1px solid #eef0f3;font-size:14px;font-weight:700;color:var(--text);display:flex;align-items:center}
.solvol-x{margin-left:auto;width:26px;height:26px;border-radius:7px;border:1px solid #eef0f3;background:none;color:var(--text3);cursor:pointer}
.solvol-bd{padding:16px;max-height:60vh;overflow:auto}
.solvol-hint{font-size:11px;color:var(--text3);margin-bottom:12px;line-height:1.4}
.solvol-row{display:grid;grid-template-columns:1fr 120px 34px;gap:6px;margin-bottom:8px;align-items:center}
.solvol-name-auto{padding:8px 10px;border-radius:8px;background:var(--blue-light,#eff6ff);border:1px solid var(--blue);color:var(--blue);font-size:12px;font-weight:700;height:34px;box-sizing:border-box;display:flex;align-items:center;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.solvol-empty{font-size:12px;color:var(--text3);padding:8px 0}
.b-addrow{margin-top:4px;padding:8px 12px;font-size:12px;font-weight:700;color:var(--blue);background:none;border:1.5px dashed var(--blue);border-radius:8px;cursor:pointer;width:100%;font-family:inherit}
.solvol-ft{display:flex;padding:12px 16px;border-top:1px solid #eef0f3;background:var(--surface2,#f8fafc)}
.solvol-ft .b-primary{margin-left:auto;padding:8px 20px}
.search-wrap{position:relative}
.search-dd{position:fixed;background:var(--surface);border:1px solid var(--blue);border-radius:8px;box-shadow:0 8px 28px rgba(0,0,0,.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(--blue-light)}
.sd-code{font-family:var(--mono);font-size:11px;font-weight:700;color:var(--blue)}
.sd-name{font-size:12px;color:var(--text2)}
.sd-msg{padding:12px;text-align:center;color:var(--text3);font-size:12px}
.date-cell{display:flex;gap:4px;align-items:center}
.date-pick-btn{background:var(--surface);border:1px solid var(--border);border-radius:7px;width:30px;height:30px;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;cursor:pointer;color:var(--text3)}
.date-pick-btn:hover{border-color:var(--blue);color:var(--blue);background:var(--blue-light)}
.b-del:hover{background:rgba(220,38,38,.14)}
.btn{padding:9px 18px;border-radius:9px;border:none;font-family:var(--font);font-size:13px;font-weight:700;cursor:pointer;transition:all .15s;display:inline-flex;align-items:center;gap:7px;justify-content:center}
.btn:disabled{opacity:.5;cursor:not-allowed}
.b-add{background:var(--blue-light);color:var(--blue);border:1px solid #bfdbfe;margin-top:8px;font-size:12px;padding:6px 12px}
.b-add:hover{background:#dbeafe}
.b-primary{background:var(--blue);color:#fff;box-shadow:var(--shadow)}
.b-primary:hover:not(:disabled){background:var(--blue2)}
.b-cancel{background:var(--surface);color:var(--text2);border:1px solid var(--border)}
.b-cancel:hover{background:var(--surface2)}
.footer-row{display:flex;gap:10px;justify-content:flex-end;margin-top:20px;padding-top:16px;border-top:1px solid var(--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)}}
/* 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(--border);border-radius:9px;font-size:13px;font-family:var(--font);background:var(--surface);color:var(--text);outline:none}
.vf-search input:focus{border-color:var(--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(--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(--border);background:var(--surface);color:var(--text3);font-size:12px;font-weight:700;font-family:var(--font);cursor:pointer;transition:all .12s;white-space:nowrap}
.vf-chip:hover{border-color:var(--blue);color:var(--blue2)}
.vf-chip.on{background:var(--blue);border-color:var(--blue);color:#fff}
.vf-count{font-size:12px;color:var(--text3);white-space:nowrap}
/* View list — square-tile grid, same pattern as work-order.html */
.bi-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:14px}
.bi-card{background:var(--surface);border:1px solid var(--border);border-radius:12px;box-shadow:var(--shadow);padding:14px 16px;cursor:pointer;transition:all .15s;aspect-ratio:1/1;display:flex;flex-direction:column;overflow:hidden}
.bi-card:hover{border-color:var(--blue);box-shadow:0 6px 20px rgba(37,99,235,.14);transform:translateY(-2px)}
.bc-top{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;flex-wrap:wrap}
.bc-doc{font-family:var(--mono);font-size:13px;font-weight:700;color:var(--blue)}
.bc-ref{font-family:var(--mono);font-size:11px;color:var(--text3)}
.bc-icon{width:36px;height:36px;border-radius:9px;background:var(--blue-light);color:var(--blue2);display:flex;align-items:center;justify-content:center;margin:8px 0}
.status-badge{font-size:9.5px;font-weight:700;padding:3px 9px;border-radius:20px;text-transform:uppercase;letter-spacing:.3px;background:#fff7ed;color:#c2410c;border:1px solid #fed7aa;white-space:nowrap}
.status-badge.full{background:#e6f5e6;color:#16a34a;border-color:#bbf0bb}
.bc-meta{font-size:10.5px;color:var(--text3);margin-top:auto;padding-top:8px;border-top:1px solid var(--border);display:flex;flex-direction:column;gap:3px}
.bc-actions{display:inline-flex;gap:6px;flex-wrap:wrap}
.bc-btn{display:inline-flex;align-items:center;gap:4px;font-family:var(--font);font-size:11px;font-weight:700;padding:6px 12px;border-radius:7px;cursor:pointer;background:var(--surface);border:1px solid var(--border);color:var(--text2)}
.bc-btn:hover{border-color:var(--blue);color:var(--blue);background:var(--blue-light)}
.bc-btn.del:hover{border-color:var(--red);color:var(--red);background:rgba(220,38,38,.06)}
.bc-btn:disabled{opacity:.45;cursor:not-allowed}
.bc-btn:disabled:hover{border-color:var(--border);color:var(--text2);background:var(--surface)}
.bc-btn.del:disabled:hover{border-color:var(--border);color:var(--text2);background:var(--surface)}
.dv-tbl{width:100%;border-collapse:collapse;font-size:12px;margin-top:6px}
.dv-tbl th{background:var(--surface3);color:var(--text3);font-size:10px;font-weight:700;text-transform:uppercase;text-align:left;padding:6px 8px}
.dv-tbl td{padding:5px 8px;border-bottom:1px solid var(--border)}
.empty-msg{text-align:center;padding:40px;color:var(--text3);font-size:13px}
/* Detail popup */
.bi-modal-bg{position:fixed;inset:0;background:rgba(15,23,42,.55);z-index:500;display:none;align-items:flex-start;justify-content:center;padding:28px 20px;overflow-y:auto}
.bi-modal-bg.show{display:flex}
.bi-modal{background:var(--surface);border-radius:16px;width:100%;max-width:900px;margin:auto;box-shadow:0 30px 70px rgba(15,23,42,.3);max-height:92vh;display:flex;flex-direction:column;animation:mIn .18s ease}
@keyframes mIn{from{transform:translateY(16px);opacity:0}to{transform:translateY(0);opacity:1}}
.bi-modal-hd{padding:16px 22px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;flex-shrink:0;background:var(--surface2);border-radius:16px 16px 0 0;flex-wrap:wrap}
.bi-modal-close{margin-left:auto;width:32px;height:32px;border-radius:9px;border:1px solid var(--border);background:var(--surface);color:var(--text3);cursor:pointer;display:flex;align-items:center;justify-content:center}
.bi-modal-close:hover{background:var(--red);color:#fff;border-color:var(--red)}
.bi-modal-bd{padding:20px 22px;overflow-y:auto}
.bi-modal-ft{padding:14px 22px;border-top:1px solid var(--border);display:flex;gap:8px;flex-wrap:wrap;flex-shrink:0;background:var(--surface2);border-radius:0 0 16px 16px}
@media(max-width:768px){.bi-modal{max-height:100vh;border-radius:0}.bi-modal-bg{padding:0}}
.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(--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(0,0,0,.18);color:#fff;max-width:320px}
.t-ok{background:#16a34a}.t-err{background:#dc2626}
@media(max-width:768px){main{padding:14px 14px 80px}.grid3{grid-template-columns:1fr}}
</style>
<link rel="stylesheet" href="/theme.css?v=3"/>
</head>
<body>
<nav></nav>
<main id="app"></main>
<div class="toast-c" id="toast-c"></div>
<div class="bi-modal-bg" id="bi-detail-modal"><div class="bi-modal" id="bi-modal-inner"></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='/';
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;
}
// Same permission work-order.html itself gates "+ New Work Order" behind —
// "Generate Work Order" here just navigates to /work-order?from=X, which
// silently bounces back to the list for anyone without this, so show it
// only to users who could actually complete it.
const CAN_CREATE_WO=hasStepPerm('work_order:prepared_qa','add');
let _co=window.__DEFAULT_COMPANY__;
let _tab='create';
let _reqs=[]; // open requirements for the selector
let _reqReviewEnabled=false, _reqReviewHardGate=false; // Admin → System Settings — "Requirement — Store Review Workflow"
let _refNo='', _reqId='';
let _date='', _remarks='';
let _products=[]; // [{itemCode,itemName,itemDesc,requiredQty,batches:[...]}]
let _editId=null;
let _saving=false, _created=null;
let _list=[], _listLoading=false, _expanded=null;
let _linkedWo={}; // { [intimationId]: [{id,woNo,productCode,batchNumber}] } — Delete stays fully blocked once ANY Work Order exists; Edit now locks only the SPECIFIC (productCode,batchNumber) rows that already have one — see startEdit()
let _linkedWoAll={}; // same shape, but REJECTED Work Orders included — used only to keep an
// already-referenced row's Batch No. (and the row itself) from being
// changed/removed even while the rest of that row stays editable for a
// rejected-and-resubmitting Work Order — see _editLockedSet/_batchNoLockedSet
let _batchNoLockedSet=new Set(); // `${itemCode}|${batchNo}` keys whose Batch No. field (and row-remove button) must stay locked this edit session, even for rows the fuller _editLockedSet leaves otherwise editable
let _editLockedSet=new Set(); // `${itemCode}|${batchNo}` keys locked for the CURRENTLY open edit session — those rows are read-only (already used by a generated Work Order); everything else in the document stays freely editable
let _viewSearch='', _viewStatus='';
let _viewDateFrom='', _viewDateTo=''; // filters by the Intimation's own Date field
let _viewPage=0; const VF_PAGE_SIZE=20;
let _sapBatchMatches=[], _sapBatchQuery='', _sapBatchTimer=null; // cross-reference results from /sap/lookup/batch-in-sap — see scheduleSapBatchLookup()
let _batchManagedItems=new Set(); // item codes SAP itself tracks by batch (OITM.ManBtchNum='Y') — Batch No./MFG/EXP become required per item automatically
let _biAllowExceedPending=false; // Admin → System Settings — when true, Total Units may exceed the requirement's pending qty
// SFG (Semi-Finished Good) workflow — Admin → System Settings → Batch
// Issuance → "Enable SFG Workflow". Separate tab: direct item search
// (restricted server-side to SFG-classified items), no Requirement at all.
let _biSfgEnabled=false;
let _sfgProducts=[]; // same shape as _products: [{itemCode,itemName,itemDesc,batches:[...],issueDate}]
let _sfgDate='', _sfgRemarks='', _sfgSaving=false;
let _sfgSearchVal='', _sfgSearchTimer=null;
// "Duplicate Work Order" hand-off (see public/work-order.html's duplicateWO())
// — arrives here as ?duplicate=1 with the source WO's product/qty stashed in
// sessionStorage. Pre-fills the "Without Intimation" tab so the user only
// has to confirm Batch No./Issue Date/MFG-EXP, then on submit skips the
// normal success screen and jumps straight into Work Order creation
// (/work-order?from=<new intimation id>), which itself reads the SAME
// sessionStorage payload to copy over the item selection.
let _duplicateFlow=false, _duplicateSourceItem=null;
let _sfgEditId=null; // set by startEdit() when editing a no-requirement ("Without Intimation") record
// batchSize = "Total Units" (a quantity/count). batchVolume = the NEW
// "Batch Size (Volume, Ltr)" field — entered once here, later seeds Work
// Order's Solution Batch Size when a WO is generated from this batch (see
// pickBatch() in work-order.html) — distinct name deliberately, to avoid
// colliding with the existing quantity field of almost the same name.
function blankBatch(){return{batchNo:'',mfgDate:'',expDate:'',market:'',batchSize:'',batchVolume:'',solutionVolumes:[]};}
// Admin → System Settings → Batch Issuance → "Multi-Solution Volumes" — off
// by default. When on, a batch can list per-solution Name+Volume pairs
// instead of relying on the single batchVolume above (which Work Order
// generation would otherwise broadcast to EVERY Solution group — wrong for
// a multi-solution product). Loaded in init() below.
let _biMultiSolVolEnabled=false;
let _solVolModal=null; // {mode:'req'|'sfg', pi, bi} while the per-solution editor is open
// The Batch Volume cell — plain single-number input, plus (when the setting
// above is on) a small button opening the per-solution editor. Shared by
// both the "req" (Requirement-based) and "sfg" (Without Intimation) tables.
function batchVolumeCell(mode,pi,bi,b,dis){
const n=(b.solutionVolumes||[]).length;
const btn=_biMultiSolVolEnabled?`<button type="button" class="b-solvol" data-solvol="${mode}:${pi}:${bi}" title="Per-solution volumes — overrides the single value above for a multi-solution product">${n?`Solutions (${n})`:'+ Solutions'}</button>`:'';
return `<td>
<input class="fi" ${dis} type="number" min="0" step="any" data-p="${pi}" data-b="${bi}" data-f="batchVolume" placeholder="0" value="${esc(b.batchVolume)}" title="Feeds Work Order's Solution Batch Size when generated from this batch"/>
${btn}
</td>`;
}
// Auto-detect a product's Solution(s) straight from its own SAP BOM, so the
// user never has to TYPE a solution name (and can't typo/mismatch it against
// what Work Order generation later matches by). A "Solution" here is any BOM
// line whose item name contains "solution" AND has its own sub-BOM (a leaf
// item can coincidentally have "solution" in its name too, e.g. a purchased
// "…SOLUTION…" reagent — excluded since it has no ProductTreeLines of its
// own). Mirrors the same rule work-order.html's scanTree() uses, trimmed
// down to just what's needed here (find names/codes, not explode recipes).
const _bomSolutionCache={}; // itemCode -> [{code,name}], to avoid re-fetching if the modal reopens
function isSolutionName(nm){return /solution/i.test(nm||'');}
async function fetchBomFor(code){try{const r=await api('GET',`/sap/bom/${encodeURIComponent(code)}?company=${encodeURIComponent(_co)}`);if(r.success&&r.data)return r.data;}catch(_e){}return null;}
async function findSolutionsInBOM(code,depth,visiting,out){
depth=depth||0;visiting=visiting||new Set();out=out||[];
if(depth>8||visiting.has(code))return out;
visiting.add(code);
const bom=await fetchBomFor(code);
if(!bom||!bom.ProductTreeLines||!bom.ProductTreeLines.length)return out;
for(const l of bom.ProductTreeLines){
if(l.ItemType==='pit_Resource')continue;
const cc=l.ItemCode||'',nm=l.ItemName||'';
if(!cc)continue;
if(isSolutionName(nm)){
const subBom=await fetchBomFor(cc);
if(subBom&&subBom.ProductTreeLines&&subBom.ProductTreeLines.length){
if(!out.some(s=>s.code===cc))out.push({code:cc,name:nm});
continue; // don't recurse into the Solution's OWN recipe — that's its ingredients, not another product-level branch
}
}
await findSolutionsInBOM(cc,depth+1,visiting,out);
}
return out;
}
async function autoDetectSolutions(itemCode){
if(!itemCode)return [];
if(_bomSolutionCache[itemCode])return _bomSolutionCache[itemCode];
const found=await findSolutionsInBOM(itemCode,0,new Set(),[]);
_bomSolutionCache[itemCode]=found;
return found;
}
function currentSolVolProduct(){
if(!_solVolModal)return null;
const arr=_solVolModal.mode==='sfg'?_sfgProducts:_products;
return arr[_solVolModal.pi];
}
function currentSolVolBatch(){
const p=currentSolVolProduct();
return p&&p.batches[_solVolModal.bi];
}
async function openSolVolModal(mode,pi,bi){
_solVolModal={mode,pi,bi};
renderSolVolModal();
const b=currentSolVolBatch();
const p=currentSolVolProduct();
// Only auto-fill when the list is empty — never overwrite volumes the
// user already typed just because the modal reopened.
if(b&&p&&!(b.solutionVolumes&&b.solutionVolumes.length)){
await rescanSolutions();
}
}
async function rescanSolutions(){
const b=currentSolVolBatch(),p=currentSolVolProduct();
if(!b||!p)return;
const btn=document.getElementById('solvol-rescan');
if(btn){btn.disabled=true;btn.innerHTML='<span class="sp"></span> Scanning BOM…';}
const found=await autoDetectSolutions(p.itemCode);
if(found.length){
// Keep any volume already typed for a code that's still found; drop
// stale auto-detected rows for codes no longer in the BOM (e.g. item
// code was corrected). Manually-added rows (no code) are left alone.
const manual=(b.solutionVolumes||[]).filter(sv=>!sv.code);
const existingByCode={};(b.solutionVolumes||[]).forEach(sv=>{if(sv.code)existingByCode[sv.code]=sv;});
b.solutionVolumes=[...found.map(f=>({code:f.code,name:f.name,volume:(existingByCode[f.code]&&existingByCode[f.code].volume)||''})),...manual];
}else if(!(b.solutionVolumes||[]).length){
toast(`No Solutions detected in ${p.itemCode}'s BOM — add one manually below if needed, or use the single Batch Volume field instead.`,'err');
}
renderSolVolModal();
}
function closeSolVolModal(){
const mode=_solVolModal&&_solVolModal.mode;
_solVolModal=null;
const ov=document.getElementById('solvol-ov');if(ov)ov.remove();
// Re-render the underlying table so the "Solutions (n)" button's count
// reflects whatever was just added/removed.
if(mode==='sfg')renderSfgOnly(); else render();
}
function renderSolVolModal(){
let ov=document.getElementById('solvol-ov');
if(!ov){ov=document.createElement('div');ov.id='solvol-ov';ov.className='solvol-ov';document.body.appendChild(ov);}
const b=currentSolVolBatch();
if(!b){ov.remove();return;}
b.solutionVolumes=b.solutionVolumes||[];
const rows=b.solutionVolumes.map((sv,i)=>`
<div class="solvol-row">
${sv.code
?`<div class="solvol-name-auto" title="Auto-detected from BOM — ${esc(sv.code)}">${esc(sv.name)}</div>`
:`<input class="fi" placeholder="Solution name (e.g. CPD IP)" value="${esc(sv.name)}" data-svf="name" data-svi="${i}"/>`}
<input class="fi" type="number" min="0" step="any" placeholder="Volume (Ltr)" value="${esc(sv.volume)}" data-svf="volume" data-svi="${i}"/>
<button type="button" class="b-del" data-svrm="${i}" title="Remove"><i data-lucide="trash-2" style="width:14px;height:14px"></i></button>
</div>`).join('');
ov.innerHTML=`
<div class="solvol-modal">
<div class="solvol-hd">Per-Solution Volumes<button type="button" class="solvol-x" id="solvol-close">✕</button></div>
<div class="solvol-bd">
<div class="solvol-hint">Solutions auto-detected from this product's own BOM are shown by name (locked — can't be mistyped) with the item code as a match key; just fill in each Volume (Ltr). Work Order generation applies each volume to that EXACT solution, never a guess by typed text. Leave this empty to keep using the single Batch Volume field for a one-solution product.</div>
<div id="solvol-rows">${rows||'<div class="solvol-empty">No solutions yet — scanning, or add one manually below.</div>'}</div>
<div style="display:flex;gap:8px;margin-top:4px">
<button type="button" class="b-addrow" id="solvol-rescan" style="flex:1">↻ Re-scan BOM</button>
<button type="button" class="b-addrow" id="solvol-add" style="flex:1">+ Add Manually</button>
</div>
</div>
<div class="solvol-ft"><button type="button" class="btn b-primary" id="solvol-done">Done</button></div>
</div>`;
if(window.lucide)lucide.createIcons();
document.getElementById('solvol-close').onclick=closeSolVolModal;
document.getElementById('solvol-done').onclick=closeSolVolModal;
document.getElementById('solvol-rescan').onclick=rescanSolutions;
document.getElementById('solvol-add').onclick=()=>{b.solutionVolumes.push({name:'',volume:''});renderSolVolModal();};
ov.querySelectorAll('[data-svrm]').forEach(btn=>btn.onclick=()=>{b.solutionVolumes.splice(+btn.dataset.svrm,1);renderSolVolModal();});
ov.querySelectorAll('[data-svf]').forEach(inp=>inp.oninput=function(){b.solutionVolumes[+this.dataset.svi][this.dataset.svf]=this.value;});
ov.onclick=(e)=>{if(e.target===ov)closeSolVolModal();};
}
function esc(v){return(v==null?'':v).toString().replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
function q(s){return document.querySelector(s);}
function today(){return new Date().toISOString().slice(0,10);}
function fmtDate(d){if(!d)return'—';try{return new Date(d).toLocaleDateString('en-IN',{day:'2-digit',month:'short',year:'numeric'});}catch{return d;}}
// MFG/EXP — 6 selectable formats (2 full-date + 4 month-only). Entry is
// picker-only (see dateCell()/openDatePickerUnified() 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 addThreeYears()/parseMEDate() work on whatever
// string is already saved, regardless of which of the 6 formats it's in.
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];}
// Which of the 6 formats does this saved string match? null if none.
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 validMfgExp(v){return !v||!v.trim()||!!parseMEDate(v);}
// Build the output string for a native picker's ISO value ("YYYY-MM-DD" for
// full-date formats, "YYYY-MM" for month formats) in the given format id.
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}`;
}
}
// Parse ANY of the 6 saved formats to a Date (day defaults to 1 for
// month-only formats) so Exp can be validated as strictly later than Mfg.
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);
}
// Blood-bag finished goods carry a fixed 3-year shelf life: Exp = Mfg + 3 years
// − 1 unit, matching the printed shelf-life convention, re-emitted in the SAME
// format the Mfg date was in. For a full-date Mfg that's − 1 day (10-Jan-2026
// → 09-Jan-2029); for a month-only Mfg there's no day to subtract from, so
// it's − 1 month instead. Returns '' if invalid.
function addThreeYears(v){
const fmtId=detectFormat(v);
const dt=parseMEDate(v);
if(!fmtId||!dt)return '';
const hasDay=fmtId==='DMY'||fmtId==='DMYn';
const nd=new Date(dt.getFullYear()+3,dt.getMonth(),hasDay?dt.getDate():1);
if(hasDay)nd.setDate(nd.getDate()-1); else nd.setMonth(nd.getMonth()-1);
const iso=`${nd.getFullYear()}-${pad2(nd.getMonth()+1)}${hasDay?'-'+pad2(nd.getDate()):''}`;
return formatByPicked(iso,fmtId);
}
// For blood-bag FG, the Mfg date drives the Exp date: whenever the user sets or
// CHANGES a valid Mfg, Exp is (re)computed to Mfg + 3 years — in create AND edit
// mode. The user can still override by editing Exp afterwards (until Mfg changes
// again). A partial/invalid Mfg leaves the existing Exp untouched.
function maybeAutoExp(pi,bi){
const p=_products[pi],b=p&&p.batches[bi];
if(!p||!b||!p.bloodBagFG)return;
const ne=addThreeYears(b.mfgDate);
if(ne)b.expDate=ne;
}
// Alert the instant a batch's Exp is not later than its Mfg (both fully
// entered) — immediate feedback, not only on save.
function checkExpAfterMfg(pi,bi){
const b=_products[pi]&&_products[pi].batches[bi];if(!b)return;
const md=parseMEDate(b.mfgDate),ed=parseMEDate(b.expDate);
if(md&&ed&&ed<=md)toast(`${_products[pi].itemCode}: EXP date (${b.expDate}) must be later than MFG date (${b.mfgDate})`,'err');
}
// Flag which loaded products are blood-bag FG (item group 101/103, minus the
// two CAPD machines) so maybeAutoExp knows where the 3-year rule applies.
async function classifyBloodBag(){
const codes=[...new Set(_products.map(p=>p.itemCode).filter(Boolean))];
if(!codes.length)return;
try{
const r=await api('GET',`/sap/blood-bag-fg-items?codes=${encodeURIComponent(codes.join(','))}&company=${encodeURIComponent(_co)}`);
const set=new Set((r.data||[]).map(c=>String(c).toUpperCase()));
_products.forEach(p=>{p.bloodBagFG=set.has(String(p.itemCode).toUpperCase());});
}catch(_e){}
}
// A single reusable native date/month input, repositioned under whichever
// calendar icon was clicked (so the browser's picker popup opens right next
// to it) — kept genuinely on-screen (not off-screen/hidden), which is also
// what makes showPicker() keep working reliably on repeat opens.
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;
}
// Remembers which of the 6 formats is currently selected per ROW (keyed
// "pi:bi:mode") — MFG and EXP always share the same format (changing one
// changes the other), so this is keyed one level up from the individual
// field. Pure UI state, not saved; defaults to whatever format the existing
// Mfg (or, failing that, Exp) value is already in, else DD-MMM-YYYY.
const _dateFmtSel={};
function rowDateFmt(pi,bi,mode,b){
const key=`${pi}:${bi}:${mode}`;
if(!_dateFmtSel[key])_dateFmtSel[key]=detectFormat(b&&b.mfgDate)||detectFormat(b&&b.expDate)||'DMY';
return _dateFmtSel[key];
}
// Re-express an already-set date string in a different one of the 6
// formats (e.g. switching the row's format dropdown from DD-MMM-YYYY to
// YYYY-MM converts "15-Aug-2026" → "2026-08"). 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);
}
// Entry is picker-only — the text field is read-only, set exclusively via
// the native date/month picker opened by the calendar button, formatted per
// whichever of the 6 formats is currently selected in the dropdown next to
// it. Eliminates free-typed malformed dates entirely. `mode` picks which
// array/handlers this cell belongs to: 'req' (_products) or 'sfg' (_sfgProducts).
function dateCell(pi,bi,field,value,mode,fmtId,locked){
mode=mode||'req';
const dis=locked?'disabled':'';
return `<div class="date-cell">
<select class="fi" ${dis} data-dfmt="${pi}:${bi}:${mode}" style="width:108px;flex-shrink:0;font-size:11px;padding:4px 20px 4px 6px">
${DATE_FORMATS.map(f=>`<option value="${f.id}" ${fmtId===f.id?'selected':''}>${f.label}</option>`).join('')}
</select>
<input class="fi" data-p="${pi}" data-b="${bi}" data-f="${field}" value="${esc(value)}" placeholder="Pick a date" readonly style="flex:1;min-width:0;cursor:${locked?'not-allowed':'pointer'};background:var(--surface2)" ${locked?'':`data-dpopen="${pi}:${bi}:${field}:${mode}"`}/>
${locked?'':`<button type="button" class="date-pick-btn" data-dp="${pi}:${bi}:${field}:${mode}" title="Pick date"><i data-lucide="calendar" style="width:13px;height:13px"></i></button>`}
</div>`;
}
function openDatePickerUnified(pi,bi,field,mode,btn){
const arr=mode==='sfg'?_sfgProducts:_products;
const b=arr[pi].batches[bi];
const fmtId=rowDateFmt(pi,bi,mode,b);
const kind=fmtById(fmtId).kind;
const el=getDatePickerEl();
el.type=kind==='full'?'date':'month';
// Exp can't be older than Mfg — constrain the picker's min from the Mfg date
// (like the requirement's "To > From"). Date picker → Mfg+1 day; month
// picker → the month AFTER Mfg's month (both enforce strictly later).
el.min='';
if(field==='expDate'){
const md=parseMEDate(b.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();
// body has zoom:1.10 (theme.css) — fixed-position coords inside a zoomed
// element are scaled, so divide back (same fix as the item-search dropdown).
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;
b[field]=formatByPicked(el.value,fmtId);
if(mode==='sfg'){
if(field==='mfgDate')maybeAutoExpSfg(pi,bi);
checkExpAfterMfgSfg(pi,bi);
renderSfgOnly();
}else{
if(field==='mfgDate')maybeAutoExp(pi,bi); // blood-bag FG → Exp = Mfg + 3 years (create & edit)
checkExpAfterMfg(pi,bi); // immediate Exp>Mfg alert
render();
}
};
el.focus();
if(el.showPicker)el.showPicker();
}
function checkExpAfterMfgSfg(pi,bi){
const b=_sfgProducts[pi]&&_sfgProducts[pi].batches[bi];if(!b)return;
const md=parseMEDate(b.mfgDate),ed=parseMEDate(b.expDate);
if(md&&ed&&ed<=md)toast(`${_sfgProducts[pi].itemCode}: EXP date (${b.expDate}) must be later than MFG date (${b.mfgDate})`,'err');
}
// Unlike Requirement-mode's maybeAutoExp() (which only applies the 3-year
// shelf-life rule to blood-bag-classified items), the SFG tab applies it to
// EVERY item unconditionally — being added here at all is already the
// signal, no separate blood-bag item-group check needed.
function maybeAutoExpSfg(pi,bi){
const p=_sfgProducts[pi],b=p&&p.batches[bi];
if(!p||!b)return;
const ne=addThreeYears(b.mfgDate);
if(ne)b.expDate=ne;
}
// ── Floating item-search dropdown (shared, attached to <body>) ──
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=(r.width/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;}
let _ddInput=null;
window.addEventListener('scroll',()=>{if(_ddInput)positionDD(_ddInput);},true);
window.addEventListener('resize',hideDD);
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(),4000);}
async function loadReqs(){try{const r=await api('GET',`/requirements?company=${encodeURIComponent(_co)}`);_reqs=r.data||[];}catch(e){toast(e.message,'err');_reqs=[];}render();}
// Which of the currently-loaded products SAP itself tracks by batch
// (OITM.ManBtchNum='Y') — drives the item-wise "Batch Required" badge and
// the client-side presence check in submit(). Non-fatal on failure: the
// authoritative check always runs server-side regardless.
async function loadBatchManagedItems(){
const codes=[...new Set(_products.map(p=>p.itemCode).filter(Boolean))];
if(!codes.length){_batchManagedItems=new Set();return;}
try{
const r=await api('GET',`/sap/batch-managed-items?codes=${encodeURIComponent(codes.join(','))}&company=${encodeURIComponent(_co)}`);
_batchManagedItems=new Set(r.data||[]);
}catch(_e){_batchManagedItems=new Set();}
render();
}
function selectRequirement(id){
const req=_reqs.find(x=>String(x.id)===String(id));
if(!req){_reqId='';_refNo='';_products=[];render();return;}
_reqId=req.id;_refNo=req.refNo;
_products=(req.lines||[])
.filter(l=>Number(l.pendingQty!=null?l.pendingQty:l.requiredQty)>0)
.map(l=>{
const pendingQty=l.pendingQty!=null?l.pendingQty:(l.requiredQty!=null?l.requiredQty:'');
return{
itemCode:l.itemCode||'',itemName:l.itemName||'',itemDesc:l.itemDesc||'',
requiredQty:l.requiredQty!=null?l.requiredQty:'',
pendingQty,
issueDate:today(),
// Default a single batch's Total Units to the full pending qty — most
// items are issued in one batch; the user can split into multiple
// batches (adjusting each) when needed.
batches:[{...blankBatch(),batchSize:pendingQty}]
};
});
render();
classifyBloodBag(); // background: enables Mfg+3yr auto-expiry for blood-bag FG
loadBatchManagedItems();
}
// ─────────── 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="layers"></i> Batch Issuance Intimation</div>
<div class="page-sub">Raise batch issuance against a logistics requirement</div>
</div>
<div class="tabs">
<button class="tab ${_tab==='create'?'on':''}" data-t="create">${_editId?'Edit':'+ New'} Intimation</button>
${_biSfgEnabled?`<button class="tab ${_tab==='sfg'?'on':''}" data-t="sfg">🏭 Without Intimation</button>`:''}
<button class="tab ${_tab==='view'?'on':''}" data-t="view">View Intimations</button>
</div>
<div id="body"></div>`;
q('#body').innerHTML=_tab==='create'?renderCreate():_tab==='sfg'?renderSfgCreate():renderView();
document.querySelectorAll('.tab').forEach(b=>b.onclick=()=>{const t=b.dataset.t;if(t==='view'){_tab='view';loadList();}else if(t==='sfg'){_sfgEditId=null;resetSfg();_tab='sfg';render();}else{newDoc();}});
if(_tab==='create')wireCreate();else if(_tab==='sfg')wireSfgCreate();else wireView();
lucide.createIcons();
}
function renderCreate(){
const gateOn=_reqReviewEnabled&&_reqReviewHardGate;
const reqOptions=_reqs.filter(r=>r.totalPending==null||r.totalPending>0).map(r=>{
const pend=(r.lines||[]).filter(l=>Number(l.pendingQty!=null?l.pendingQty:l.requiredQty)>0).length;
const notReviewed=gateOn&&(r.reviewStage||0)!==2;
const tag=notReviewed?(r.reviewStage===1?' ⏳ awaiting Store review':' ⚠ not yet shared with Store'):'';
return `<option value="${r.id}" ${String(_reqId)===String(r.id)?'selected':''} ${notReviewed?'disabled':''}>${esc(r.refNo)} — ${pend} item(s) pending${tag}</option>`;
}).join('');
const selectRow=_editId
? `<div class="fg"><label class="fl">Requirement Ref No</label><div class="ref-pill">${esc(_refNo)}</div></div>`
: `<div class="fg"><label class="fl">Requirement (Ref No) *</label><select class="fi" id="sel-req"><option value="">— Select requirement —</option>${reqOptions}</select></div>`;
const productsHtml=_products.length?_products.map((p,pi)=>{
const req=_batchManagedItems.has(p.itemCode);
const usedQty=p.batches.reduce((s,b)=>s+(Number(b.batchSize)||0),0);
const overPending=p.pendingQty!=null&&usedQty>Number(p.pendingQty);
const batchRows=p.batches.map((b,bi)=>{
// Already used by a generated Work Order — read-only for the rest of
// this edit session (see startEdit()); every other batch stays
// editable, and new ones can always be added.
const rowLocked=_editLockedSet.has(`${p.itemCode}|${b.batchNo}`);
const dis=rowLocked?'disabled':'';
// Batch No. itself (and this row's remove button) stays locked even for
// a row a rejected Work Order references — otherwise fixing the other
// fields could also let the Batch No. drift, silently breaking that
// rejected order's (productCode,batchNumber) link back to this row.
const noLocked=rowLocked||_batchNoLockedSet.has(`${p.itemCode}|${b.batchNo}`);
const noDis=noLocked?'disabled':'';
return `
<tr${rowLocked?' title="Already used by a generated Work Order — locked"':(noLocked?' title="Batch No. is used by a rejected Work Order — number is locked, other fields can still be corrected"':'')}>
<td><input class="fi" ${noDis} data-p="${pi}" data-b="${bi}" data-f="batchNo" placeholder="${req?'Batch No. *':'Batch No.'}" value="${esc(b.batchNo)}" style="${req?'border-color:var(--amber)':''}"/></td>
<td>${dateCell(pi,bi,'mfgDate',b.mfgDate,'req',rowDateFmt(pi,bi,'req',b),rowLocked)}</td>
<td>${dateCell(pi,bi,'expDate',b.expDate,'req',rowDateFmt(pi,bi,'req',b),rowLocked)}</td>
<td><input class="fi" ${dis} data-p="${pi}" data-b="${bi}" data-f="market" placeholder="Market" value="${esc(b.market)}"/></td>
<td><input class="fi" ${dis} type="number" min="0" step="any" data-p="${pi}" data-b="${bi}" data-f="batchSize" placeholder="0" value="${esc(b.batchSize)}"/></td>
${batchVolumeCell('req',pi,bi,b,dis)}
<td class="td-act">${noLocked?`<i data-lucide="lock" style="width:14px;height:14px;color:var(--text3)" title="${rowLocked?'Locked — Work Order already generated':'Batch No. locked — a rejected Work Order references it'}"></i>`:`<button class="b-del" data-rmb="${pi}:${bi}" title="Remove batch"><i data-lucide="trash-2" style="width:14px;height:14px"></i></button>`}</td>
</tr>`;}).join('');
return `
<div class="prod-block">
<div class="prod-hd">
<span class="prod-code">${esc(p.itemCode)}</span>
<span class="prod-name">${esc(p.itemName||p.itemDesc||'')}</span>
${req?`<span class="ref-pill" style="background:var(--amber-light,#fef3c7);color:var(--amber,#b45309)" title="SAP tracks this item by batch — Batch No./MFG/EXP are mandatory">Batch Required</span>`:''}
<span class="prod-req" style="${overPending?'color:var(--red);font-weight:700':''}">${p.pendingQty!=null?`Pending: ${esc(p.pendingQty)} of ${esc(p.requiredQty)}`:`Required: ${esc(p.requiredQty)}`}${overPending?(_biAllowExceedPending?' · exceeds pending (allowed by admin setting)':' · exceeds pending!'):''}</span>
</div>
<div class="prod-bd">
<div class="fg" style="max-width:220px;margin-bottom:10px"><label class="fl">Issue Date *</label><input class="fi" type="date" data-pissue="${pi}" value="${esc(p.issueDate||today())}" required/></div>
<div class="tbl-wrap"><table class="bt-tbl">
<thead><tr><th>Batch No.</th><th>Date of Mfg.</th><th>Date of Exp.</th><th>Market</th><th>Total Units</th><th>Batch Size (Ltr)</th><th class="td-act"></th></tr></thead>
<tbody>${batchRows}</tbody>
</table></div>
<button class="btn b-add" data-addb="${pi}"><i data-lucide="plus" style="width:14px;height:14px"></i> Add Batch</button>
</div>
</div>`;
}).join('') : `<div class="empty-msg">Select a requirement above to load its products.</div>`;
return `
<div class="card">
<div class="card-hd"><i data-lucide="${_editId?'pencil':'layers'}" style="width:16px;height:16px"></i> ${_editId?'Edit Intimation':'New Intimation'} ${_refNo?`<span style="margin-left:auto" class="ref-pill">${esc(_refNo)}</span>`:''}</div>
<div class="card-bd">
<div class="grid3">
${selectRow}
<div class="fg"><label class="fl">Date</label><input class="fi" type="date" id="inp-date" value="${esc(_date)}"/></div>
</div>
${productsHtml}
<div class="fg" style="margin-top:16px"><label class="fl">Remarks (optional)</label><input class="fi" id="inp-remarks" placeholder="Notes…" value="${esc(_remarks)}"/></div>
<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?'disabled':''}>${_saving?'<span class="sp"></span> Saving…':(_editId?'Update Intimation':'Send Intimation')}</button>
</div>
</div>
</div>`;
}
function wireCreate(){
const selReq=q('#sel-req');if(selReq)selReq.onchange=function(){selectRequirement(this.value);};
q('#inp-date').onchange=function(){_date=this.value;};
q('#inp-remarks').oninput=function(){_remarks=this.value;};
document.querySelectorAll('[data-pissue]').forEach(el=>el.onchange=function(){_products[+this.dataset.pissue].issueDate=this.value;});
document.querySelectorAll('[data-f]').forEach(el=>el.oninput=function(){
const pi=+this.dataset.p,bi=+this.dataset.b,f=this.dataset.f;_products[pi].batches[bi][f]=this.value;
if(f==='mfgDate'){
maybeAutoExp(pi,bi); // recomputes Exp once Mfg is a complete valid date
const b=_products[pi].batches[bi];
const ex=document.querySelector(`input[data-p="${pi}"][data-b="${bi}"][data-f="expDate"]`);
if(ex&&ex.value!==b.expDate)ex.value=b.expDate; // reflect recomputed Exp live, without disturbing the Mfg field
}
if(f==='mfgDate'||f==='expDate')checkExpAfterMfg(pi,bi); // immediate Exp>Mfg alert
});
document.querySelectorAll('[data-dp]').forEach(btn=>btn.onclick=function(){
const[pi,bi,field,mode]=this.dataset.dp.split(':');openDatePickerUnified(+pi,+bi,field,mode,this);});
document.querySelectorAll('[data-dpopen]').forEach(inp=>inp.onclick=function(){
const[pi,bi,field,mode]=this.dataset.dpopen.split(':');openDatePickerUnified(+pi,+bi,field,mode,this);});
document.querySelectorAll('[data-dfmt]').forEach(sel=>sel.onchange=function(){
const[pi,bi,mode]=this.dataset.dfmt.split(':');
_dateFmtSel[`${pi}:${bi}:${mode}`]=this.value;
const b=_products[+pi]&&_products[+pi].batches[+bi];
if(b){b.mfgDate=reformatDate(b.mfgDate,this.value);b.expDate=reformatDate(b.expDate,this.value);}
render();
});
document.querySelectorAll('[data-addb]').forEach(b=>b.onclick=()=>{_products[+b.dataset.addb].batches.push(blankBatch());render();});
document.querySelectorAll('[data-rmb]').forEach(b=>b.onclick=()=>{const[pi,bi]=b.dataset.rmb.split(':').map(Number);_products[pi].batches.splice(bi,1);if(!_products[pi].batches.length)_products[pi].batches=[blankBatch()];render();});
document.querySelectorAll('[data-solvol]').forEach(b=>b.onclick=()=>{const[mode,pi,bi]=b.dataset.solvol.split(':');openSolVolModal(mode,+pi,+bi);});
q('#btn-cancel').onclick=()=>{if(_editId){_editId=null;_tab='view';loadList();}else{newDoc();}};
q('#btn-submit').onclick=submit;
}
// ─────────── SFG (Semi-Finished Good) — no Requirement ───────────
function renderSfgCreate(){
const productsHtml=_sfgProducts.length?_sfgProducts.map((p,pi)=>{
const req=_batchManagedItems.has(p.itemCode);
const batchRows=p.batches.map((b,bi)=>{
// Already used by a generated Work Order — read-only for the rest of
// this edit session (see startEdit()); every other batch stays
// editable, and new ones can always be added.
const rowLocked=_editLockedSet.has(`${p.itemCode}|${b.batchNo}`);
const dis=rowLocked?'disabled':'';
// Batch No. itself (and this row's remove button) stays locked even for
// a row a rejected Work Order references — otherwise fixing the other
// fields could also let the Batch No. drift, silently breaking that
// rejected order's (productCode,batchNumber) link back to this row.
const noLocked=rowLocked||_batchNoLockedSet.has(`${p.itemCode}|${b.batchNo}`);
const noDis=noLocked?'disabled':'';
return `
<tr${rowLocked?' title="Already used by a generated Work Order — locked"':(noLocked?' title="Batch No. is used by a rejected Work Order — number is locked, other fields can still be corrected"':'')}>
<td><input class="fi" ${noDis} data-p="${pi}" data-b="${bi}" data-f="batchNo" placeholder="${req?'Batch No. *':'Batch No.'}" value="${esc(b.batchNo)}" style="${req?'border-color:var(--amber)':''}"/></td>
<td>${dateCell(pi,bi,'mfgDate',b.mfgDate,'sfg',rowDateFmt(pi,bi,'sfg',b),rowLocked)}</td>
<td>${dateCell(pi,bi,'expDate',b.expDate,'sfg',rowDateFmt(pi,bi,'sfg',b),rowLocked)}</td>
<td><input class="fi" ${dis} data-p="${pi}" data-b="${bi}" data-f="market" placeholder="Market" value="${esc(b.market)}"/></td>
<td><input class="fi" ${dis} type="number" min="0" step="any" data-p="${pi}" data-b="${bi}" data-f="batchSize" placeholder="0" value="${esc(b.batchSize)}"/></td>
${batchVolumeCell('sfg',pi,bi,b,dis)}
<td class="td-act">${noLocked?`<i data-lucide="lock" style="width:14px;height:14px;color:var(--text3)" title="${rowLocked?'Locked — Work Order already generated':'Batch No. locked — a rejected Work Order references it'}"></i>`:`<button class="b-del" data-rmb="${pi}:${bi}" title="Remove batch"><i data-lucide="trash-2" style="width:14px;height:14px"></i></button>`}</td>
</tr>`;}).join('');
const prodLocked=p.batches.some(b=>_editLockedSet.has(`${p.itemCode}|${b.batchNo}`)||_batchNoLockedSet.has(`${p.itemCode}|${b.batchNo}`));
return `
<div class="prod-block">
<div class="prod-hd">
<span class="prod-code">${esc(p.itemCode)}</span>
<span class="prod-name">${esc(p.itemName||p.itemDesc||'')}</span>
${req?`<span class="ref-pill" style="background:var(--amber-light,#fef3c7);color:var(--amber,#b45309)" title="SAP tracks this item by batch — Batch No./MFG/EXP are mandatory">Batch Required</span>`:''}
${prodLocked?`<span title="Has a batch already used by a generated Work Order — item can't be removed" style="margin-left:auto"><i data-lucide="lock" style="width:14px;height:14px;color:var(--text3)"></i></span>`:`<button type="button" class="b-del" data-rmprod="${pi}" title="Remove item" style="margin-left:auto"><i data-lucide="x" style="width:14px;height:14px"></i></button>`}
</div>
<div class="prod-bd">
<div class="fg" style="max-width:220px;margin-bottom:10px"><label class="fl">Issue Date *</label><input class="fi" type="date" data-pissue="${pi}" value="${esc(p.issueDate||today())}" required/></div>
<div class="tbl-wrap"><table class="bt-tbl">
<thead><tr><th>Batch No.</th><th>Date of Mfg.</th><th>Date of Exp.</th><th>Market</th><th>Total Units</th><th>Batch Size (Ltr)</th><th class="td-act"></th></tr></thead>
<tbody>${batchRows}</tbody>
</table></div>
<button type="button" class="btn b-add" data-addb="${pi}"><i data-lucide="plus" style="width:14px;height:14px"></i> Add Batch</button>
</div>
</div>`;
}).join('') : `<div class="empty-msg">Search and add an item above to get started.</div>`;
return `
<div class="card">
<div class="card-hd"><i data-lucide="${_sfgEditId?'pencil':'factory'}" style="width:16px;height:16px"></i> ${_sfgEditId?'Edit Without Intimation':'New Without Intimation'} <span style="margin-left:auto;font-size:11px;color:var(--text3);font-weight:400">No Requirement needed</span></div>
<div class="card-bd">
<div class="grid3">
<div class="fg" style="grid-column:span 2"><label class="fl">Search Item *</label>
<div class="search-wrap"><input class="fi" id="sfg-item-search" placeholder="Search item code / name…" value="${esc(_sfgSearchVal)}" autocomplete="off"/></div>
</div>
<div class="fg"><label class="fl">Date</label><input class="fi" type="date" id="sfg-inp-date" value="${esc(_sfgDate)}"/></div>
</div>
${productsHtml}
<div class="fg" style="margin-top:16px"><label class="fl">Remarks (optional)</label><input class="fi" id="sfg-inp-remarks" placeholder="Notes…" value="${esc(_sfgRemarks)}"/></div>
<div class="footer-row">
<button class="btn b-cancel" id="sfg-btn-cancel">${_sfgEditId?'Cancel':'Reset'}</button>
<button class="btn b-primary" id="sfg-btn-submit" ${_sfgSaving?'disabled':''}>${_sfgSaving?'<span class="sp"></span> Saving…':(_sfgEditId?'Update Intimation':'Send Intimation')}</button>
</div>
</div>
</div>`;
}
// Patches only #body (not a full render()) so an in-progress search/edit in
// this tab is never disrupted — same principle used throughout this app.
function renderSfgOnly(){const b=q('#body');if(b){b.innerHTML=renderSfgCreate();wireSfgCreate();if(window.lucide)lucide.createIcons();}}
function resetSfg(){_sfgProducts=[];_sfgDate=today();_sfgRemarks='';_sfgSearchVal='';_editLockedSet=new Set();_batchNoLockedSet=new Set();}
async function fetchSfgItems(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 loadSfgBatchManagedItems(){
const codes=[...new Set(_sfgProducts.map(p=>p.itemCode).filter(Boolean))];
if(!codes.length)return;
try{
const r=await api('GET',`/sap/batch-managed-items?codes=${encodeURIComponent(codes.join(','))}&company=${encodeURIComponent(_co)}`);
(r.data||[]).forEach(c=>_batchManagedItems.add(c));
}catch(_e){}
renderSfgOnly();
}
function addSfgItem(it){
if(_sfgProducts.some(p=>p.itemCode===it.ItemCode)){toast('Item already added','err');return;}
_sfgProducts.push({itemCode:it.ItemCode,itemName:it.ItemName,itemDesc:it.ItemName,issueDate:today(),batches:[blankBatch()]});
_sfgSearchVal='';
loadSfgBatchManagedItems();
renderSfgOnly();
}
function wireSfgCreate(){
q('#sfg-inp-date').onchange=function(){_sfgDate=this.value;};
q('#sfg-inp-remarks').oninput=function(){_sfgRemarks=this.value;};
document.querySelectorAll('[data-pissue]').forEach(el=>el.onchange=function(){_sfgProducts[+this.dataset.pissue].issueDate=this.value;});
document.querySelectorAll('[data-f]').forEach(el=>el.oninput=function(){
const pi=+this.dataset.p,bi=+this.dataset.b,f=this.dataset.f;_sfgProducts[pi].batches[bi][f]=this.value;
if(f==='mfgDate'){
maybeAutoExpSfg(pi,bi); // recomputes Exp once Mfg is a complete valid date
const b=_sfgProducts[pi].batches[bi];
const ex=document.querySelector(`input[data-p="${pi}"][data-b="${bi}"][data-f="expDate"]`);
if(ex&&ex.value!==b.expDate)ex.value=b.expDate;
}
if(f==='mfgDate'||f==='expDate')checkExpAfterMfgSfg(pi,bi);
});
document.querySelectorAll('[data-dp]').forEach(btn=>btn.onclick=function(){
const[pi,bi,field,mode]=this.dataset.dp.split(':');openDatePickerUnified(+pi,+bi,field,mode,this);});
document.querySelectorAll('[data-dpopen]').forEach(inp=>inp.onclick=function(){
const[pi,bi,field,mode]=this.dataset.dpopen.split(':');openDatePickerUnified(+pi,+bi,field,mode,this);});
document.querySelectorAll('[data-dfmt]').forEach(sel=>sel.onchange=function(){
const[pi,bi,mode]=this.dataset.dfmt.split(':');
_dateFmtSel[`${pi}:${bi}:${mode}`]=this.value;
const b=_sfgProducts[+pi]&&_sfgProducts[+pi].batches[+bi];
if(b){b.mfgDate=reformatDate(b.mfgDate,this.value);b.expDate=reformatDate(b.expDate,this.value);}
renderSfgOnly();
});
document.querySelectorAll('[data-addb]').forEach(b=>b.onclick=()=>{_sfgProducts[+b.dataset.addb].batches.push(blankBatch());renderSfgOnly();});
document.querySelectorAll('[data-rmb]').forEach(b=>b.onclick=()=>{const[pi,bi]=b.dataset.rmb.split(':').map(Number);_sfgProducts[pi].batches.splice(bi,1);if(!_sfgProducts[pi].batches.length)_sfgProducts[pi].batches=[blankBatch()];renderSfgOnly();});
document.querySelectorAll('[data-solvol]').forEach(b=>b.onclick=()=>{const[mode,pi,bi]=b.dataset.solvol.split(':');openSolVolModal(mode,+pi,+bi);});
document.querySelectorAll('[data-rmprod]').forEach(b=>b.onclick=()=>{_sfgProducts.splice(+b.dataset.rmprod,1);renderSfgOnly();});
q('#sfg-btn-cancel').onclick=()=>{if(_sfgEditId){_sfgEditId=null;resetSfg();_tab='view';loadList();}else{resetSfg();render();}};
q('#sfg-btn-submit').onclick=submitSfg;
const inp=q('#sfg-item-search');
if(inp){
inp.oninput=function(){
_sfgSearchVal=this.value;
const v=this.value.trim();
if(v.length<2){hideDD();return;}
clearTimeout(_sfgSearchTimer);
_sfgSearchTimer=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 fetchSfgItems(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();addSfgItem(it);hideDD();};
dd.appendChild(row);
});
positionDD(inp);
},250);
};
inp.onblur=()=>setTimeout(hideDD,150);
}
}
async function submitSfg(){
if(!_sfgProducts.length){toast('Search and add at least one item','err');return;}
const hasBatch=_sfgProducts.some(p=>p.batches.some(b=>(b.batchNo||'').trim()||b.batchSize));
if(!hasBatch){toast('Enter at least one batch (Batch No. / Size)','err');return;}
for(const p of _sfgProducts){
if(!(p.issueDate||'').trim()){toast(`${p.itemCode}: Issue Date is required`,'err');return;}
}
for(const p of _sfgProducts){for(const b of p.batches){
if(!validMfgExp(b.mfgDate)){toast(`${p.itemCode}: invalid MFG date "${b.mfgDate}". Use DD-MMM-YYYY or MMM/YYYY`,'err');return;}
if(!validMfgExp(b.expDate)){toast(`${p.itemCode}: invalid EXP date "${b.expDate}". Use DD-MMM-YYYY or MMM/YYYY`,'err');return;}
const md=parseMEDate(b.mfgDate),ed=parseMEDate(b.expDate);
if(md&&ed&&ed<=md){toast(`${p.itemCode}: EXP date (${b.expDate}) must be later than MFG date (${b.mfgDate})`,'err');return;}
}}
{
const seen=new Map();
for(const p of _sfgProducts){
const required=_batchManagedItems.has(p.itemCode);
const usedRows=p.batches.filter(b=>(b.batchNo||'').trim()||b.batchSize||(b.mfgDate||'').trim()||(b.expDate||'').trim()||(b.market||'').trim());
for(const b of usedRows){
if(required){
if(!(b.batchNo||'').trim()){toast(`${p.itemCode}: Batch No. is required (SAP tracks this item by batch)`,'err');return;}
if(!(b.mfgDate||'').trim()){toast(`${p.itemCode}: MFG Date is required (SAP tracks this item by batch)`,'err');return;}
if(!(b.expDate||'').trim()){toast(`${p.itemCode}: EXP Date is required (SAP tracks this item by batch)`,'err');return;}
}
if((b.batchNo||'').trim()){
const key=b.batchNo.trim().toUpperCase();
if(seen.has(key)){toast(`Duplicate Batch No. "${b.batchNo}" used for both ${seen.get(key)} and ${p.itemCode}`,'err');return;}
seen.set(key,p.itemCode);
}
}
}
}
_sfgSaving=true;renderSfgOnly();
const payload={refNo:'',requirementId:null,date:_sfgDate||null,remarks:_sfgRemarks,products:_sfgProducts,company:_co};
try{
if(_sfgEditId){
const r=await api('PUT','/batch-intimations/'+_sfgEditId,payload);
_sfgSaving=false;_sfgEditId=null;toast('Intimation '+(r.data?.docNo||'')+' updated');resetSfg();_tab='view';loadList();
}else{
const r=await api('POST','/batch-intimations',payload);
_created=r.data;_sfgSaving=false;resetSfg();
if(_duplicateFlow){_duplicateFlow=false;window.location.href='/work-order?from='+r.data.id;return;}
render();
}
}catch(e){_sfgSaving=false;renderSfgOnly();toast(e.message,'err');}
}
async function submit(){
if(!_refNo){toast('Select a requirement','err');return;}
if(!_products.length){toast('No products loaded','err');return;}
const hasBatch=_products.some(p=>p.batches.some(b=>(b.batchNo||'').trim()||b.batchSize));
if(!hasBatch){toast('Enter at least one batch (Batch No. / Size)','err');return;}
for(const p of _products){
if(!(p.issueDate||'').trim()){toast(`${p.itemCode}: Issue Date is required`,'err');return;}
}
// Validate MFG/EXP date format
for(const p of _products){for(const b of p.batches){
if(!validMfgExp(b.mfgDate)){toast(`${p.itemCode}: invalid MFG date "${b.mfgDate}". Use DD-MMM-YYYY or MMM/YYYY`,'err');return;}
if(!validMfgExp(b.expDate)){toast(`${p.itemCode}: invalid EXP date "${b.expDate}". Use DD-MMM-YYYY or MMM/YYYY`,'err');return;}
// Exp must be strictly later than Mfg (when both are given)
const md=parseMEDate(b.mfgDate),ed=parseMEDate(b.expDate);
if(md&&ed&&ed<=md){toast(`${p.itemCode}: EXP date (${b.expDate}) must be later than MFG date (${b.mfgDate})`,'err');return;}
}}
// Guard: total batch size per product cannot exceed its pending qty — in
// Edit mode, pendingQty was fetched excluding this document's own qty (see
// startEdit()), so this applies the same in both modes. Authoritative check
// (can't be bypassed) runs server-side on submit either way. Admin can
// relax this (System Settings → "Allow Exceeding Pending Qty").
for(const p of _products){
if(p.pendingQty==null||_biAllowExceedPending)continue;
const used=p.batches.reduce((s,b)=>s+(Number(b.batchSize)||0),0);
if(used>Number(p.pendingQty)){toast(`${p.itemCode}: batch total ${used} exceeds pending ${p.pendingQty}`,'err');return;}
}
// "Batch No. Required" — item-wise, auto-derived from SAP (_batchManagedItems,
// loaded in selectRequirement()/startEdit()) rather than a manual toggle.
// Presence check applies only to items SAP tracks by batch; the in-document
// duplicate check + the authoritative cross-document/SAP uniqueness check
// (which always runs server-side) apply to any batch no. that was entered,
// required or not.
{
const seen=new Map();
for(const p of _products){
const required=_batchManagedItems.has(p.itemCode);
const usedRows=p.batches.filter(b=>(b.batchNo||'').trim()||b.batchSize||(b.mfgDate||'').trim()||(b.expDate||'').trim()||(b.market||'').trim());
for(const b of usedRows){
if(required){
if(!(b.batchNo||'').trim()){toast(`${p.itemCode}: Batch No. is required (SAP tracks this item by batch)`,'err');return;}
if(!(b.mfgDate||'').trim()){toast(`${p.itemCode}: MFG Date is required (SAP tracks this item by batch)`,'err');return;}
if(!(b.expDate||'').trim()){toast(`${p.itemCode}: EXP Date is required (SAP tracks this item by batch)`,'err');return;}
}
if((b.batchNo||'').trim()){
const key=b.batchNo.trim().toUpperCase();
if(seen.has(key)){toast(`Duplicate Batch No. "${b.batchNo}" used for both ${seen.get(key)} and ${p.itemCode}`,'err');return;}
seen.set(key,p.itemCode);
}
}
}
}
_saving=true;render();
const payload={refNo:_refNo,requirementId:_reqId,date:_date||null,remarks:_remarks,products:_products,company:_co};
try{
if(_editId){
const r=await api('PUT','/batch-intimations/'+_editId,payload);
_saving=false;_editId=null;toast('Intimation '+(r.data?.docNo||'')+' updated');_tab='view';loadList();
}else{
const r=await api('POST','/batch-intimations',payload);
_created=r.data;_saving=false;render();
}
}catch(e){_saving=false;render();toast(e.message,'err');}
}
function newDoc(){_editId=null;_created=null;_reqId='';_refNo='';_products=[];_date=today();_remarks='';_batchManagedItems=new Set();_editLockedSet=new Set();_batchNoLockedSet=new Set();_tab='create';render();loadReqs();}
async function startEdit(bi){
// Rows whose (itemCode,batchNo) already has a generated Work Order —
// those specific batches are read-only for the rest of this edit session
// (see renderCreate()/renderSfgCreate()); every other row in the document
// stays freely editable/removable, and new products/batches can always be
// added. Same key format captureStatus() already uses.
const linked=_linkedWo[String(bi.id)]||[];
_editLockedSet=new Set(linked.map(l=>`${l.productCode||''}|${l.batchNumber||''}`));
const linkedAll=_linkedWoAll[String(bi.id)]||[];
_batchNoLockedSet=new Set(linkedAll.map(l=>`${l.productCode||''}|${l.batchNumber||''}`));
// A "Without Intimation" (SFG) record has no refNo/requirement at all by
// design — editing it must stay on that same no-requirement path, not the
// regular Requirement-based edit form (whose submit() requires a refNo and
// would otherwise wrongly reject saving with "Select a requirement").
if(!(bi.refNo||'').trim()){
_sfgEditId=bi.id;_created=null;_tab='sfg';
_sfgDate=bi.date||'';_sfgRemarks=bi.remarks||'';
_sfgProducts=(bi.products||[]).map(p=>({
itemCode:p.itemCode||'',itemName:p.itemName||'',itemDesc:p.itemDesc||'',
issueDate:p.issueDate||today(),
batches:(p.batches&&p.batches.length)?p.batches.map(b=>({batchNo:b.batchNo||'',mfgDate:b.mfgDate||'',expDate:b.expDate||'',market:b.market||'',batchSize:b.batchSize!=null?b.batchSize:'',batchVolume:b.batchVolume!=null?b.batchVolume:'',solutionVolumes:Array.isArray(b.solutionVolumes)?b.solutionVolumes.filter(sv=>(sv.name||'').trim()||(sv.volume!=null&&sv.volume!=='')):[]})):[blankBatch()]
}));
render();window.scrollTo(0,0);
loadSfgBatchManagedItems();
return;
}
_editId=bi.id;_created=null;_tab='create';
_refNo=bi.refNo;_reqId=bi.requirementId||'';
_date=bi.date||'';_remarks=bi.remarks||'';
_products=(bi.products||[]).map(p=>({
itemCode:p.itemCode||'',itemName:p.itemName||'',itemDesc:p.itemDesc||'',requiredQty:p.requiredQty!=null?p.requiredQty:'',
issueDate:p.issueDate||today(),
batches:(p.batches&&p.batches.length)?p.batches.map(b=>({batchNo:b.batchNo||'',mfgDate:b.mfgDate||'',expDate:b.expDate||'',market:b.market||'',batchSize:b.batchSize!=null?b.batchSize:'',batchVolume:b.batchVolume!=null?b.batchVolume:'',solutionVolumes:Array.isArray(b.solutionVolumes)?b.solutionVolumes.filter(sv=>(sv.name||'').trim()||(sv.volume!=null&&sv.volume!=='')):[]})):[blankBatch()]
}));
render();window.scrollTo(0,0);
classifyBloodBag(); // enable Mfg+3yr auto-expiry when editing too
loadBatchManagedItems();
// Fetch pendingQty per item EXCLUDING this document's own already-saved qty
// (else it would count against itself and make Total Units look full up
// even before any change) — see routes/requirements.js's excludeIntimationId.
try{
const r=await api('GET',`/requirements?company=${encodeURIComponent(_co)}&excludeIntimationId=${encodeURIComponent(bi.id)}`);
const req=(r.data||[]).find(x=>String(x.id)===String(bi.requirementId)||x.refNo===bi.refNo);
if(req){
const byItem={};(req.lines||[]).forEach(l=>{byItem[l.itemCode]=l.pendingQty;});
_products.forEach(p=>{if(byItem[p.itemCode]!=null)p.pendingQty=byItem[p.itemCode];});
render();
}
}catch(_e){/* non-fatal — server-side check still applies at submit */}
}
async function deleteDoc(id,docNo){
if(!confirm('Delete intimation '+docNo+'?\n\nIt will be removed from the list (soft delete).'))return;
try{await api('DELETE','/batch-intimations/'+id);toast('Intimation '+docNo+' deleted');loadList();}
catch(e){toast(e.message,'err');}
}
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">Intimation Sent</div>
<div class="suc-sub">Batch issuance intimation created for ${esc(_created.refNo)}.</div>
<div class="ref-pill" style="font-size:16px;padding:8px 18px">${esc(_created.docNo)}</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 Intimation</button>
</div>
</div>
</div>`;
q('#btn-another').onclick=()=>{newDoc();};
q('#btn-view').onclick=()=>{_created=null;_tab='view';loadList();};
}
// ─────────── VIEW ───────────
async function loadList(){
_listLoading=true;render();
try{const r=await api('GET',`/batch-intimations?company=${encodeURIComponent(_co)}`);_list=r.data||[];}catch(e){toast(e.message,'err');_list=[];}
try{const r=await api('GET','/batch-intimations/linked-work-orders/all');_linkedWo=r.data||{};_linkedWoAll=r.allData||{};}catch(_e){_linkedWo={};_linkedWoAll={};}
_listLoading=false;render();
}
function lockedInfo(id){const l=_linkedWo[String(id)];return (l&&l.length)?l:null;}
// Display status is computed live from Work Order coverage, NOT the stored
// STATUS column (which is only ever written once, at creation, as
// 'SENT_TO_QA' — it never reflects that Work Orders were later generated).
// Multi-batch intimations can be partially captured (some batches have a
// Work Order, others don't), so this is per-batch, not per-intimation.
function captureStatus(bi){
const linked=_linkedWo[String(bi.id)]||[];
const usedSet=new Set(linked.map(l=>`${l.productCode||''}|${l.batchNumber||''}`));
let total=0,used=0;
(bi.products||[]).forEach(p=>(p.batches||[]).forEach(b=>{
total++;
if(usedSet.has(`${p.itemCode||''}|${b.batchNo||''}`))used++;
}));
if(!total||!used)return{label:'SENT TO QA',cls:''};
if(used>=total)return{label:'FULLY CAPTURED',cls:'full'};
return{label:'PARTIALLY CAPTURED',cls:''};
}
function filteredBiList(){
let list=_list;
if(_viewStatus)list=list.filter(bi=>captureStatus(bi).label===_viewStatus);
const s=_viewSearch.trim().toUpperCase();
if(s)list=list.filter(bi=>
(bi.docNo||'').toUpperCase().includes(s)||
(bi.refNo||'').toUpperCase().includes(s)||
(bi.createdByName||bi.createdBy||'').toUpperCase().includes(s)||
(bi.products||[]).some(p=>(p.itemCode||'').toUpperCase().includes(s)||(p.itemName||'').toUpperCase().includes(s)||
(p.batches||[]).some(b=>(b.batchNo||'').toUpperCase().includes(s))));
if(_viewDateFrom||_viewDateTo)list=list.filter(bi=>{
if(!bi.date)return false;
const d=bi.date.slice(0,10);
if(_viewDateFrom&&d<_viewDateFrom)return false;
if(_viewDateTo&&d>_viewDateTo)return false;
return true;
});
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 intimations yet. Create one from the “New Intimation” tab.</div></div>`;
const allList=filteredBiList();
const totalPages=Math.max(1,Math.ceil(allList.length/VF_PAGE_SIZE));
if(_viewPage>=totalPages)_viewPage=totalPages-1;
if(_viewPage<0)_viewPage=0;
const list=allList.slice(_viewPage*VF_PAGE_SIZE,(_viewPage+1)*VF_PAGE_SIZE);
const statuses=['SENT TO QA','PARTIALLY CAPTURED','FULLY CAPTURED'];
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 doc no, ref no, item code/name…" value="${esc(_viewSearch)}"/></div>
<input class="fi" id="vf-datefrom" type="date" value="${esc(_viewDateFrom)}" style="width:140px"/>
<span style="font-size:11px;color:var(--text3)">to</span>
<input class="fi" id="vf-dateto" type="date" value="${esc(_viewDateTo)}" style="width:140px"/>
<div class="vf-chips">
<button class="vf-chip ${!_viewStatus?'on':''}" data-vfstatus="">All</button>
${statuses.map(st=>`<button class="vf-chip ${_viewStatus===st?'on':''}" data-vfstatus="${esc(st)}">${esc(st)}</button>`).join('')}
</div>
<span class="vf-count">${allList.length} of ${_list.length}</span>
</div>`;
const sapBanner=_sapBatchMatches.length?`<div style="margin-bottom:12px;padding:10px 12px;background:var(--surface2);border:1px solid var(--border,#dfe4ea);border-radius:10px">
<div style="font-size:11px;font-weight:700;color:var(--text2);margin-bottom:6px"><i data-lucide="search-check" style="width:12px;height:12px;vertical-align:-2px"></i> Also found in SAP (not one of this portal's own Intimations/Work Orders):</div>
${_sapBatchMatches.map(m=>m.source==='productionOrder'
?`<div style="font-size:12px;padding:3px 0"><a href="/production#order=${m.absEntry}&company=${encodeURIComponent(_co)}" style="color:var(--blue)" target="_blank">Production Order ${esc(String(m.docNum))}</a> — ${esc(m.itemCode)} ${esc(m.itemName||'')} · Batch (SAP Comments) <span style="font-family:var(--mono)">${esc(m.batch)}</span></div>`
:`<div style="font-size:12px;padding:3px 0">SAP Batch Master — ${esc(m.itemCode)} ${esc(m.itemName||'')} · <span style="font-family:var(--mono)">${esc(m.batch)}</span></div>`
).join('')}
</div>`:'';
const grid=list.length
? `<div class="bi-grid">${list.map(bi=>{
const batchCount=(bi.products||[]).reduce((n,p)=>n+((p.batches||[]).length),0);
const locked=lockedInfo(bi.id);
const cs=captureStatus(bi);
return `
<div class="bi-card" data-id="${bi.id}">
<div class="bc-top">
<span class="bc-doc">${esc(bi.docNo)}</span>
<span class="status-badge ${cs.cls}">${esc(cs.label)}</span>
${locked?`<span class="status-badge" style="background:var(--surface3,#eef1f5);color:var(--text3)" title="Work Order ${esc(locked.map(l=>l.woNo).join(', '))} already generated — Delete disabled; Edit ${cs.label==='FULLY CAPTURED'?'disabled too (nothing left to edit)':'still allowed for the remaining, not-yet-captured batches'}"><i data-lucide="lock" style="width:10px;height:10px;vertical-align:-1px"></i> Locked</span>`:''}
</div>
<div class="bc-icon"><i data-lucide="send" style="width:18px;height:18px"></i></div>
<div class="bc-ref">Ref: ${esc(bi.refNo)}</div>
<div style="font-size:11.5px;color:var(--text2);margin-top:2px">${(bi.products||[]).length} product(s) · ${batchCount} batch(es)</div>
${locked?`<div style="font-size:11px;color:var(--text3);margin-top:2px"><i data-lucide="file-cog" style="width:11px;height:11px;vertical-align:-1px"></i> ${esc(locked.map(l=>l.woNo).join(', '))}</div>`:''}
<div class="bc-meta">
<span><i data-lucide="calendar" style="width:12px;height:12px;vertical-align:-2px"></i> ${fmtDate(bi.date)}</span>
<span>${esc(bi.createdByName||bi.createdBy||'')}${bi.updatedAt?' · edited':''}</span>
</div>
</div>`;
}).join('')}</div>
<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:12px">
<button class="btn b-cancel" id="vf-prev" ${_viewPage===0?'disabled':''} style="padding:6px 14px;font-size:11px">‹ Prev</button>
<span style="font-size:11px;color:var(--text3)">Page ${_viewPage+1} of ${totalPages} · ${allList.length} intimation${allList.length===1?'':'s'}</span>
<button class="btn b-cancel" id="vf-next" ${_viewPage>=totalPages-1?'disabled':''} style="padding:6px 14px;font-size:11px">Next ›</button>
</div>`
: `<div class="card"><div class="empty-msg">No intimations match your search/filter.</div></div>`;
return bar+sapBanner+grid;
}
// Looks up the current search term against SAP itself (OBTN batch master +
// Production Order Comments — see /sap/lookup/batch-in-sap) once it's long
// enough to be a real batch number, not just item-code-length noise. Debounced
// so it doesn't fire on every keystroke; only re-renders the SAP banner, not
// the whole list, to avoid stealing focus from the search input mid-type.
function scheduleSapBatchLookup(){
clearTimeout(_sapBatchTimer);
const term=_viewSearch.trim();
if(term.length<4){
if(_sapBatchMatches.length){_sapBatchMatches=[];_sapBatchQuery='';q('#body').innerHTML=renderView();wireView();if(window.lucide)lucide.createIcons();}
return;
}
_sapBatchTimer=setTimeout(async()=>{
if(_viewSearch.trim()!==term)return; // stale — a newer keystroke already superseded this
try{
const r=await api('GET',`/sap/lookup/batch-in-sap?batch=${encodeURIComponent(term)}&company=${encodeURIComponent(_co)}`);
if(_viewSearch.trim()!==term)return; // still stale by the time the response lands
_sapBatchMatches=r.data||[];_sapBatchQuery=term;
}catch(_e){_sapBatchMatches=[];}
const pos=q('#vf-search')?.selectionStart;
q('#body').innerHTML=renderView();wireView();
const s2=q('#vf-search');if(s2&&pos!=null){s2.focus();s2.setSelectionRange(pos,pos);}
if(window.lucide)lucide.createIcons();
},450);
}
function closeBiDetail(){document.getElementById('bi-detail-modal').classList.remove('show');}
function openBiDetail(id){
const bi=_list.find(x=>x.id===id);
if(!bi)return;
const batchCount=(bi.products||[]).reduce((n,p)=>n+((p.batches||[]).length),0);
const detail=(bi.products||[]).map(p=>{
const rows=(p.batches||[]).map(b=>`<tr><td style="font-family:var(--mono)">${esc(b.batchNo)}</td><td>${esc(b.mfgDate)}</td><td>${esc(b.expDate)}</td><td>${esc(b.market)}</td><td>${esc(b.batchSize)}</td><td>${esc(b.batchVolume)}</td></tr>`).join('');
return `<div style="margin-bottom:10px"><div style="font-size:12px;font-weight:700;margin-bottom:4px"><span style="font-family:var(--mono);color:var(--blue)">${esc(p.itemCode)}</span> ${esc(p.itemName||'')} <span style="color:var(--text3);font-weight:400">· req ${esc(p.requiredQty)}${p.issueDate?` · issue ${fmtDate(p.issueDate)}`:''}</span></div>
<table class="dv-tbl"><thead><tr><th>Batch No.</th><th>Mfg</th><th>Exp</th><th>Market</th><th>Total Units</th><th>Batch Size (Ltr)</th></tr></thead><tbody>${rows}</tbody></table></div>`;
}).join('');
const locked=lockedInfo(bi.id);
const fullyCaptured=captureStatus(bi).label==='FULLY CAPTURED';
// Delete always stays blocked once ANY Work Order references this
// document (can't remove the whole thing while part of it is in use).
// Edit only fully blocks once EVERY batch is captured — otherwise it
// opens with just those specific rows locked (see startEdit()).
const lockNote=locked?(fullyCaptured
?`<div style="margin-top:10px;padding:8px 10px;background:var(--surface3,#eef1f5);border-radius:8px;font-size:11.5px;color:var(--text3);display:flex;align-items:center;gap:6px"><i data-lucide="lock" style="width:13px;height:13px"></i> Locked — Work Order ${esc(locked.map(l=>l.woNo).join(', '))} already generated from every batch. Edit/Delete are disabled.</div>`
:`<div style="margin-top:10px;padding:8px 10px;background:var(--surface3,#eef1f5);border-radius:8px;font-size:11.5px;color:var(--text3);display:flex;align-items:center;gap:6px"><i data-lucide="lock" style="width:13px;height:13px"></i> Partially locked — Work Order ${esc(locked.map(l=>l.woNo).join(', '))} already generated from ${locked.length} of ${batchCount} batch(es). Those rows are read-only in Edit; the rest can still be changed. Delete stays disabled.</div>`
):'';
const inner=document.getElementById('bi-modal-inner');
inner.innerHTML=`
<div class="bi-modal-hd">
<span class="bc-doc">${esc(bi.docNo)}</span>
<span class="bc-ref">Ref: ${esc(bi.refNo)}</span>
<span class="status-badge ${captureStatus(bi).cls}">${esc(captureStatus(bi).label)}</span>
<span style="font-size:11px;color:var(--text4)">${esc(bi.createdByName||bi.createdBy||'')}${bi.updatedAt?' · edited':''}</span>
<button class="bi-modal-close" id="bi-modal-x"><i data-lucide="x" style="width:15px;height:15px"></i></button>
</div>
<div class="bi-modal-bd">
<div class="bc-meta" style="margin-top:0;border-top:none;padding-top:0;flex-direction:row;gap:14px">
<span><i data-lucide="calendar" style="width:13px;height:13px;vertical-align:-2px"></i> ${fmtDate(bi.date)}</span>
<span><i data-lucide="package" style="width:13px;height:13px;vertical-align:-2px"></i> ${(bi.products||[]).length} product(s) · ${batchCount} batch(es)</span>
</div>
${lockNote}
<div style="margin-top:14px">${detail}${bi.remarks?`<div style="margin-top:8px;font-size:12px;color:var(--text3)"><strong>Remarks:</strong> ${esc(bi.remarks)}</div>`:''}</div>
</div>
<div class="bi-modal-ft">
${CAN_CREATE_WO?`<button class="bc-btn wo" id="bi-modal-wo" ${fullyCaptured?'disabled title="Fully captured — every batch already has a Work Order"':''}><i data-lucide="file-cog" style="width:13px;height:13px"></i> Generate Work Order</button>`:''}
<button class="bc-btn" id="bi-modal-edit" ${fullyCaptured?'disabled title="Every batch already has a Work Order — nothing left to edit"':''}><i data-lucide="pencil" style="width:13px;height:13px"></i> Edit</button>
<button class="bc-btn del" id="bi-modal-del" ${locked?'disabled title="Locked — a Work Order already exists for this Intimation"':''}><i data-lucide="trash-2" style="width:13px;height:13px"></i> Delete</button>
</div>`;
document.getElementById('bi-detail-modal').classList.add('show');
if(window.lucide)lucide.createIcons();
document.getElementById('bi-modal-x').onclick=closeBiDetail;
if(CAN_CREATE_WO&&!fullyCaptured)document.getElementById('bi-modal-wo').onclick=()=>{window.location.href='/work-order?from='+bi.id;};
// Edit's own enable/disable condition is fullyCaptured (a partial lock
// still opens Edit, just with the locked rows read-only inside it — see
// startEdit()) — this must match, not `locked`, which is also true for a
// PARTIAL lock and was silently leaving Edit's click handler unwired
// while the button still rendered enabled (looked clickable, did nothing).
if(!fullyCaptured){
document.getElementById('bi-modal-edit').onclick=()=>{closeBiDetail();startEdit(bi);};
}
if(!locked){
document.getElementById('bi-modal-del').onclick=()=>{closeBiDetail();deleteDoc(bi.id,bi.docNo);};
}
}
function wireView(){
document.querySelectorAll('.bi-card').forEach(c=>c.onclick=()=>openBiDetail(+c.dataset.id));
const s=q('#vf-search');
if(s){
s.oninput=function(){
_viewSearch=this.value;
_viewPage=0;
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();
scheduleSapBatchLookup();
};
}
document.querySelectorAll('[data-vfstatus]').forEach(b=>b.onclick=function(){_viewStatus=this.dataset.vfstatus;_viewPage=0;q('#body').innerHTML=renderView();wireView();if(window.lucide)lucide.createIcons();});
const df=q('#vf-datefrom'),dt=q('#vf-dateto');
if(df)df.onchange=function(){_viewDateFrom=this.value;_viewPage=0;q('#body').innerHTML=renderView();wireView();if(window.lucide)lucide.createIcons();};
if(dt)dt.onchange=function(){_viewDateTo=this.value;_viewPage=0;q('#body').innerHTML=renderView();wireView();if(window.lucide)lucide.createIcons();};
const vp=q('#vf-prev'),vn=q('#vf-next');
if(vp)vp.onclick=function(){_viewPage--;q('#body').innerHTML=renderView();wireView();if(window.lucide)lucide.createIcons();};
if(vn)vn.onclick=function(){_viewPage++;q('#body').innerHTML=renderView();wireView();if(window.lucide)lucide.createIcons();};
}
// ─────────── INIT ───────────
_date=today();
_sfgDate=today();
loadReqs();
(function(){
const params=new URLSearchParams(window.location.search);
if(params.get('duplicate')!=='1')return;
let raw;try{raw=sessionStorage.getItem('wo_duplicate_payload');}catch(_e){raw=null;}
if(!raw)return;
try{_duplicateSourceItem=JSON.parse(raw);_duplicateFlow=true;}catch(_e){}
})();
api('GET','/config').then(cfg=>{
if(cfg&&typeof cfg.biAllowExceedPending==='boolean')_biAllowExceedPending=cfg.biAllowExceedPending;
if(cfg){
_reqReviewEnabled=!!cfg.requirementStoreReviewEnabled;
_reqReviewHardGate=!!cfg.requirementStoreReviewHardGate;
if(_reqReviewEnabled&&_reqReviewHardGate&&_tab==='create')render();
if(typeof cfg.biSfgWorkflowEnabled==='boolean'&&cfg.biSfgWorkflowEnabled!==_biSfgEnabled){_biSfgEnabled=cfg.biSfgWorkflowEnabled;render();}
if(typeof cfg.biMultiSolutionVolumesEnabled==='boolean')_biMultiSolVolEnabled=cfg.biMultiSolutionVolumesEnabled;
}
if(_duplicateFlow&&_duplicateSourceItem){
if(_biSfgEnabled){
const d=_duplicateSourceItem;
_sfgProducts=[{itemCode:d.productCode||'',itemName:d.productName||d.productCode||'',itemDesc:d.productDesc||'',issueDate:today(),batches:[Object.assign(blankBatch(),{batchSize:d.totalUnits||''})]}];
_tab='sfg';
toast('Pre-filled from the source Work Order — confirm Batch No., Issue Date and MFG/EXP before submitting');
}else{
// "Without Intimation" isn't enabled here — the duplicate flow can't
// silently pick a real Requirement on the user's behalf, so drop the
// hand-off and let them create a normal Intimation, then use its own
// "Generate Work Order" button (item selection copying is lost in
// this fallback — only the item/qty pre-fill was skippable safely).
try{sessionStorage.removeItem('wo_duplicate_payload');}catch(_e){}
_duplicateFlow=false;
toast('"Without Intimation" isn\'t enabled — pick a Requirement to create the Intimation, then use its own "Generate Work Order" button','err');
}
render();
}
}).catch(()=>{});
</script>
<script src="/sidebar.js?v=31"></script>
</body>
</html>