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