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

1064 lines
64 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<script src="/auth-guard.js?v=4"></script>
<script src="/company-list.js"></script>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1"/>
<title>Requirements Management — 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;--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:1100px;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,#f3f4f6);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}
.grid2{display:grid;grid-template-columns:1fr 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)}
.tbl-wrap{overflow-x:auto;border:1px solid var(--border);border-radius:10px}
table.req-tbl{width:100%;border-collapse:collapse;font-size:13px;min-width:720px}
.req-tbl th{background:#eef0f3;color:var(--text3);font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.4px;text-align:left;padding:9px 10px;border-bottom:1px solid var(--border)}
.req-tbl td{padding:6px 8px;border-bottom:1px solid var(--border);vertical-align:middle}
.req-tbl tr:last-child td{border-bottom:none}
.td-sr{width:52px;text-align:center;font-weight:700;color:var(--text3)}
.td-qty{width:120px}
.td-act{width:44px;text-align:center}
.search-wrap{position:relative}
/* Floating item-search dropdown — attached to <body> so no container can clip it */
.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}
.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-del:hover{background:rgba(220,38,38,.14)}
.btn{padding:10px 20px;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: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 */
.req-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:14px}
.req-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}
.req-card:hover{border-color:var(--blue);box-shadow:0 6px 20px rgba(37,99,235,.14);transform:translateY(-2px)}
.rc-top{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;flex-wrap:wrap}
.rc-ref{font-family:var(--mono);font-size:13.5px;font-weight:700;color:var(--blue)}
.status-badge{font-size:9.5px;font-weight:700;padding:3px 9px;border-radius:20px;text-transform:uppercase;letter-spacing:.3px;white-space:nowrap}
.sb-open{background:#eff6ff;color:#2563eb;border:1px solid #bfdbfe}
.sb-closed{background:#e6f5e6;color:#16a34a;border:1px solid #b3dab3}
.rc-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}
.rc-actions{display:inline-flex;gap:6px}
.rc-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)}
.rc-btn:hover{border-color:var(--blue);color:var(--blue);background:var(--blue-light)}
.rc-btn.del:hover{border-color:var(--red);color:var(--red);background:rgba(220,38,38,.06)}
.rc-meta{font-size:11px;color:var(--text3);margin-top:auto;padding-top:8px;border-top:1px solid var(--border);display:flex;flex-direction:column;gap:3px}
.rc-by{font-size:10.5px;color:var(--text4);margin-top:2px}
.empty-msg{text-align:center;padding:40px;color:var(--text3);font-size:13px}
/* Detail popup */
.req-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}
.req-modal-bg.show{display:flex}
.req-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}}
.req-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}
.req-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}
.req-modal-close:hover{background:var(--red);color:#fff;border-color:var(--red)}
.req-modal-bd{padding:20px 22px;overflow-y:auto}
.req-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){.req-modal{max-height:100vh;border-radius:0}.req-modal-bg{padding:0}}
/* Success */
.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 */
.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}.grid2{grid-template-columns:1fr}}
/* Calculator tab */
.grid4{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:8px}
@media(max-width:860px){.grid4{grid-template-columns:1fr 1fr}}
.calc-note{font-size:11.5px;color:var(--text3);background:var(--surface2);border:1px solid var(--border);border-radius:8px;padding:8px 11px;margin-top:4px;margin-bottom:16px;line-height:1.5}
table.calc-tbl{width:100%;border-collapse:collapse;font-size:12px;min-width:1180px}
.calc-tbl th{background:#eef0f3;color:var(--text3);font-size:9.5px;font-weight:700;text-transform:uppercase;letter-spacing:.3px;text-align:right;padding:8px 8px;border-bottom:1px solid var(--border);white-space:nowrap}
.calc-tbl th.l{text-align:left}
.calc-tbl td{padding:5px 7px;border-bottom:1px solid var(--border);vertical-align:middle;text-align:right;font-family:var(--mono);font-size:11.5px}
.calc-tbl td.l{text-align:left;font-family:var(--font)}
.calc-tbl tr:last-child td{border-bottom:none}
.calc-tbl .req-pos{color:var(--blue);font-weight:700}
.calc-tbl .req-neg{color:var(--text4)}
.calc-chk{width:15px;height:15px;cursor:pointer}
.calc-inp-qty{width:90px;padding:4px 6px;border:1px solid var(--border);border-radius:5px;font-family:var(--mono);font-size:11.5px;text-align:right}
.btn-mini2{padding:4px 9px;border-radius:6px;border:1px solid var(--border);background:var(--surface);color:var(--blue);font-size:10.5px;font-weight:700;cursor:pointer}
.btn-mini2:hover{background:var(--blue-light)}
.btn-mini2:disabled{opacity:.5;cursor:not-allowed}
</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="req-modal-bg" id="req-detail-modal"><div class="req-modal" id="req-modal-inner"></div></div>
<div class="req-modal-bg" id="grp-modal"><div class="req-modal" id="grp-modal-inner" style="max-width:640px"></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;
}
function hasStepAssigned(fullKey){return ['view','add','edit','approve','delete'].some(p=>hasStepPerm(fullKey,p));}
function hasWorkflowPerm(workflow,perm){
if(_user&&_user.role==='admin')return true;
const steps=Array.isArray(_user&&_user.approvalSteps)?_user.approvalSteps:[];
return steps.some(s=>{
if(typeof s==='string')return s.startsWith(workflow+':');
return s&&s.step&&s.step.startsWith(workflow+':')&&Array.isArray(s.perms)&&s.perms.includes(perm);
});
}
// Any assignment at all on ANY "requirement:*" step (manage OR
// production_review OR store_review, with any perm) is enough to see the
// page — e.g. a Store user who only has 'add' on requirement:store_review
// (no 'view' checked) still needs to reach the list to do their review.
// Individual actions (New/Edit/Delete/Share/Review) stay gated on their own
// specific perm below.
function hasWorkflowAssigned(workflow){
if(_user&&_user.role==='admin')return true;
const steps=Array.isArray(_user&&_user.approvalSteps)?_user.approvalSteps:[];
return steps.some(s=>{
if(typeof s==='string')return s.startsWith(workflow+':');
return s&&s.step&&s.step.startsWith(workflow+':')&&Array.isArray(s.perms)&&s.perms.length>0;
});
}
const CAN_VIEW_REQ=hasWorkflowAssigned('requirement');
const CAN_ADD_REQ=hasStepPerm('requirement:manage','add');
const CAN_EDIT_REQ=hasStepPerm('requirement:manage','edit');
const CAN_DELETE_REQ=hasStepPerm('requirement:manage','delete');
const CAN_SHARE_REQ=hasStepPerm('requirement:production_review','add');
const CAN_STORE_REVIEW_REQ=hasStepPerm('requirement:store_review','add');
let _reqReviewEnabled=false, _reqReviewHardGate=false; // Admin → System Settings — "Requirement — Store Review Workflow"
let _storeReviewRows=null, _storeReviewFor=null, _storeReviewRemarks=''; // active Store-review edit session
let _co=window.__DEFAULT_COMPANY__;
let _tab=CAN_ADD_REQ?'create':'view';
let _periodFrom='', _periodTo='';
let _rows=[blankRow()];
let _remarks='';
let _nextRef='…';
let _editId=null; // null = create mode; otherwise the requirement id being edited
let _saving=false;
let _created=null;
let _list=[], _listLoading=false, _expanded=null;
let _viewSearch='', _viewStatus='';
let _searchTimer={};
// ── Requirement Calculator state ──
let _calcRows=[]; // {itemCode,itemName,itemDesc,saleQtyA,saleQtyB,avgSale,stock,quarantine,totalFG,stockInHandMonths,requirement,selected,loading,calculated}
let _calcPAFrom='',_calcPATo='',_calcPBFrom='',_calcPBTo='';
let _calcMonths=10;
let _calcStockWhs='',_calcQuarantineWhs='';
let _warehouses=[];
let _calcInited=false;
let _calcAllRunning=false;
let _calcPAEnabled=true,_calcPBEnabled=true; // Admin → System Settings — at least one always stays true
let _calcPALabel='Period A',_calcPBLabel='Period B'; // Admin → System Settings — display names for the two periods
// ── Item Groups (self-service presets, shared company-wide) ──
let _calcGroups=[]; // [{id,name,items:[{itemCode,itemName}],createdBy,createdByName}]
let _calcGroupPick=''; // currently selected group id in the picker
let _grpEditId=null; // null = creating a new group; otherwise editing this group's id
let _grpName='';
let _grpItems=[]; // [{itemCode,itemName}] being built in the modal
let _grpSearchTimer=null;
function blankRow(){return{itemCode:'',itemName:'',itemDesc:'',requiredQty:''};}
function esc(v){return(v==null?'':v).toString().replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
function q(s){return document.querySelector(s);}
function fmtDate(d){if(!d)return'—';try{return new Date(d).toLocaleDateString('en-IN',{day:'2-digit',month:'short',year:'numeric'});}catch{return d;}}
// YYYY-MM-DD one day after the given date — used as the "To" picker's min so it
// can only be later than "From".
function nextDay(ymd){const[y,m,d]=ymd.split('-').map(Number);return new Date(Date.UTC(y,m-1,d+1)).toISOString().slice(0,10);}
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 fetchItems(v){try{const r=await api('GET',`/sap/lookup/items?q=${encodeURIComponent(v)}&company=${encodeURIComponent(_co)}`);if(r.success&&r.data?.length)return r.data;}catch{}return[];}
async function loadNextRef(){try{const r=await api('GET',`/requirements/next-ref?company=${encodeURIComponent(_co)}`);if(r.success)_nextRef=r.refNo;}catch{_nextRef='REQ-…';}render();}
async function loadReviewConfig(){
try{const cfg=await api('GET','/config');
if(cfg){
_reqReviewEnabled=!!cfg.requirementStoreReviewEnabled;
_reqReviewHardGate=!!cfg.requirementStoreReviewHardGate;
}
}catch{}
if(_tab==='view')render();
}
// ─────────── RENDER ───────────
function render(){
const app=q('#app');
if(_created){renderSuccess(app);lucide.createIcons();return;}
if(!CAN_VIEW_REQ){
app.innerHTML=`
<div class="page-head">
<div class="page-title"><i data-lucide="clipboard-list"></i> Requirements Management</div>
</div>
<div class="card"><div class="card-bd" style="text-align:center;padding:30px;color:var(--text3)">You are not assigned any "Requirements" permission. Ask an admin to grant it (User Management → Approval Steps → Requirements).</div></div>`;
lucide.createIcons();
return;
}
app.innerHTML=`
<div class="page-head">
<div class="page-title"><i data-lucide="clipboard-list"></i> Requirements Management</div>
<div class="page-sub">Logistics — raise material requirements against a period</div>
</div>
<div class="tabs">
${CAN_ADD_REQ?`<button class="tab ${_tab==='create'?'on':''}" data-t="create">+ New Requirement</button>`:''}
<button class="tab ${_tab==='view'?'on':''}" data-t="view">View Requirements</button>
${CAN_ADD_REQ?`<button class="tab ${_tab==='calc'?'on':''}" data-t="calc">📊 Calculator</button>`:''}
</div>
<div id="body"></div>`;
q('#body').innerHTML = _tab==='create' ? renderCreate() : _tab==='calc' ? renderCalc() : renderView();
document.querySelectorAll('.tab').forEach(b=>b.onclick=()=>{
const t=b.dataset.t;
if(t==='view'){_tab='view';loadList();}
else if(t==='calc'){_tab='calc';initCalc();}
else{newRequirement();}
});
if(_tab==='create')wireCreate(); else if(_tab==='calc')wireCalc(); else wireView();
lucide.createIcons();
}
function renderCreate(){
const rowsHtml=_rows.map((r,i)=>`
<tr>
<td class="td-sr">${i+1}</td>
<td>
<div class="search-wrap">
<input class="fi" data-ri="${i}" placeholder="Search item code / name…" value="${esc(r.itemCode||'')}" autocomplete="off"/>
</div>
</td>
<td><input class="fi" data-desc="${i}" placeholder="Description" value="${esc(r.itemDesc)}"/></td>
<td class="td-qty"><input class="fi" data-qty="${i}" type="number" min="0" step="any" placeholder="0" value="${esc(r.requiredQty)}"/></td>
<td class="td-act"><button class="b-del" data-rm="${i}" title="Remove"><i data-lucide="trash-2" style="width:14px;height:14px"></i></button></td>
</tr>`).join('');
return `
<div class="card">
<div class="card-hd"><i data-lucide="${_editId?'pencil':'file-plus'}" style="width:16px;height:16px"></i> ${_editId?'Edit Requirement':'New Requirement'} <span style="margin-left:auto" class="ref-pill">${esc(_nextRef)}</span></div>
<div class="card-bd">
<div class="grid2">
<div class="fg"><label class="fl">Requirement Period — From</label><input class="fi" type="date" id="inp-from" value="${esc(_periodFrom)}"/></div>
<div class="fg"><label class="fl">Requirement Period — To</label><input class="fi" type="date" id="inp-to" value="${esc(_periodTo)}" min="${_periodFrom?nextDay(_periodFrom):''}"/></div>
</div>
<div class="tbl-wrap">
<table class="req-tbl">
<thead><tr>
<th class="td-sr">Sr.</th>
<th>Item Name</th>
<th>Item Description</th>
<th class="td-qty">Required Qty</th>
<th class="td-act"></th>
</tr></thead>
<tbody>${rowsHtml}</tbody>
</table>
</div>
<button class="btn b-add" id="btn-add"><i data-lucide="plus" style="width:15px;height:15px"></i> Add Row</button>
<div class="fg" style="margin-top:18px"><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-reset">${_editId?'Cancel':'Reset'}</button>
<button class="btn b-primary" id="btn-submit" ${_saving?'disabled':''}>${_saving?'<span class="sp"></span> Saving…':(_editId?'Update Requirement':'Generate Requirement')}</button>
</div>
</div>
</div>`;
}
function wireCreate(){
q('#inp-from').onchange=function(){
_periodFrom=this.value;
const toEl=q('#inp-to');
if(toEl){toEl.min=_periodFrom?nextDay(_periodFrom):'';
// alert + clear a now-invalid To right away (not only on save)
if(_periodTo&&new Date(_periodTo)<=new Date(_periodFrom)){
toast('"To" date must be later than "From" date','err');
_periodTo='';toEl.value='';
}}
};
q('#inp-to').onchange=function(){
if(_periodFrom&&this.value&&new Date(this.value)<=new Date(_periodFrom)){
toast('"To" date must be later than "From" date','err');
_periodTo='';this.value='';return;
}
_periodTo=this.value;
};
q('#inp-remarks').oninput=function(){_remarks=this.value;};
q('#btn-add').onclick=()=>{_rows.push(blankRow());render();};
q('#btn-reset').onclick=()=>{if(_editId){_editId=null;_tab='view';loadList();}else{_periodFrom='';_periodTo='';_remarks='';_rows=[blankRow()];render();}};
q('#btn-submit').onclick=submit;
document.querySelectorAll('[data-desc]').forEach(el=>el.oninput=function(){_rows[+this.dataset.desc].itemDesc=this.value;});
document.querySelectorAll('[data-qty]').forEach(el=>el.oninput=function(){_rows[+this.dataset.qty].requiredQty=this.value;});
document.querySelectorAll('[data-rm]').forEach(b=>b.onclick=function(){const i=+this.dataset.rm;_rows.splice(i,1);if(!_rows.length)_rows=[blankRow()];render();});
// Per-row item search — uses a single floating dropdown attached to <body>
document.querySelectorAll('[data-ri]').forEach(inp=>{
const i=+inp.dataset.ri;
inp.oninput=function(){
const v=this.value.trim();_rows[i].itemName='';_rows[i].itemCode='';
if(v.length<2){hideDD();return;}
clearTimeout(_searchTimer[i]);
_searchTimer[i]=setTimeout(async()=>{
showDD(inp,'<div class="sd-msg"><span class="sp" style="border-color:rgba(37,99,235,.25);border-top-color:#2563eb"></span></div>');
const items=await fetchItems(v);
if(!items.length){showDD(inp,'<div class="sd-msg">No items found</div>');return;}
const dd=getDD();dd.innerHTML='';
items.forEach(it=>{
const row=document.createElement('div');row.className='sd-row';
row.innerHTML=`<span class="sd-code">${esc(it.ItemCode)}</span><span class="sd-name">${esc(it.ItemName)}</span>`;
row.onmousedown=e=>{e.preventDefault();selectItem(i,it);hideDD();};
dd.appendChild(row);
});
positionDD(inp);
},250);
};
inp.onblur=()=>setTimeout(hideDD,150);
});
}
// ── Floating dropdown helpers ──
function getDD(){
let dd=document.getElementById('float-dd');
if(!dd){dd=document.createElement('div');dd.id='float-dd';dd.className='search-dd';document.body.appendChild(dd);}
return dd;
}
function positionDD(inp){
const dd=getDD(),r=inp.getBoundingClientRect();
// <body> may have a zoom (theme.css sets zoom:1.10); fixed coords inside a
// zoomed element are scaled, so divide the viewport rect back by the zoom.
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);
function selectItem(i,it){
_rows[i].itemCode=it.ItemCode;
_rows[i].itemName=it.ItemName;
// Always overwrite — picking a DIFFERENT item must replace whatever
// description was there before (a leftover/stale description from a prior
// selection previously blocked this, since it only filled when empty).
_rows[i].itemDesc=it.ItemName;
// Update inputs in place (avoid full re-render / focus loss)
const nameInp=document.querySelector(`[data-ri="${i}"]`);if(nameInp)nameInp.value=it.ItemCode;
const descInp=document.querySelector(`[data-desc="${i}"]`);if(descInp)descInp.value=it.ItemName;
}
async function submit(){
if(_editId&&!CAN_EDIT_REQ){toast('You are not permitted to edit requirements','err');return;}
if(!_editId&&!CAN_ADD_REQ){toast('You are not permitted to create requirements','err');return;}
if(!_periodFrom||!_periodTo){toast('Select requirement period (from & to)','err');return;}
if(new Date(_periodTo)<=new Date(_periodFrom)){toast('"To" date must be later than "From" date','err');return;}
const lines=_rows.filter(r=>r.itemCode).map(r=>({itemCode:r.itemCode,itemName:r.itemName,itemDesc:r.itemDesc,requiredQty:Number(r.requiredQty)||0}));
if(!lines.length){toast('Add at least one item (search & select)','err');return;}
if(lines.some(l=>!(l.requiredQty>0))){toast('Enter a required quantity for every item','err');return;}
_saving=true;render();
try{
if(_editId){
const r=await api('PUT','/requirements/'+_editId,{periodFrom:_periodFrom,periodTo:_periodTo,lines,remarks:_remarks});
_saving=false;_editId=null;toast('Requirement '+(r.data?.refNo||'')+' updated');_tab='view';loadList();
}else{
const r=await api('POST','/requirements',{periodFrom:_periodFrom,periodTo:_periodTo,lines,remarks:_remarks,company:_co});
_created=r.data;_saving=false;render();
}
}catch(e){_saving=false;render();toast(e.message,'err');}
}
function newRequirement(){_editId=null;_created=null;_periodFrom='';_periodTo='';_remarks='';_rows=[blankRow()];_tab='create';loadNextRef();}
function startEdit(r){
_editId=r.id;_created=null;_tab='create';
_nextRef=r.refNo;
_periodFrom=r.periodFrom||'';_periodTo=r.periodTo||'';_remarks=r.remarks||'';
_rows=(r.lines&&r.lines.length)?r.lines.map(l=>({itemCode:l.itemCode||'',itemName:l.itemName||'',itemDesc:l.itemDesc||'',requiredQty:l.requiredQty!=null?l.requiredQty:''})):[blankRow()];
render();window.scrollTo(0,0);
}
async function deleteReq(id,ref){
if(!CAN_DELETE_REQ){toast('You are not permitted to delete requirements','err');return;}
if(!confirm('Delete requirement '+ref+'?\n\nIt will be removed from the list (soft delete).'))return;
try{await api('DELETE','/requirements/'+id);toast('Requirement '+ref+' 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">Requirement Generated</div>
<div class="suc-sub">Your requirement has been created successfully.</div>
<div class="ref-pill" style="font-size:16px;padding:8px 18px">${esc(_created.refNo)}</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 Requirement</button>
</div>
</div>
</div>`;
q('#btn-another').onclick=()=>{_created=null;_periodFrom='';_periodTo='';_remarks='';_rows=[blankRow()];loadNextRef();};
q('#btn-view').onclick=()=>{_created=null;_tab='view';loadList();};
}
// ─────────── CALCULATOR ───────────
// A worksheet (per the reference sheet): historical Sale Qty over two
// periods, live SAP Stock + Quarantine Stock, and a suggested production
// Requirement per item — which the user can then send straight into the
// "New Requirement" tab to actually save it.
function blankCalcRow(){return{itemCode:'',itemName:'',itemDesc:'',saleQtyA:null,saleQtyB:null,avgSale:null,stock:null,quarantine:null,totalFG:null,stockInHandMonths:null,requirement:null,selected:true,loading:false,calculated:false};}
function n3(v){if(v==null||isNaN(v))return '—';return(Math.round(v*1000)/1000).toLocaleString('en-IN');}
async function loadWarehouses(){
if(_warehouses.length)return;
try{const r=await api('GET',`/sap/lookup/warehouses?company=${encodeURIComponent(_co)}`);_warehouses=r.data||[];}catch(_e){_warehouses=[];}
}
async function loadCalcConfig(){
try{const cfg=await api('GET','/config');
if(cfg){
if(typeof cfg.reqCalcPeriodAEnabled==='boolean')_calcPAEnabled=cfg.reqCalcPeriodAEnabled;
if(typeof cfg.reqCalcPeriodBEnabled==='boolean')_calcPBEnabled=cfg.reqCalcPeriodBEnabled;
if(cfg.reqCalcPeriodALabel)_calcPALabel=cfg.reqCalcPeriodALabel;
if(cfg.reqCalcPeriodBLabel)_calcPBLabel=cfg.reqCalcPeriodBLabel;
}
}catch(_e){}
}
async function loadCalcGroups(){
try{const r=await api('GET',`/requirement-calc/groups?company=${encodeURIComponent(_co)}`);_calcGroups=r.data||[];}catch(_e){_calcGroups=[];}
}
async function initCalc(){
await Promise.all([loadWarehouses(),loadCalcConfig(),loadCalcGroups()]);
if(!_calcInited){_calcRows=[blankCalcRow()];_calcInited=true;}
render();
}
const CALC_STOCK_WHS=[{code:'1A',name:'FG DOMESTIC'},{code:'1B',name:'FG EXPORT'}];
const CALC_QTN_WHS=[{code:'16',name:'QUARANTINE'}];
function renderCalc(){
const whOptsFrom=(list,sel)=>'<option value="">— select —</option>'+list.map(w=>`<option value="${esc(w.code)}" ${sel===w.code?'selected':''}>${esc(w.code)} — ${esc(w.name||'')}</option>`).join('');
const rowsHtml=_calcRows.map((r,i)=>{
const reqCls=r.requirement>0?'req-pos':'req-neg';
return `<tr>
<td class="l" style="width:26px;text-align:center"><input type="checkbox" class="calc-chk" data-csel="${i}" ${r.selected?'checked':''}/></td>
<td class="l" style="width:40px;text-align:center">${i+1}</td>
<td class="l" style="min-width:160px"><div class="search-wrap"><input class="fi" style="height:32px" data-cri="${i}" placeholder="Search item code / name…" value="${esc(r.itemCode||'')}" autocomplete="off"/></div></td>
<td class="l" style="min-width:200px">${esc(r.itemDesc||r.itemName||'')}</td>
<td>${r.loading?'…':n3(r.saleQtyA)}</td>
<td>${r.loading?'…':n3(r.saleQtyB)}</td>
<td>${r.loading?'…':n3(r.avgSale)}</td>
<td>${r.loading?'…':n3(r.stock)}</td>
<td>${r.loading?'…':n3(r.quarantine)}</td>
<td>${r.loading?'…':n3(r.totalFG)}</td>
<td>${r.loading?'…':n3(r.stockInHandMonths)}</td>
<td class="${r.calculated?reqCls:''}">${r.loading?'<span class="sp" style="border-color:rgba(37,99,235,.25);border-top-color:#2563eb;width:11px;height:11px"></span>':n3(r.requirement)}</td>
<td style="text-align:center"><button class="btn-mini2" data-ccalc="${i}" ${!r.itemCode||r.loading?'disabled':''} title="Calculate this row">↻</button></td>
<td class="td-act"><button class="b-del" data-crm="${i}" title="Remove"><i data-lucide="trash-2" style="width:13px;height:13px"></i></button></td>
</tr>`;
}).join('');
const anyCalculated=_calcRows.some(r=>r.calculated);
const selCount=_calcRows.filter(r=>r.selected&&r.calculated).length;
return `
<div class="card">
<div class="card-hd"><i data-lucide="calculator" style="width:16px;height:16px"></i> Requirement Calculator</div>
<div class="card-bd">
<div class="calc-note">${_calcPAEnabled&&_calcPBEnabled
?`Enter the two comparison periods and the warehouses that represent your normal Stock and Quarantine Stock, add items, then click <b>Calculate All</b>. Average Sale/Month = (${esc(_calcPALabel)} qty + ${esc(_calcPBLabel)} qty) ÷ Months; Requirement = Average Sale/Month × 2 − Total FG.`
:`Only ${esc(_calcPAEnabled?_calcPALabel:_calcPBLabel)} is enabled (Admin → System Settings). Enter that period and the warehouses that represent your normal Stock and Quarantine Stock, add items, then click <b>Calculate All</b>. Average Sale/Month = ${esc(_calcPAEnabled?_calcPALabel:_calcPBLabel)} qty ÷ the number of months that period spans; Requirement = Average Sale/Month × 2 − Total FG.`}</div>
<div class="grid4">
${_calcPAEnabled?`
<div class="fg"><label class="fl">${esc(_calcPALabel)} — From</label><input class="fi" type="date" id="calc-pa-from" value="${esc(_calcPAFrom)}"/></div>
<div class="fg"><label class="fl">${esc(_calcPALabel)} — To</label><input class="fi" type="date" id="calc-pa-to" value="${esc(_calcPATo)}"/></div>`:''}
${_calcPBEnabled?`
<div class="fg"><label class="fl">${esc(_calcPBLabel)} — From</label><input class="fi" type="date" id="calc-pb-from" value="${esc(_calcPBFrom)}"/></div>
<div class="fg"><label class="fl">${esc(_calcPBLabel)} — To</label><input class="fi" type="date" id="calc-pb-to" value="${esc(_calcPBTo)}"/></div>`:''}
</div>
<div class="grid4">
<div class="fg"><label class="fl">Stock Warehouse</label><select class="fi" id="calc-wh-stock">${whOptsFrom(CALC_STOCK_WHS,_calcStockWhs)}</select></div>
<div class="fg"><label class="fl">Quarantine Warehouse</label><select class="fi" id="calc-wh-qtn">${whOptsFrom(CALC_QTN_WHS,_calcQuarantineWhs)}</select></div>
${(_calcPAEnabled&&_calcPBEnabled)?`<div class="fg"><label class="fl">Months (divisor)</label><input class="fi" type="number" min="1" step="1" id="calc-months" value="${esc(_calcMonths)}"/></div>`:''}
<div class="fg" style="justify-content:flex-end;display:flex;align-items:flex-end"><button class="btn b-primary" id="btn-calc-all" style="width:100%;height:38px" ${_calcAllRunning?'disabled':''}>${_calcAllRunning?'<span class="sp"></span> Calculating…':'⚡ Calculate All'}</button></div>
</div>
<div class="grid4" style="align-items:flex-end">
<div class="fg" style="grid-column:span 2">
<label class="fl">Item Group — fetch all items from a saved group</label>
<select class="fi" id="calc-grp-pick">
<option value="">— select a group —</option>
${_calcGroups.map(g=>`<option value="${g.id}" ${_calcGroupPick===String(g.id)?'selected':''}>${esc(g.name)} (${g.items.length} item${g.items.length===1?'':'s'})</option>`).join('')}
</select>
</div>
<div class="fg"><button class="btn b-cancel" id="btn-grp-fetch" style="width:100%;height:38px" ${_calcGroupPick?'':'disabled'}>Fetch Group Items</button></div>
<div class="fg"><button class="btn b-cancel" id="btn-grp-manage" style="width:100%;height:38px"><i data-lucide="settings" style="width:14px;height:14px"></i> Manage Groups</button></div>
</div>
<div class="tbl-wrap">
<table class="calc-tbl">
<thead><tr>
<th></th><th>Sr.</th><th class="l">Item No.</th><th class="l">Item Description</th>
<th>Sale Qty<br/>(${esc(_calcPALabel)})</th><th>Sale Qty<br/>(${esc(_calcPBLabel)})</th><th>Avg Sale<br/>/Month</th>
<th>Stock</th><th>Quarantine<br/>Stock</th><th>Total FG</th><th>Stock in Hand<br/>(Months)</th>
<th>Requirement</th><th></th><th></th>
</tr></thead>
<tbody>${rowsHtml}</tbody>
</table>
</div>
<button class="btn b-add" id="btn-calc-add"><i data-lucide="plus" style="width:15px;height:15px"></i> Add Row</button>
<div class="footer-row">
<span style="margin-right:auto;align-self:center;font-size:12px;color:var(--text3)">${anyCalculated?`${selCount} of ${_calcRows.filter(r=>r.calculated).length} calculated row(s) selected`:'Calculate at least one row to continue'}</span>
${CAN_ADD_REQ?`<button class="btn b-primary" id="btn-calc-send" ${selCount?'':'disabled'}>Send Selected to New Requirement →</button>`:''}
</div>
</div>
</div>`;
}
async function calcRow(i){
const r=_calcRows[i];
if(!r||!r.itemCode)return;
if(_calcPAEnabled&&(!_calcPAFrom||!_calcPATo)){toast(`Set the ${_calcPALabel} date range first`,'err');return;}
if(_calcPBEnabled&&(!_calcPBFrom||!_calcPBTo)){toast(`Set the ${_calcPBLabel} date range first`,'err');return;}
r.loading=true;render();
try{
const qs=new URLSearchParams({
itemCode:r.itemCode,periodAFrom:_calcPAFrom,periodATo:_calcPATo,periodBFrom:_calcPBFrom,periodBTo:_calcPBTo,
months:String(_calcMonths||10),
});
if(_calcStockWhs)qs.set('stockWhs',_calcStockWhs);
if(_calcQuarantineWhs)qs.set('quarantineWhs',_calcQuarantineWhs);
const d=await api('GET',`/requirement-calc/line?${qs.toString()}`);
const v=d.data||{};
Object.assign(r,{saleQtyA:v.saleQtyPeriodA,saleQtyB:v.saleQtyPeriodB,avgSale:v.avgSalePerMonth,stock:v.stock,quarantine:v.quarantineStock,totalFG:v.totalFG,stockInHandMonths:v.stockInHandMonths,requirement:v.requirement,calculated:true});
}catch(e){toast(`${r.itemCode}: ${e.message}`,'err');}
r.loading=false;render();
}
async function calcAllRows(){
if(_calcPAEnabled&&(!_calcPAFrom||!_calcPATo)){toast(`Set the ${_calcPALabel} date range first`,'err');return;}
if(_calcPBEnabled&&(!_calcPBFrom||!_calcPBTo)){toast(`Set the ${_calcPBLabel} date range first`,'err');return;}
const idxs=_calcRows.map((r,i)=>r.itemCode?i:-1).filter(i=>i>=0);
if(!idxs.length){toast('Add at least one item first','err');return;}
_calcAllRunning=true;render();
await Promise.all(idxs.map(i=>calcRow(i)));
_calcAllRunning=false;render();
}
function sendCalcToCreate(){
if(!CAN_ADD_REQ){toast('You are not permitted to create requirements','err');return;}
const chosen=_calcRows.filter(r=>r.selected&&r.calculated&&r.itemCode);
if(!chosen.length){toast('Select at least one calculated row first','err');return;}
_rows=chosen.map(r=>({itemCode:r.itemCode,itemName:r.itemName,itemDesc:r.itemDesc||r.itemName,requiredQty:Math.max(0,Math.round((r.requirement||0)*1000)/1000)}));
_periodFrom=_calcPBFrom||'';_periodTo=_calcPBTo||'';
_editId=null;_created=null;_tab='create';
loadNextRef();
toast(`${chosen.length} item(s) sent to New Requirement — review quantities and period, then Generate.`);
}
function wireCalc(){
if(q('#calc-pa-from'))q('#calc-pa-from').onchange=function(){_calcPAFrom=this.value;};
if(q('#calc-pa-to'))q('#calc-pa-to').onchange=function(){_calcPATo=this.value;};
if(q('#calc-pb-from'))q('#calc-pb-from').onchange=function(){_calcPBFrom=this.value;};
if(q('#calc-pb-to'))q('#calc-pb-to').onchange=function(){_calcPBTo=this.value;};
q('#calc-wh-stock').onchange=function(){_calcStockWhs=this.value;};
q('#calc-wh-qtn').onchange=function(){_calcQuarantineWhs=this.value;};
if(q('#calc-months'))q('#calc-months').onchange=function(){_calcMonths=parseFloat(this.value)||10;};
q('#btn-calc-all').onclick=calcAllRows;
q('#btn-calc-add').onclick=()=>{_calcRows.push(blankCalcRow());render();};
if(q('#btn-calc-send'))q('#btn-calc-send').onclick=sendCalcToCreate;
q('#calc-grp-pick').onchange=function(){_calcGroupPick=this.value;render();};
q('#btn-grp-fetch').onclick=fetchGroupIntoRows;
q('#btn-grp-manage').onclick=()=>openGroupModal();
document.querySelectorAll('[data-csel]').forEach(el=>el.onchange=function(){_calcRows[+this.dataset.csel].selected=this.checked;render();});
document.querySelectorAll('[data-ccalc]').forEach(el=>el.onclick=function(){calcRow(+this.dataset.ccalc);});
document.querySelectorAll('[data-crm]').forEach(el=>el.onclick=function(){const i=+this.dataset.crm;_calcRows.splice(i,1);if(!_calcRows.length)_calcRows=[blankCalcRow()];render();});
// Per-row item search — reuses the same floating dropdown as the Create tab.
document.querySelectorAll('[data-cri]').forEach(inp=>{
const i=+inp.dataset.cri;
inp.oninput=function(){
const v=this.value.trim();_calcRows[i].itemName='';_calcRows[i].itemCode='';_calcRows[i].calculated=false;
if(v.length<2){hideDD();return;}
clearTimeout(_searchTimer['c'+i]);
_searchTimer['c'+i]=setTimeout(async()=>{
showDD(inp,'<div class="sd-msg"><span class="sp" style="border-color:rgba(37,99,235,.25);border-top-color:#2563eb"></span></div>');
const items=await fetchItems(v);
if(!items.length){showDD(inp,'<div class="sd-msg">No items found</div>');return;}
const dd=getDD();dd.innerHTML='';
items.forEach(it=>{
const row=document.createElement('div');row.className='sd-row';
row.innerHTML=`<span class="sd-code">${esc(it.ItemCode)}</span><span class="sd-name">${esc(it.ItemName)}</span>`;
row.onmousedown=e=>{e.preventDefault();_calcRows[i].itemCode=it.ItemCode;_calcRows[i].itemName=it.ItemName;_calcRows[i].itemDesc=it.ItemName;hideDD();render();};
dd.appendChild(row);
});
positionDD(inp);
},250);
};
inp.onblur=()=>setTimeout(hideDD,150);
});
}
// ── Item Group management modal ──
function openGroupModal(editId){
_grpEditId=editId||null;
if(editId){const g=_calcGroups.find(x=>x.id===editId);_grpName=g?g.name:'';_grpItems=g?g.items.map(it=>({...it})):[];}
else{_grpName='';_grpItems=[];}
renderGroupModal();
document.getElementById('grp-modal').classList.add('show');
}
function closeGroupModal(){document.getElementById('grp-modal').classList.remove('show');}
function renderGroupModal(){
const inner=document.getElementById('grp-modal-inner');
const canEditThis=g=>_user&&(_user.role==='admin'||g.createdBy===_user.username);
const listHtml=_calcGroups.length?_calcGroups.map(g=>`
<div style="display:flex;align-items:center;gap:8px;padding:8px 0;border-bottom:1px solid var(--border)">
<div style="flex:1"><b>${esc(g.name)}</b> <span style="color:var(--text3);font-size:11px">— ${g.items.length} item(s), by ${esc(g.createdByName||g.createdBy)}</span></div>
${canEditThis(g)?`<button class="btn-mini2" data-grp-edit="${g.id}">Edit</button><button class="btn-mini2" data-grp-del="${g.id}" style="color:var(--red)">Delete</button>`:''}
</div>`).join(''):`<div style="color:var(--text3);font-size:12px;padding:8px 0">No groups saved yet.</div>`;
const itemsHtml=_grpItems.map((it,i)=>`
<span style="display:inline-flex;align-items:center;gap:6px;background:var(--surface2);border:1px solid var(--border);border-radius:14px;padding:4px 10px;margin:3px;font-size:11.5px">
${esc(it.itemCode)} — ${esc(it.itemName||'')}
<button data-grp-item-rm="${i}" style="border:none;background:none;color:var(--red);cursor:pointer;font-weight:700">×</button>
</span>`).join('');
inner.innerHTML=`
<div class="req-modal-hd"><i data-lucide="layers" style="width:16px;height:16px"></i> Item Groups
<button class="req-modal-close" id="grp-modal-x"><i data-lucide="x" style="width:15px;height:15px"></i></button>
</div>
<div class="req-modal-bd">
<div style="margin-bottom:14px">${listHtml}</div>
<div class="fg"><label class="fl">${_grpEditId?'Editing Group':'New Group'} — Name</label><input class="fi" id="grp-name-inp" value="${esc(_grpName)}" placeholder="e.g. All Blood Bag Items"/></div>
<div class="fg" style="margin-top:10px"><label class="fl">Add Items</label><div class="search-wrap"><input class="fi" id="grp-item-search" placeholder="Search item code / name…" autocomplete="off"/></div></div>
<div style="margin-top:8px">${itemsHtml||'<span style="color:var(--text3);font-size:12px">No items added yet.</span>'}</div>
</div>
<div class="req-modal-ft">
<button class="btn b-cancel" id="grp-cancel-btn">${_grpEditId?'Cancel Edit':'Close'}</button>
<button class="btn b-primary" id="grp-save-btn">${_grpEditId?'Save Changes':'Create Group'}</button>
</div>`;
if(window.lucide)lucide.createIcons();
wireGroupModal();
}
function wireGroupModal(){
document.getElementById('grp-modal-x').onclick=closeGroupModal;
document.getElementById('grp-cancel-btn').onclick=()=>{if(_grpEditId){_grpEditId=null;_grpName='';_grpItems=[];renderGroupModal();}else closeGroupModal();};
document.getElementById('grp-save-btn').onclick=saveGroup;
document.getElementById('grp-name-inp').oninput=function(){_grpName=this.value;};
document.querySelectorAll('[data-grp-edit]').forEach(el=>el.onclick=function(){openGroupModal(+this.dataset.grpEdit);});
document.querySelectorAll('[data-grp-del]').forEach(el=>el.onclick=function(){deleteGroupConfirm(+this.dataset.grpDel);});
document.querySelectorAll('[data-grp-item-rm]').forEach(el=>el.onclick=function(){_grpItems.splice(+this.dataset.grpItemRm,1);renderGroupModal();});
const inp=document.getElementById('grp-item-search');
inp.oninput=function(){
const v=this.value.trim();
if(v.length<2){hideDD();return;}
clearTimeout(_grpSearchTimer);
_grpSearchTimer=setTimeout(async()=>{
showDD(inp,'<div class="sd-msg"><span class="sp" style="border-color:rgba(37,99,235,.25);border-top-color:#2563eb"></span></div>');
const items=await fetchItems(v);
if(!items.length){showDD(inp,'<div class="sd-msg">No items found</div>');return;}
const dd=getDD();dd.innerHTML='';
items.forEach(it=>{
const row=document.createElement('div');row.className='sd-row';
row.innerHTML=`<span class="sd-code">${esc(it.ItemCode)}</span><span class="sd-name">${esc(it.ItemName)}</span>`;
row.onmousedown=e=>{
e.preventDefault();
if(!_grpItems.some(x=>x.itemCode===it.ItemCode))_grpItems.push({itemCode:it.ItemCode,itemName:it.ItemName});
inp.value='';hideDD();renderGroupModal();
};
dd.appendChild(row);
});
positionDD(inp);
},250);
};
inp.onblur=()=>setTimeout(hideDD,150);
}
async function saveGroup(){
if(!_grpName.trim()){toast('Enter a group name','err');return;}
if(!_grpItems.length){toast('Add at least one item','err');return;}
try{
if(_grpEditId){
await api('PUT',`/requirement-calc/groups/${_grpEditId}`,{name:_grpName.trim(),items:_grpItems});
toast('Group updated');
}else{
await api('POST','/requirement-calc/groups',{name:_grpName.trim(),items:_grpItems,company:_co});
toast('Group created');
}
_grpEditId=null;_grpName='';_grpItems=[];
await loadCalcGroups();
renderGroupModal();
}catch(e){toast(e.message,'err');}
}
async function deleteGroupConfirm(id){
const g=_calcGroups.find(x=>x.id===id);
if(!g)return;
if(!confirm(`Delete group "${g.name}"? This cannot be undone.`))return;
try{
await api('DELETE',`/requirement-calc/groups/${id}`);
toast('Group deleted');
if(_calcGroupPick===String(id))_calcGroupPick='';
await loadCalcGroups();
renderGroupModal();
render();
}catch(e){toast(e.message,'err');}
}
function fetchGroupIntoRows(){
const g=_calcGroups.find(x=>String(x.id)===_calcGroupPick);
if(!g)return;
const existing=new Set(_calcRows.filter(r=>r.itemCode).map(r=>r.itemCode));
let added=0;
g.items.forEach(it=>{
if(existing.has(it.itemCode))return;
existing.add(it.itemCode);
const row=blankCalcRow();
row.itemCode=it.itemCode;row.itemName=it.itemName;row.itemDesc=it.itemName;
const blankIdx=_calcRows.findIndex(r=>!r.itemCode);
if(blankIdx>=0)_calcRows[blankIdx]=row;else _calcRows.push(row);
added++;
});
if(!added){toast('All items from that group are already in the worksheet','err');return;}
toast(`${added} item(s) added from "${g.name}"`);
render();
}
// ─────────── VIEW ───────────
async function loadList(){_listLoading=true;render();try{const r=await api('GET',`/requirements?company=${encodeURIComponent(_co)}`);_list=r.data||[];}catch(e){toast(e.message,'err');_list=[];}_listLoading=false;render();}
function reqBadge(st){
return st==='FULLY_INTIMATED'
? `<span class="status-badge sb-closed">Fully Intimated</span>`
: st==='PARTIAL'
? `<span class="status-badge" style="background:#fff7ed;color:#c2410c;border:1px solid #fed7aa">Partial</span>`
: `<span class="status-badge sb-open">Open</span>`;
}
function reviewBadge(r){
if(!_reqReviewEnabled)return '';
const stage=r.reviewStage||0;
if(stage===1)return `<span class="status-badge" style="background:#eff6ff;color:#1d4ed8;border:1px solid #bfdbfe">⇄ With Store</span>`;
if(stage===2)return `<span class="status-badge" style="background:#f0fdf4;color:#15803d;border:1px solid #bbf7d0">✓ Store Reviewed</span>`;
return '';
}
function filteredReqList(){
let list=_list;
if(_viewStatus)list=list.filter(r=>(r.intimationStatus||'OPEN')===_viewStatus);
const s=_viewSearch.trim().toUpperCase();
if(s)list=list.filter(r=>
(r.refNo||'').toUpperCase().includes(s)||
(r.createdByName||r.createdBy||'').toUpperCase().includes(s)||
(r.remarks||'').toUpperCase().includes(s)||
(r.lines||[]).some(l=>(l.itemCode||'').toUpperCase().includes(s)||(l.itemName||'').toUpperCase().includes(s)));
return list;
}
function renderView(){
if(_listLoading)return `<div class="card"><div class="empty-msg"><span class="sp" style="border-color:rgba(37,99,235,.25);border-top-color:#2563eb"></span> Loading…</div></div>`;
if(!_list.length)return `<div class="card"><div class="empty-msg">No requirements yet. Create one from the “New Requirement” tab.</div></div>`;
const list=filteredReqList();
const chips=[['','All'],['OPEN','Open'],['PARTIAL','Partial'],['FULLY_INTIMATED','Fully Intimated']];
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 ref no, item code/name, remarks…" value="${esc(_viewSearch)}"/></div>
<div class="vf-chips">${chips.map(([v,lbl])=>`<button class="vf-chip ${_viewStatus===v?'on':''}" data-vfstatus="${v}">${lbl}</button>`).join('')}</div>
<span class="vf-count">${list.length} of ${_list.length}</span>
</div>`;
const grid=list.length
? `<div class="req-grid">${list.map(r=>{
const st=r.intimationStatus||'OPEN';
return `
<div class="req-card" data-id="${r.id}">
<div class="rc-top">
<span class="rc-ref">${esc(r.refNo)}</span>
${reqBadge(st)}
${reviewBadge(r)}
</div>
<div class="rc-icon"><i data-lucide="clipboard-list" style="width:18px;height:18px"></i></div>
<div style="font-size:12px;color:var(--text2);font-weight:600">${(r.lines||[]).length} item(s)</div>
<div class="rc-by">${esc(r.createdByName||r.createdBy||'')}</div>
<div class="rc-meta">
<span><i data-lucide="calendar" style="width:12px;height:12px;vertical-align:-2px"></i> ${fmtDate(r.periodFrom)} → ${fmtDate(r.periodTo)}</span>
<span><i data-lucide="clock" style="width:12px;height:12px;vertical-align:-2px"></i> ${fmtDate(r.createdAt)}</span>
</div>
</div>`;
}).join('')}</div>`
: `<div class="card"><div class="empty-msg">No requirements match your search/filter.</div></div>`;
return bar+grid;
}
function closeReqDetail(){document.getElementById('req-detail-modal').classList.remove('show');}
function openReqDetail(id){
const r=_list.find(x=>x.id===id);
if(!r)return;
const st=r.intimationStatus||'OPEN';
const lines=(r.lines||[]).map(l=>{
const done=l.intimatedQty!=null?l.intimatedQty:0;
const pend=l.pendingQty!=null?l.pendingQty:l.requiredQty;
const pc=Number(pend)>0?'color:#c2410c;font-weight:700':'color:#16a34a;font-weight:700';
return `<tr><td class="td-sr">${l.srNo}</td><td style="font-family:var(--mono);font-size:12px;color:var(--blue)">${esc(l.itemCode)}</td><td>${esc(l.itemName)}</td><td>${esc(l.itemDesc)}</td><td class="td-qty">${esc(l.requiredQty)}</td><td class="td-qty">${esc(done)}</td><td class="td-qty" style="${pc}">${esc(pend)}</td></tr>`;
}).join('');
const actions=r.hasIntimations
? `<button class="rc-btn" disabled title="Locked — batch intimation(s) exist against this requirement. Editing and deleting are disabled." style="opacity:.5;cursor:not-allowed"><i data-lucide="lock" style="width:13px;height:13px"></i> Locked</button>`
: `${CAN_EDIT_REQ?`<button class="rc-btn" data-edit="${r.id}"><i data-lucide="pencil" style="width:13px;height:13px"></i> Edit</button>`:''}
${CAN_DELETE_REQ?`<button class="rc-btn del" data-del="${r.id}"><i data-lucide="trash-2" style="width:13px;height:13px"></i> Delete</button>`:''}`;
const stage=r.reviewStage||0;
let reviewActions='';
if(_reqReviewEnabled){
if(stage===0&&CAN_SHARE_REQ)
reviewActions=`<button class="rc-btn" id="req-share-btn" style="background:#eff6ff;color:#1d4ed8;border-color:#bfdbfe"><i data-lucide="send" style="width:13px;height:13px"></i> Share with Store</button>`;
else if(stage===1&&CAN_STORE_REVIEW_REQ)
reviewActions=`<button class="rc-btn" id="req-storereview-btn" style="background:#eff6ff;color:#1d4ed8;border-color:#bfdbfe"><i data-lucide="edit-3" style="width:13px;height:13px"></i> Store Review</button>`;
}
const reviewSection = (_reqReviewEnabled && stage>0) ? renderReviewSection(r) : '';
const inner=document.getElementById('req-modal-inner');
inner.innerHTML=`
<div class="req-modal-hd">
<span class="rc-ref">${esc(r.refNo)}</span>
${reqBadge(st)}
<span style="font-size:11px;color:var(--text4)">${esc(r.createdByName||r.createdBy||'')}${r.updatedAt?' · edited':''}</span>
<button class="req-modal-close" id="req-modal-x"><i data-lucide="x" style="width:15px;height:15px"></i></button>
</div>
<div class="req-modal-bd">
<div class="rc-meta" style="margin-top:0;border-top:none;padding-top:0">
<span><i data-lucide="calendar" style="width:13px;height:13px;vertical-align:-2px"></i> ${fmtDate(r.periodFrom)} → ${fmtDate(r.periodTo)}</span>
<span><i data-lucide="package" style="width:13px;height:13px;vertical-align:-2px"></i> ${(r.lines||[]).length} item(s)</span>
${st!=='OPEN'?`<span><i data-lucide="layers" style="width:13px;height:13px;vertical-align:-2px"></i> ${st==='FULLY_INTIMATED'?'fully':'partly'} intimated</span>`:''}
</div>
<div class="tbl-wrap" style="margin-top:14px"><table class="req-tbl">
<thead><tr><th class="td-sr">Sr.</th><th>Code</th><th>Item Name</th><th>Description</th><th class="td-qty">Req. Qty</th><th class="td-qty">Intimated</th><th class="td-qty">Pending</th></tr></thead>
<tbody>${lines}</tbody>
</table></div>
${r.remarks?`<div style="margin-top:10px;font-size:12px;color:var(--text3)"><strong>Remarks:</strong> ${esc(r.remarks)}</div>`:''}
${reviewSection}
</div>
<div class="req-modal-ft">${reviewActions}${actions}</div>`;
document.getElementById('req-detail-modal').classList.add('show');
if(window.lucide)lucide.createIcons();
document.getElementById('req-modal-x').onclick=closeReqDetail;
const editBtn=inner.querySelector('[data-edit]');if(editBtn)editBtn.onclick=()=>{closeReqDetail();startEdit(r);};
const delBtn=inner.querySelector('[data-del]');if(delBtn)delBtn.onclick=()=>{closeReqDetail();deleteReq(r.id,r.refNo);};
const shareBtn=inner.querySelector('#req-share-btn');if(shareBtn)shareBtn.onclick=()=>shareWithStore(r);
const srBtn=inner.querySelector('#req-storereview-btn');if(srBtn)srBtn.onclick=()=>openStoreReview(r);
}
// ── Store Review workflow: share / before-after / editable review screen ──
function renderReviewSection(r){
const stage=r.reviewStage||0;
let html=`<div style="margin-top:18px;border-top:1px dashed var(--border);padding-top:14px">
<div style="font-size:12px;font-weight:700;color:var(--text2);margin-bottom:8px">
<i data-lucide="repeat" style="width:13px;height:13px;vertical-align:-2px"></i> Store Review
${stage===1?'<span style="color:#1d4ed8">— shared, awaiting Store</span>':'<span style="color:#15803d">— reviewed by Store</span>'}
</div>
<div style="font-size:11px;color:var(--text4)">Shared by ${esc(r.sharedByName||r.sharedBy||'—')} ${r.sharedAt?'· '+fmtDate(r.sharedAt):''}</div>`;
if(stage===2){
html+=`<div style="font-size:11px;color:var(--text4);margin-top:2px">Reviewed by ${esc(r.reviewedByName||r.reviewedBy||'—')} ${r.reviewedAt?'· '+fmtDate(r.reviewedAt):''}</div>`;
if(r.reviewRemarks)html+=`<div style="margin-top:6px;font-size:12px;color:var(--text3)"><strong>Store remarks:</strong> ${esc(r.reviewRemarks)}</div>`;
const before=r.originalLines||[];
const beforeMap={};before.forEach(l=>beforeMap[l.itemCode]=l);
const after=r.lines||[];
// Only rows Store actually touched: new items, or an existing item whose
// qty changed. Untouched lines are omitted — this is a change log, not a
// full re-listing of the requirement's items.
const rows=after.filter(l=>{
const b=beforeMap[l.itemCode];
return !b||Number(b.requiredQty)!==Number(l.requiredQty);
}).map(l=>{
const b=beforeMap[l.itemCode];
return `<tr>
<td style="font-family:var(--mono);font-size:12px;color:var(--blue)">${esc(l.itemCode)}</td>
<td>${esc(l.itemName)}</td>
<td class="td-qty">${b?esc(b.requiredQty):'<span style="color:#16a34a;font-weight:700">new</span>'}</td>
<td class="td-qty" style="color:#c2410c;font-weight:700">${esc(l.requiredQty)}</td>
</tr>`;
}).join('');
const removed=before.filter(b=>!after.some(a=>a.itemCode===b.itemCode));
const removedRows=removed.map(b=>`<tr><td style="font-family:var(--mono);font-size:12px;color:var(--blue)">${esc(b.itemCode)}</td><td>${esc(b.itemName)}</td><td class="td-qty">${esc(b.requiredQty)}</td><td class="td-qty" style="color:#dc2626;text-decoration:line-through">removed</td></tr>`).join('');
html+=(rows+removedRows)
? `<div class="tbl-wrap" style="margin-top:10px"><table class="req-tbl">
<thead><tr><th>Code</th><th>Item Name</th><th class="td-qty">Before</th><th class="td-qty">After</th></tr></thead>
<tbody>${rows}${removedRows}</tbody>
</table></div>`
: `<div style="margin-top:10px;font-size:12px;color:var(--text3)">No changes — Store reverted without modifying any item or quantity.</div>`;
}
html+='</div>';
return html;
}
async function shareWithStore(r){
try{
const res=await api('POST',`/requirements/${r.id}/share-with-store`,{});
toast('Shared with Store');
closeReqDetail();
await loadList();
}catch(e){toast(e.message,'err');}
}
function openStoreReview(r){
_storeReviewFor=r;
_storeReviewRows=(r.lines||[]).map(l=>({itemCode:l.itemCode||'',itemName:l.itemName||'',itemDesc:l.itemDesc||'',requiredQty:l.requiredQty!=null?l.requiredQty:''}));
_storeReviewRemarks='';
renderStoreReviewModal();
}
function renderStoreReviewModal(){
const r=_storeReviewFor;
const rowsHtml=_storeReviewRows.map((row,i)=>`
<tr>
<td class="td-sr">${i+1}</td>
<td style="font-family:var(--mono);font-size:12px;color:var(--blue)">${esc(row.itemCode)}</td>
<td>${esc(row.itemName)}</td>
<td class="td-qty"><input class="fi" data-srqty="${i}" type="number" min="0" step="any" value="${esc(row.requiredQty)}"/></td>
<td class="td-act"><button class="b-del" data-srrm="${i}" title="Remove"><i data-lucide="trash-2" style="width:14px;height:14px"></i></button></td>
</tr>`).join('');
const inner=document.getElementById('req-modal-inner');
inner.innerHTML=`
<div class="req-modal-hd">
<span class="rc-ref">${esc(r.refNo)}</span>
<span style="font-size:12px;font-weight:700;color:#1d4ed8">Store Review</span>
<button class="req-modal-close" id="req-modal-x"><i data-lucide="x" style="width:15px;height:15px"></i></button>
</div>
<div class="req-modal-bd">
<div style="font-size:12px;color:var(--text3);margin-bottom:10px">Cross-check against SAP's MRP Wizard output, adjust quantities / remove items as needed, then revert to Production. This is a single round trip.</div>
<div class="tbl-wrap"><table class="req-tbl">
<thead><tr><th class="td-sr">Sr.</th><th>Code</th><th>Item Name</th><th class="td-qty">Required Qty</th><th class="td-act"></th></tr></thead>
<tbody>${rowsHtml}</tbody>
</table></div>
<div class="fg" style="margin-top:14px"><label class="fl">Remarks (optional)</label><input class="fi" id="sr-remarks" placeholder="Notes for Production…" value="${esc(_storeReviewRemarks)}"/></div>
</div>
<div class="req-modal-ft">
<button class="rc-btn" id="sr-cancel">Cancel</button>
<button class="rc-btn" id="sr-submit" style="background:#eff6ff;color:#1d4ed8;border-color:#bfdbfe"><i data-lucide="check" style="width:13px;height:13px"></i> Revert to Production</button>
</div>`;
document.getElementById('req-detail-modal').classList.add('show');
if(window.lucide)lucide.createIcons();
document.getElementById('req-modal-x').onclick=closeReqDetail;
document.getElementById('sr-cancel').onclick=closeReqDetail;
document.getElementById('sr-remarks').oninput=function(){_storeReviewRemarks=this.value;};
inner.querySelectorAll('[data-srqty]').forEach(el=>el.oninput=function(){_storeReviewRows[+this.dataset.srqty].requiredQty=this.value;});
inner.querySelectorAll('[data-srrm]').forEach(b=>b.onclick=function(){_storeReviewRows.splice(+this.dataset.srrm,1);renderStoreReviewModal();});
document.getElementById('sr-submit').onclick=submitStoreReview;
}
async function submitStoreReview(){
const lines=_storeReviewRows.filter(row=>row.itemCode).map(row=>({itemCode:row.itemCode,itemName:row.itemName,itemDesc:row.itemDesc,requiredQty:Number(row.requiredQty)||0}));
if(!lines.length){toast('At least one item is required','err');return;}
try{
await api('POST',`/requirements/${_storeReviewFor.id}/revert-to-production`,{lines,remarks:_storeReviewRemarks});
toast('Reverted to Production');
closeReqDetail();
_storeReviewFor=null;_storeReviewRows=null;
await loadList();
}catch(e){toast(e.message,'err');}
}
function wireView(){
document.querySelectorAll('.req-card').forEach(c=>c.onclick=()=>openReqDetail(+c.dataset.id));
const s=q('#vf-search');
if(s){
s.oninput=function(){
_viewSearch=this.value;
const pos=this.selectionStart;
q('#body').innerHTML=renderView();
wireView();
const s2=q('#vf-search');
if(s2){s2.focus();s2.setSelectionRange(pos,pos);}
if(window.lucide)lucide.createIcons();
};
}
document.querySelectorAll('[data-vfstatus]').forEach(b=>b.onclick=function(){_viewStatus=this.dataset.vfstatus;q('#body').innerHTML=renderView();wireView();if(window.lucide)lucide.createIcons();});
}
// ─────────── INIT ───────────
if(_tab==='view')loadList(); else loadNextRef();
loadReviewConfig();
</script>
<script src="/sidebar.js?v=31"></script>
</body>
</html>