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

1140 lines
70 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}
.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 _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 _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:''};}
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':'';
return `
<tr${rowLocked?' title="Already used by a generated Work Order — locked"':''}>
<td><input class="fi" ${dis} 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>
<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"/></td>
<td class="td-act">${rowLocked?`<i data-lucide="lock" style="width:14px;height:14px;color:var(--text3)" title="Locked — Work Order already generated"></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();});
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':'';
return `
<tr${rowLocked?' title="Already used by a generated Work Order — locked"':''}>
<td><input class="fi" ${dis} 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>
<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"/></td>
<td class="td-act">${rowLocked?`<i data-lucide="lock" style="width:14px;height:14px;color:var(--text3)" title="Locked — Work Order already generated"></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}`));
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();}
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-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();_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||''}`));
// 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:''})):[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:''})):[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||{};}catch(_e){_linkedWo={};}
_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)));
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 list=filteredBiList();
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>
<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">${list.length} of ${_list.length}</span>
</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>
<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 class="card"><div class="empty-msg">No intimations match your search/filter.</div></div>`;
return bar+grid;
}
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;};
if(!locked){
document.getElementById('bi-modal-edit').onclick=()=>{closeBiDetail();startEdit(bi);};
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;
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();});
}
// ─────────── 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(_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=25"></script>
</body>
</html>