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