654 lines
42 KiB
HTML
654 lines
42 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<script src="/auth-guard.js?v=4"></script>
|
||
<script src="/company-list.js"></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/exceljs@4.4.0/dist/exceljs.min.js"></script>
|
||
<meta charset="UTF-8"/>
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0"/>
|
||
<title>Production Planning — SAP B1</title>
|
||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&family=Bebas+Neue&display=swap" rel="stylesheet"/>
|
||
<style>
|
||
:root{--ink:#080e18;--ink2:#0d1626;--ink3:#111f35;--purple:#9f5afd;--blue:#1a6fff;--amber:#f5a623;--green:#0dbb8a;--red:#e53e3e;--teal:#06b6d4;--border:#1c2e48;--border2:#243650;--text:#c8d6e8;--text2:#7a9ab8;--text3:#3d5470;--surface:#0f1d30;--surface2:#142338;--surface3:#0b1520;--radius:10px;--font:'Space Grotesk',sans-serif;--mono:'JetBrains Mono',monospace;--display:'Bebas Neue',sans-serif;--shadow:0 8px 32px rgba(0,0,0,.5)}
|
||
*{box-sizing:border-box;margin:0;padding:0}
|
||
body{font-family:var(--font);background:var(--ink);color:var(--text);min-height:100vh;-webkit-font-smoothing:antialiased}
|
||
nav{position:sticky;top:0;z-index:200;background:rgba(8,14,24,.92);backdrop-filter:blur(20px);border-bottom:1px solid var(--border);padding:0 12px;height:50px;display:flex;align-items:center;justify-content:space-between;gap:6px}
|
||
.nav-logo{background:var(--blue);color:#fff;font-family:var(--display);font-size:15px;letter-spacing:2px;padding:3px 8px;border-radius:5px}
|
||
.nav-links{display:flex;gap:2px;overflow-x:auto;flex:1;justify-content:center}
|
||
.nav-link{padding:4px 8px;border-radius:5px;font-size:10px;font-weight:600;color:var(--text2);text-decoration:none;white-space:nowrap;font-family:var(--font)}.nav-link:hover{color:var(--text);background:rgba(255,255,255,.05)}.nav-link.active{color:var(--blue);background:rgba(26,111,255,.1)}
|
||
.btn-sm{padding:4px 8px;border-radius:5px;font-size:10px;font-weight:700;border:1px solid var(--border2);color:var(--text3);background:none;cursor:pointer;font-family:var(--font)}.btn-sm:hover{border-color:var(--red);color:var(--red)}
|
||
main{padding:12px;max-width:1400px;margin:0 auto}
|
||
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);margin-bottom:12px;overflow:hidden}
|
||
.card-hd{background:var(--surface3);padding:10px 14px;font-size:12px;font-weight:700;color:var(--text);display:flex;align-items:center;flex-wrap:wrap;row-gap:6px;gap:8px;border-bottom:1px solid var(--border)}
|
||
.card-hd .right{margin-left:auto;font-size:11px;color:var(--text3);font-weight:600}
|
||
.card-bd{padding:12px 14px}
|
||
.fg{margin-bottom:10px}
|
||
.fl{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.4px;color:var(--text3);margin-bottom:4px;display:block}
|
||
.fi{width:100%;padding:8px 10px;border:1.5px solid var(--border2);border-radius:7px;font-size:13px;font-family:var(--font);background:var(--surface2);color:var(--text);outline:none;height:36px}.fi:focus{border-color:var(--blue);box-shadow:0 0 0 2px rgba(26,111,255,.15)}.fi::placeholder{color:var(--text3)}
|
||
select.fi{cursor:pointer;appearance:none;background:var(--surface2) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%233d5470'/%3E%3C/svg%3E") no-repeat right 10px center;padding-right:26px}
|
||
.fi.calc{background:var(--ink2);color:var(--teal);font-weight:700;cursor:not-allowed}
|
||
.btn{padding:9px 16px;border-radius:8px;border:none;font-family:var(--font);font-size:12.5px;font-weight:700;cursor:pointer;transition:all .18s;display:inline-flex;align-items:center;gap:6px;justify-content:center}.btn:disabled{opacity:.4;cursor:not-allowed}
|
||
.b-primary{background:var(--blue);color:#fff}.b-primary:hover:not(:disabled){background:#155ad6}
|
||
.b-cancel{background:var(--surface2);color:var(--text2);border:1px solid var(--border2)}.b-cancel:hover{border-color:var(--text2)}
|
||
.b-danger{background:rgba(229,62,62,.1);color:var(--red);border:1px solid rgba(229,62,62,.3)}.b-danger:hover{background:rgba(229,62,62,.2)}
|
||
.empty-msg{text-align:center;padding:24px;color:var(--text3);font-size:12px}
|
||
.sp{width:14px;height:14px;border:2px solid rgba(255,255,255,.25);border-top-color:#fff;border-radius:50%;animation:spin .6s linear infinite;display:inline-block}
|
||
@keyframes spin{to{transform:rotate(360deg)}}
|
||
.toast-c{position:fixed;top:56px;right:10px;z-index:9999;display:flex;flex-direction:column;gap:6px;pointer-events:none}
|
||
.toast{padding:9px 13px;border-radius:8px;font-size:12px;font-weight:600;box-shadow:var(--shadow);max-width:320px}
|
||
.t-ok{background:var(--green);color:#fff}.t-err{background:var(--red);color:#fff}
|
||
.pp-filters{display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end}
|
||
.pp-filters .fg{margin:0;min-width:140px;flex:1 1 140px}
|
||
.pp-tblwrap{overflow-x:auto;border:1px solid var(--border);border-radius:8px}
|
||
table.pp{width:100%;border-collapse:separate;border-spacing:0;font-size:12px}
|
||
table.pp th{text-align:left;padding:8px 10px;font-size:9.5px;font-weight:700;text-transform:uppercase;letter-spacing:.3px;color:var(--text3);background:var(--surface3);border-bottom:2px solid var(--border2);white-space:nowrap}
|
||
table.pp td{padding:8px 10px;border-bottom:1px solid var(--border);white-space:nowrap}
|
||
table.pp tbody tr:hover td{background:rgba(26,111,255,.05)}
|
||
table.pp .num{text-align:right;font-family:var(--mono)}
|
||
table.pp .calc{color:var(--teal);font-family:var(--mono);font-weight:700}
|
||
.pill{font-size:9.5px;font-weight:800;padding:3px 9px;border-radius:20px;letter-spacing:.3px}
|
||
.pill-planned{background:rgba(245,166,35,.12);color:var(--amber)}
|
||
.pill-done{background:rgba(13,187,138,.12);color:var(--green)}
|
||
.pill-approved{background:rgba(26,111,255,.12);color:var(--blue)}
|
||
.pill-rejected{background:rgba(229,62,62,.12);color:var(--red)}
|
||
.row-actions{display:flex;gap:6px}
|
||
.icon-btn{width:26px;height:26px;border-radius:6px;border:1px solid var(--border2);background:var(--surface2);color:var(--text2);cursor:pointer;font-size:12px}
|
||
.icon-btn:hover{border-color:var(--blue);color:var(--blue)}
|
||
.icon-btn.danger:hover{border-color:var(--red);color:var(--red);background:rgba(229,62,62,.08)}
|
||
.pp-pager{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:12px}
|
||
.pp-count{font-size:11px;font-weight:700;color:var(--blue);background:rgba(26,111,255,.1);padding:3px 11px;border-radius:20px}
|
||
/* Modal */
|
||
.mv{position:fixed;inset:0;z-index:9998;background:rgba(4,8,14,.72);backdrop-filter:blur(4px);display:flex;align-items:flex-start;justify-content:center;padding:22px 14px;overflow:auto}
|
||
.mw{width:100%;max-width:920px;background:var(--surface);border:1px solid var(--border2);border-radius:14px;box-shadow:var(--shadow);overflow:hidden}
|
||
.mh{display:flex;align-items:center;gap:12px;padding:14px 18px;background:var(--surface3);border-bottom:1px solid var(--border)}
|
||
.mh-t{font-size:14px;font-weight:700;color:var(--text)}
|
||
.mx{width:30px;height:30px;border-radius:8px;border:1px solid var(--border2);background:none;color:var(--text2);cursor:pointer;font-size:14px;margin-left:auto}
|
||
.mx:hover{border-color:var(--red);color:var(--red)}
|
||
.mb{padding:16px 18px;max-height:calc(100vh - 210px);overflow:auto;display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px}
|
||
.mf{display:flex;gap:8px;padding:14px 18px;border-top:1px solid var(--border);background:var(--surface3)}
|
||
.mf .grow{margin-left:auto}
|
||
.mb .wide{grid-column:1/-1}
|
||
.mb.list{display:block}
|
||
.def-row{display:flex;align-items:center;gap:10px;padding:8px 4px;border-bottom:1px solid var(--border);font-size:12px;flex-wrap:wrap}
|
||
.def-row .code{font-family:var(--mono);font-weight:700;color:var(--blue);width:86px;flex-shrink:0}
|
||
.def-row .name{flex:1 1 140px;min-width:0;color:var(--text2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.def-row .vals{display:flex;gap:12px;font-family:var(--mono);font-size:10.5px;color:var(--text3);flex-wrap:wrap}
|
||
.def-row .vals b{color:var(--text)}
|
||
.qty-progress-wrap{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
|
||
.qty-progress-bar{flex:1 1 160px;height:10px;border-radius:6px;background:var(--surface2);border:1px solid var(--border2);overflow:hidden}
|
||
.qty-progress-fill{height:100%;background:var(--blue);border-radius:6px;transition:width .2s}
|
||
.qty-progress-fill.over{background:var(--amber)}
|
||
.qty-progress-text{font-family:var(--mono);font-size:12px;font-weight:700;color:var(--text);white-space:nowrap}
|
||
.pp-dd{position:fixed;background:var(--surface2);border:1px solid var(--blue);border-radius:10px;box-shadow:0 10px 32px rgba(0,0,0,.4);z-index:9999;max-height:320px;overflow-y:auto;display:none}
|
||
.pp-dd-row{padding:10px 14px;cursor:pointer;border-bottom:1px solid var(--border);display:flex;flex-direction:column;gap:3px}
|
||
.pp-dd-row:hover{background:rgba(26,111,255,.12)}
|
||
.pp-dd-code{font-size:14px;font-weight:700;color:var(--blue)}
|
||
.pp-dd-name{font-size:13px;color:var(--text2);line-height:1.35;white-space:normal}
|
||
.pp-dd-msg{padding:14px;text-align:center;color:var(--text3);font-size:12px}
|
||
@media (max-width:600px){
|
||
main{padding:8px}
|
||
nav{padding:0 8px}
|
||
.card-bd{padding:10px}
|
||
.mb{grid-template-columns:1fr;padding:12px}
|
||
.mv{padding:0}
|
||
.mw{max-width:100%;height:100%;border-radius:0}
|
||
.mb{max-height:calc(100vh - 160px)}
|
||
table.pp th,table.pp td{padding:6px 7px;font-size:11px}
|
||
}
|
||
</style>
|
||
<link rel="stylesheet" href="/theme.css?v=3"/>
|
||
</head>
|
||
<body>
|
||
<div class="toast-c" id="toast-c"></div>
|
||
<nav>
|
||
<span class="nav-logo">SAP</span>
|
||
<div class="nav-links">
|
||
<a href="/" class="nav-link">Home</a><a href="/production" class="nav-link">Production</a>
|
||
<a href="/production-planning" class="nav-link active">Production Planning</a>
|
||
</div>
|
||
<button class="btn-sm" id="btn-logout">Logout</button>
|
||
</nav>
|
||
<main id="app"></main>
|
||
|
||
<script>
|
||
'use strict';
|
||
let _tok=null,_user=null;
|
||
const BE='/api';
|
||
(function(){try{_tok=sessionStorage.getItem('portal_token');const u=sessionStorage.getItem('portal_user');if(u)_user=JSON.parse(u);}catch(_e){}})();
|
||
if(!_tok)window.location.href='/';
|
||
document.getElementById('btn-logout').onclick=()=>{sessionStorage.clear();document.cookie='portal_token=;path=/;expires=Thu, 01 Jan 1970 00:00:00 GMT';document.cookie='portal_user=;path=/;expires=Thu, 01 Jan 1970 00:00:00 GMT';localStorage.removeItem('token');localStorage.removeItem('portal_user');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;
|
||
}
|
||
const CAN_VIEW=hasStepPerm('production_planning:entry','view')||hasStepPerm('production_planning:approve','view');
|
||
const CAN_ADD =hasStepPerm('production_planning:entry','add');
|
||
const CAN_EDIT=hasStepPerm('production_planning:entry','edit');
|
||
const CAN_DEL =hasStepPerm('production_planning:entry','delete');
|
||
const CAN_APPROVE=hasStepPerm('production_planning:approve','approve');
|
||
|
||
function esc(v){return(v||'').toString().replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
|
||
function today(){return new Date().toISOString().slice(0,10);}
|
||
function num(v){const n=parseFloat(v);return isNaN(n)?0:n;}
|
||
|
||
let _co=window.__DEFAULT_COMPANY__;
|
||
let _companies=[{value:_co,label:_co}];
|
||
function coOptions(sel){return _companies.map(c=>`<option value="${esc(c.value)}" ${sel===c.value?'selected':''}>${esc(c.label)}</option>`).join('');}
|
||
|
||
const PRODUCT_TYPES=['Blood Bag','CAPD'];
|
||
|
||
let _plans=[];let _listLoading=false;let _exporting=false;
|
||
let _fStatus='ALL';let _fFrom='';let _fTo='';let _fType='ALL';let _fMarket='';let _fSearch='';
|
||
let _searchTimer=null;let _listSearchTimer=null;
|
||
let _page=0,_pageSize=25;
|
||
let _modalOpen=false;let _saving=false;
|
||
let _editId=null;let _editRejectInfo=null;
|
||
let _form=blankForm();
|
||
function blankForm(){return {planDate:today(),productName:'',itemCode:'',productType:'',market:'',noOfPackage:0,quantity:0,perCycleQty:0,noOfCycleDay:0,status:'PLANNED',batchNo:''};}
|
||
|
||
let _defModalOpen=false;let _defaults=[];let _defLoading=false;let _defSaving=false;let _defEditId=null;
|
||
let _defForm=blankDefForm();
|
||
function blankDefForm(){return {itemCode:'',itemName:'',noOfPackage:0,perCycleQty:0,noOfCycleDay:0,productType:''};}
|
||
|
||
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(_e){}return[];}
|
||
function getDD(){let dd=document.getElementById('float-dd');if(!dd){dd=document.createElement('div');dd.id='float-dd';dd.className='pp-dd';document.body.appendChild(dd);}return dd;}
|
||
function positionDD(inp){const dd=getDD(),r=inp.getBoundingClientRect();dd.style.left=r.left+'px';dd.style.top=(r.bottom+3)+'px';dd.style.width=Math.max(r.width,300)+'px';}
|
||
function showDD(inp,html){const dd=getDD();dd.innerHTML=html;dd.style.display='block';positionDD(inp);}
|
||
function hideDD(){const dd=document.getElementById('float-dd');if(dd)dd.style.display='none';}
|
||
|
||
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(BE+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=document.getElementById('toast-c');const el=document.createElement('div');el.className=`toast t-${type}`;el.textContent=(type==='ok'?'OK ':'ERR ')+msg;tc.appendChild(el);setTimeout(()=>el.remove(),4500);}
|
||
|
||
async function loadList(){
|
||
_listLoading=true;render();
|
||
try{
|
||
const qs=new URLSearchParams();qs.set('company',_co);
|
||
if(_fStatus!=='ALL')qs.set('status',_fStatus);
|
||
if(_fFrom)qs.set('from',_fFrom);
|
||
if(_fTo)qs.set('to',_fTo);
|
||
if(_fType!=='ALL')qs.set('productType',_fType);
|
||
if(_fMarket.trim())qs.set('market',_fMarket.trim());
|
||
if(_fSearch.trim())qs.set('q',_fSearch.trim());
|
||
const r=await api('GET',`/production-planning?${qs.toString()}`);
|
||
_plans=r.data||[];
|
||
}catch(e){toast(e.message,'err');_plans=[];}
|
||
_listLoading=false;_page=0;render();
|
||
}
|
||
|
||
function openNew(){_editId=null;_editRejectInfo=null;_form=blankForm();_modalOpen=true;render();loadDefaults();}
|
||
function openEdit(p){
|
||
_editId=p.id;
|
||
_form={planDate:p.planDate,productName:p.productName,itemCode:'',productType:p.productType||'',market:p.market||'',noOfPackage:p.noOfPackage,quantity:p.quantity,perCycleQty:p.perCycleQty,noOfCycleDay:p.noOfCycleDay,status:p.status,batchNo:p.batchNo};
|
||
_editRejectInfo=p.rejectedBy?{by:p.rejectedByName||p.rejectedBy,reason:p.rejectReason}:null;
|
||
_modalOpen=true;render();loadDefaults();
|
||
}
|
||
function closeModal(){_modalOpen=false;hideDD();render();}
|
||
|
||
async function savePlan(){
|
||
if(!_form.planDate){toast('Date is required','err');return;}
|
||
if(!_form.productName.trim()){toast('Product Name is required','err');return;}
|
||
const totalPerDay=num(_form.perCycleQty)*num(_form.noOfCycleDay);
|
||
if(totalPerDay>0&&num(_form.quantity)>totalPerDay){toast(`Quantity cannot exceed Total Qty/Day (${fmtNum(totalPerDay)})`,'err');return;}
|
||
_saving=true;render();
|
||
try{
|
||
const body={...(_form),company:_co};
|
||
if(_editId)await api('PUT',`/production-planning/${_editId}`,body);
|
||
else await api('POST','/production-planning',body);
|
||
toast(_editId?'Plan updated':'Plan added');
|
||
_modalOpen=false;_saving=false;
|
||
await loadList();
|
||
}catch(e){_saving=false;toast(e.message,'err');render();}
|
||
}
|
||
|
||
function onListSearch(inp){
|
||
_fSearch=inp.value;
|
||
clearTimeout(_listSearchTimer);
|
||
_listSearchTimer=setTimeout(()=>loadList(),350);
|
||
}
|
||
|
||
async function deletePlan(p){
|
||
if(!confirm(`Delete plan for "${p.productName}" on ${p.planDate}?`))return;
|
||
try{await api('DELETE',`/production-planning/${p.id}`);toast('Plan deleted');await loadList();}
|
||
catch(e){toast(e.message,'err');}
|
||
}
|
||
|
||
async function toggleStatus(p){
|
||
const next=p.status==='DONE'?'PLANNED':'DONE';
|
||
try{await api('PUT',`/production-planning/${p.id}/status`,{status:next});toast(`Marked ${next==='DONE'?'Done':'Planned'}`);await loadList();}
|
||
catch(e){toast(e.message,'err');}
|
||
}
|
||
|
||
async function approvePlan(p){
|
||
if(!confirm(`Approve plan for "${p.productName}" on ${p.planDate}?`))return;
|
||
try{await api('PUT',`/production-planning/${p.id}/approve`);toast('Plan approved');await loadList();}
|
||
catch(e){toast(e.message,'err');}
|
||
}
|
||
|
||
async function rejectPlan(p){
|
||
const reason=prompt(`Reject plan for "${p.productName}" on ${p.planDate} — reason (required):`);
|
||
if(reason===null)return;
|
||
if(!reason.trim()){toast('A reason is required to reject','err');return;}
|
||
try{await api('PUT',`/production-planning/${p.id}/reject`,{reason:reason.trim()});toast('Plan rejected — sent back for update');await loadList();}
|
||
catch(e){toast(e.message,'err');}
|
||
}
|
||
|
||
function fmtNum(v){const n=num(v);return n%1===0?String(n):n.toFixed(2);}
|
||
|
||
// Styled .xlsx export via ExcelJS (same pattern as Rejection Analytics'
|
||
// export — SheetJS, the app's other library, has no cell-styling support
|
||
// in its free build). Exports the CURRENTLY filtered/listed plans, with a
|
||
// title block and a bordered/colored table. Prepared By/Date and Approved
|
||
// By/Date are per-plan columns (who actually entered / approved THAT row),
|
||
// not a single blank signature line — different plans can have different
|
||
// preparers and approvers.
|
||
const XLSX_BLUE='FF1A6FFF', XLSX_INK='FF0B1520', XLSX_WHITE='FFFFFFFF', XLSX_ZEBRA='FFEFF4FF', XLSX_TEXT='FF0F172A', XLSX_MUTED='FF64748B';
|
||
function xlsxBorder(color){
|
||
const b={style:'thin',color:{argb:color||'FFB9C6DA'}};
|
||
return {top:b,left:b,bottom:b,right:b};
|
||
}
|
||
async function exportPlans(){
|
||
if(!_plans.length){toast('Nothing to export in this filter','err');return;}
|
||
if(typeof ExcelJS==='undefined'){toast('Export library failed to load — check your connection and retry','err');return;}
|
||
_exporting=true;render();
|
||
try{
|
||
const wb=new ExcelJS.Workbook();
|
||
wb.creator='SAP ERP Portal';wb.created=new Date();
|
||
const ws=wb.addWorksheet('Production Planning',{views:[{state:'frozen',ySplit:7}]});
|
||
const totalPerDayOf=p=>num(p.perCycleQty)*num(p.noOfCycleDay);
|
||
const fmtDt=iso=>iso?new Date(iso).toLocaleString():'';
|
||
// No. of Package / Prod. As per Single don't apply to CAPD (see
|
||
// renderModal) — when every plan being exported is CAPD, drop those two
|
||
// columns entirely instead of a sheet full of "—".
|
||
const allCapd=_plans.every(p=>p.productType==='CAPD');
|
||
const colDefs=[
|
||
{h:'Date',w:12,get:p=>p.planDate},
|
||
{h:'Batch No.',w:14,get:p=>p.batchNo||''},
|
||
{h:'Product Name',w:26,get:p=>p.productName},
|
||
{h:'Product Type',w:13,get:p=>p.productType||''},
|
||
{h:'Market',w:14,get:p=>p.market||''},
|
||
{h:'No. of Package',w:13,num:true,skip:allCapd,get:p=>p.noOfPackage},
|
||
{h:'Quantity',w:11,num:true,get:p=>p.quantity},
|
||
{h:'Prod. As per Single',w:15,num:true,skip:allCapd,get:p=>p.prodAsPerSingle},
|
||
{h:'Per Cycle Qty',w:12,num:true,get:p=>p.perCycleQty},
|
||
{h:'No. of Cycle/Day',w:14,num:true,get:p=>p.noOfCycleDay},
|
||
{h:'Total Qty/Day',w:13,num:true,get:p=>totalPerDayOf(p)},
|
||
{h:'Status',w:10,status:true,get:p=>p.status==='DONE'?'Done':'Planned'},
|
||
{h:'Prepared By',w:16,get:p=>p.createdByName||p.createdBy||''},
|
||
{h:'Prepared Date',w:18,get:p=>fmtDt(p.createdAt)},
|
||
{h:'Approved By',w:16,approval:true,get:p=>p.approvedByName||p.approvedBy||''},
|
||
{h:'Approved Date',w:18,get:p=>fmtDt(p.approvedAt)},
|
||
].filter(c=>!c.skip);
|
||
const cols=colDefs.map(c=>c.h);
|
||
|
||
// ── Title block ──
|
||
ws.mergeCells(`A1:${String.fromCharCode(64+cols.length)}1`);
|
||
const title=ws.getCell('A1');
|
||
title.value='Production Planning';
|
||
title.font={bold:true,size:16,color:{argb:XLSX_WHITE}};
|
||
title.fill={type:'pattern',pattern:'solid',fgColor:{argb:XLSX_BLUE}};
|
||
title.alignment={vertical:'middle',horizontal:'left'};
|
||
ws.getRow(1).height=26;
|
||
|
||
const statusLabel=_fStatus==='ALL'?'All':(_fStatus==='DONE'?'Done':'Planned');
|
||
const typeLabel=_fType==='ALL'?'All':_fType;
|
||
const metaRows=[
|
||
['Company:',_co,'Status:',statusLabel],
|
||
['From:',_fFrom||'(all)','To:',_fTo||'(all)'],
|
||
['Product Type:',typeLabel,'Market:',_fMarket||'(all)'],
|
||
['Generated:',new Date().toLocaleString(),'Total plans:',_plans.length],
|
||
];
|
||
metaRows.forEach((r,i)=>{
|
||
const row=ws.getRow(2+i);
|
||
row.getCell(1).value=r[0];row.getCell(1).font={bold:true,color:{argb:XLSX_MUTED}};
|
||
row.getCell(2).value=r[1];row.getCell(2).font={color:{argb:XLSX_TEXT}};
|
||
row.getCell(3).value=r[2];row.getCell(3).font={bold:true,color:{argb:XLSX_MUTED}};
|
||
row.getCell(4).value=r[3];row.getCell(4).font={color:{argb:XLSX_TEXT}};
|
||
});
|
||
|
||
// ── Table ──
|
||
const hdrRowNum=7;
|
||
const hdrRow=ws.getRow(hdrRowNum);
|
||
cols.forEach((h,i)=>{
|
||
const cell=hdrRow.getCell(i+1);
|
||
cell.value=h;
|
||
cell.font={bold:true,color:{argb:XLSX_WHITE}};
|
||
cell.fill={type:'pattern',pattern:'solid',fgColor:{argb:XLSX_INK}};
|
||
cell.alignment={vertical:'middle',horizontal:'center',wrapText:true};
|
||
cell.border=xlsxBorder('FF0B1520');
|
||
});
|
||
hdrRow.height=26;
|
||
_plans.forEach((p,i)=>{
|
||
const row=ws.getRow(hdrRowNum+1+i);
|
||
const isCapd=p.productType==='CAPD';
|
||
colDefs.forEach((c,ci)=>{
|
||
const cell=row.getCell(ci+1);
|
||
let v=c.get(p);
|
||
if(c.num&&isCapd&&(c.h==='No. of Package'||c.h==='Prod. As per Single'))v='—';
|
||
cell.value=v;
|
||
cell.border=xlsxBorder();
|
||
cell.fill={type:'pattern',pattern:'solid',fgColor:{argb:i%2?XLSX_ZEBRA:XLSX_WHITE}};
|
||
if(c.num)cell.alignment={horizontal:'right'};
|
||
if(c.status){
|
||
cell.font={bold:true,color:{argb:p.status==='DONE'?'FF0DBB8A':'FFB8860B'}};
|
||
cell.alignment={horizontal:'center'};
|
||
}
|
||
if(c.approval){
|
||
cell.font={bold:!!v,color:{argb:v?'FF1A6FFF':'FF94A3B8'}};
|
||
if(!v)cell.value='Pending';
|
||
}
|
||
});
|
||
});
|
||
ws.columns=colDefs.map(c=>({width:c.w}));
|
||
|
||
const buf=await wb.xlsx.writeBuffer();
|
||
const blob=new Blob([buf],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});
|
||
const link=document.createElement('a');
|
||
link.href=URL.createObjectURL(blob);
|
||
link.download=`Production_Planning_${_fFrom||'all'}_to_${_fTo||'all'}.xlsx`;
|
||
link.click();
|
||
URL.revokeObjectURL(link.href);
|
||
}catch(e){toast(e.message||'Export failed','err');}
|
||
_exporting=false;render();
|
||
}
|
||
|
||
function renderModal(){
|
||
if(!_modalOpen)return '';
|
||
const prodAsPerSingle=fmtNum(num(_form.quantity)*num(_form.noOfPackage));
|
||
const totalPerDay=num(_form.perCycleQty)*num(_form.noOfCycleDay);
|
||
const pct=totalPerDay>0?Math.min(999,Math.round(num(_form.quantity)/totalPerDay*1000)/10):0;
|
||
return `<div class="mv" id="mv">
|
||
<div class="mw">
|
||
<div class="mh"><div class="mh-t">${_editId?'Edit':'New'} Production Plan</div><button class="mx" onclick="closeModal()">✕</button></div>
|
||
${_editRejectInfo?`<div style="margin:12px 18px 0;padding:10px 14px;background:rgba(229,62,62,.1);border:1px solid rgba(229,62,62,.3);border-radius:8px;font-size:12px;color:var(--text)"><b style="color:var(--red)">Rejected</b> by ${esc(_editRejectInfo.by)}: ${esc(_editRejectInfo.reason)}</div>`:''}
|
||
<div class="mb">
|
||
<div class="fg"><label class="fl">Date</label><input type="date" class="fi" value="${esc(_form.planDate)}" oninput="_form.planDate=this.value"/></div>
|
||
<div class="fg"><label class="fl">Batch No.</label><input type="text" class="fi" value="${esc(_form.batchNo)}" placeholder="Batch no." oninput="_form.batchNo=this.value"/></div>
|
||
<div class="fg wide"><label class="fl">Product Name</label><input type="text" class="fi" id="pp-product-search" autocomplete="off" list="pp-product-datalist" value="${esc(_form.productName)}" placeholder="Search item..." oninput="onProductInput(this)"/>
|
||
<datalist id="pp-product-datalist">${_defaults.map(d=>`<option value="${esc(productOptionLabel(d))}"></option>`).join('')}</datalist>
|
||
</div>
|
||
<div class="fg"><label class="fl">Product Type</label><select class="fi" onchange="_form.productType=this.value;if(this.value==='CAPD')_form.noOfPackage=0;render()">
|
||
<option value="" ${!_form.productType?'selected':''}>—</option>
|
||
${PRODUCT_TYPES.map(t=>`<option value="${esc(t)}" ${_form.productType===t?'selected':''}>${esc(t)}</option>`).join('')}
|
||
</select></div>
|
||
<div class="fg"><label class="fl">Market</label><input type="text" class="fi" value="${esc(_form.market)}" placeholder="Market" oninput="_form.market=this.value"/></div>
|
||
${_form.productType!=='CAPD'?`<div class="fg"><label class="fl">No. of Package</label><input type="number" class="fi calc" value="${_form.noOfPackage}" disabled/></div>`:''}
|
||
<div class="fg"><label class="fl">Quantity</label><input type="number" class="fi" id="qty-input" value="${_form.quantity}" max="${totalPerDay||''}" oninput="onQtyInput(this)"/></div>
|
||
${_form.productType!=='CAPD'?`<div class="fg"><label class="fl">Prod. As per Single</label><input type="text" class="fi calc" id="pps-display" value="${prodAsPerSingle}" disabled/></div>`:''}
|
||
<div class="fg"><label class="fl">Per Cycle Qty</label><input type="number" class="fi calc" value="${_form.perCycleQty}" disabled/></div>
|
||
<div class="fg"><label class="fl">No. of Cycle/Day</label><input type="number" class="fi calc" value="${_form.noOfCycleDay}" disabled/></div>
|
||
<div class="fg"><label class="fl">Total Qty to Produce / Day</label><input type="text" class="fi calc" id="totalday-display" value="${fmtNum(totalPerDay)}" disabled/></div>
|
||
<div class="fg"><label class="fl">Status</label><select class="fi" onchange="_form.status=this.value">
|
||
<option value="PLANNED" ${_form.status==='PLANNED'?'selected':''}>Planned</option>
|
||
<option value="DONE" ${_form.status==='DONE'?'selected':''}>Done</option>
|
||
</select></div>
|
||
<div class="fg wide">
|
||
<label class="fl">Quantity of Total Qty/Day</label>
|
||
<div class="qty-progress-wrap">
|
||
<div class="qty-progress-bar"><div class="qty-progress-fill ${pct>100?'over':''}" id="qty-progress-fill" style="width:${Math.min(100,pct)}%"></div></div>
|
||
<div class="qty-progress-text" id="qty-progress-text">${fmtNum(_form.quantity)} / ${fmtNum(totalPerDay)} (${pct}%)</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="mf">
|
||
<button class="btn b-cancel" onclick="closeModal()">Cancel</button>
|
||
<button class="btn b-primary grow" ${_saving?'disabled':''} onclick="savePlan()">${_saving?'<span class="sp"></span> Saving...':'Save'}</button>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
// Plan entry only ever offers items that already have Item Defaults
|
||
// configured — a plan can't be entered for an item with no master data
|
||
// (No. of Package / Per Cycle Qty / No. of Cycle/Day are read-only, fed
|
||
// only from that master). Uses a native <datalist> (see renderModal) —
|
||
// the earlier custom floating-dropdown version broke whenever a
|
||
// background render() (loadDefaults, etc.) rebuilt the modal mid-typing
|
||
// and orphaned the dropdown's reference to the now-detached input;
|
||
// letting the browser own the suggestion UI sidesteps that class of bug
|
||
// entirely, since it isn't a page-level DOM node we manage.
|
||
function productOptionLabel(d){return `${d.itemCode} — ${d.itemName}`;}
|
||
function onProductInput(inp){
|
||
_form.productName=inp.value;
|
||
const match=_defaults.find(d=>productOptionLabel(d)===inp.value);
|
||
if(match&&_form.itemCode!==match.itemCode)applyProductSelection(match);
|
||
}
|
||
function applyProductSelection(d){
|
||
const changed=_form.itemCode!==d.itemCode;
|
||
_form.productName=d.itemName||d.itemCode;
|
||
_form.itemCode=d.itemCode;
|
||
_form.noOfPackage=d.noOfPackage;
|
||
_form.perCycleQty=d.perCycleQty;
|
||
_form.noOfCycleDay=d.noOfCycleDay;
|
||
if(d.productType)_form.productType=d.productType;
|
||
// A different item's master values never apply to the previously picked
|
||
// item's typed Quantity — clear it so nothing stale lingers.
|
||
if(changed)_form.quantity=0;
|
||
render();
|
||
toast('Defaults auto-filled from item master');
|
||
}
|
||
function onQtyInput(inp){
|
||
const totalPerDay=num(_form.perCycleQty)*num(_form.noOfCycleDay);
|
||
let v=num(inp.value);
|
||
if(totalPerDay>0&&v>totalPerDay){
|
||
v=totalPerDay;
|
||
inp.value=v;
|
||
toast(`Quantity cannot exceed Total Qty/Day (${fmtNum(totalPerDay)})`,'err');
|
||
}
|
||
_form.quantity=inp.value;
|
||
patchCalcFields();
|
||
}
|
||
function patchCalcFields(){
|
||
const pps=document.getElementById('pps-display');
|
||
if(pps)pps.value=fmtNum(num(_form.quantity)*num(_form.noOfPackage));
|
||
const totalPerDay=num(_form.perCycleQty)*num(_form.noOfCycleDay);
|
||
const td=document.getElementById('totalday-display');
|
||
if(td)td.value=fmtNum(totalPerDay);
|
||
const pct=totalPerDay>0?Math.min(999,Math.round(num(_form.quantity)/totalPerDay*1000)/10):0;
|
||
const fill=document.getElementById('qty-progress-fill');
|
||
if(fill){fill.style.width=Math.min(100,pct)+'%';fill.classList.toggle('over',pct>100);}
|
||
const txt=document.getElementById('qty-progress-text');
|
||
if(txt)txt.textContent=`${fmtNum(_form.quantity)} / ${fmtNum(totalPerDay)} (${pct}%)`;
|
||
}
|
||
|
||
// ── Item Defaults (master data, per item code) ─────────────────────
|
||
// Called both to refresh the Item Defaults modal itself AND, silently, on
|
||
// every New/Edit Plan open so its Product Name search stays current — the
|
||
// latter must NOT trigger a full render() (it rebuilds #app from scratch,
|
||
// which would tear out and recreate the search input mid-typing, orphaning
|
||
// any in-flight dropdown positioning against the now-detached old node).
|
||
async function loadDefaults(){
|
||
_defLoading=true;if(_defModalOpen)render();
|
||
try{const r=await api('GET','/production-planning/item-defaults');_defaults=r.data||[];}
|
||
catch(e){if(_defModalOpen)toast(e.message,'err');_defaults=[];}
|
||
_defLoading=false;if(_defModalOpen)render();
|
||
}
|
||
function openDefaults(){_defModalOpen=true;_defEditId=null;_defForm=blankDefForm();render();loadDefaults();}
|
||
function closeDefaults(){_defModalOpen=false;hideDD();render();}
|
||
function newDefRow(){_defEditId=null;_defForm=blankDefForm();render();}
|
||
function openDefEdit(d){_defEditId=d.id;_defForm={itemCode:d.itemCode,itemName:d.itemName,noOfPackage:d.noOfPackage,perCycleQty:d.perCycleQty,noOfCycleDay:d.noOfCycleDay,productType:d.productType||''};render();}
|
||
async function saveDefault(){
|
||
if(!_defForm.itemCode.trim()){toast('Item Code is required','err');return;}
|
||
_defSaving=true;render();
|
||
try{
|
||
await api('POST','/production-planning/item-defaults',_defForm);
|
||
toast('Item default saved');
|
||
_defForm=blankDefForm();_defEditId=null;_defSaving=false;
|
||
await loadDefaults();
|
||
}catch(e){_defSaving=false;toast(e.message,'err');render();}
|
||
}
|
||
async function deleteDefault(d){
|
||
if(!confirm(`Delete defaults for item "${d.itemCode}"?`))return;
|
||
try{await api('DELETE',`/production-planning/item-defaults/${d.id}`);toast('Deleted');await loadDefaults();}
|
||
catch(e){toast(e.message,'err');}
|
||
}
|
||
function onDefItemSearch(inp){
|
||
_defForm.itemCode=inp.value;
|
||
const v=inp.value.trim();
|
||
if(v.length<2){hideDD();return;}
|
||
clearTimeout(_searchTimer);
|
||
_searchTimer=setTimeout(async()=>{
|
||
showDD(inp,'<div class="pp-dd-msg"><span class="sp" style="border-color:rgba(26,111,255,.25);border-top-color:var(--blue)"></span></div>');
|
||
const items=await fetchItems(v);
|
||
if(!items.length){showDD(inp,'<div class="pp-dd-msg">No items found</div>');return;}
|
||
const dd=getDD();dd.innerHTML='';
|
||
items.forEach(it=>{
|
||
const row=document.createElement('div');row.className='pp-dd-row';
|
||
row.innerHTML=`<span class="pp-dd-code">${esc(it.ItemCode)}</span><span class="pp-dd-name">${esc(it.ItemName)}</span>`;
|
||
row.onmousedown=e=>{e.preventDefault();_defForm.itemCode=it.ItemCode;_defForm.itemName=it.ItemName||'';hideDD();render();};
|
||
dd.appendChild(row);
|
||
});
|
||
positionDD(inp);
|
||
},250);
|
||
}
|
||
function renderDefaultsModal(){
|
||
if(!_defModalOpen)return '';
|
||
return `<div class="mv" id="defmv">
|
||
<div class="mw" style="max-width:1040px">
|
||
<div class="mh"><div class="mh-t">Item Defaults — Production Planning</div><button class="mx" onclick="closeDefaults()">✕</button></div>
|
||
<div class="mb list">
|
||
<div class="fg" style="margin-bottom:14px">
|
||
<label class="fl">${_defEditId?'Edit':'Add'} Item Default</label>
|
||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:10px">
|
||
<div class="fg" style="margin:0"><label class="fl">Item Code</label><input type="text" class="fi" id="pp-def-item-search" autocomplete="off" placeholder="Search item..." value="${esc(_defForm.itemCode)}" oninput="onDefItemSearch(this)" onblur="setTimeout(hideDD,150)"/></div>
|
||
<div class="fg" style="margin:0"><label class="fl">Item Name</label><input type="text" class="fi" placeholder="Item Name" value="${esc(_defForm.itemName)}" oninput="_defForm.itemName=this.value"/></div>
|
||
${_defForm.productType!=='CAPD'?`<div class="fg" style="margin:0"><label class="fl">No. of Package</label><input type="number" class="fi" value="${_defForm.noOfPackage}" oninput="_defForm.noOfPackage=this.value"/></div>`:''}
|
||
<div class="fg" style="margin:0"><label class="fl">Per Cycle Qty</label><input type="number" class="fi" value="${_defForm.perCycleQty}" oninput="_defForm.perCycleQty=this.value"/></div>
|
||
<div class="fg" style="margin:0"><label class="fl">No. of Cycle/Day</label><input type="number" class="fi" value="${_defForm.noOfCycleDay}" oninput="_defForm.noOfCycleDay=this.value"/></div>
|
||
<div class="fg" style="margin:0"><label class="fl">Product Type</label><select class="fi" onchange="_defForm.productType=this.value;if(this.value==='CAPD')_defForm.noOfPackage=0;render()">
|
||
<option value="" ${!_defForm.productType?'selected':''}>—</option>
|
||
${PRODUCT_TYPES.map(t=>`<option value="${esc(t)}" ${_defForm.productType===t?'selected':''}>${esc(t)}</option>`).join('')}
|
||
</select></div>
|
||
</div>
|
||
<div style="display:flex;gap:8px;margin-top:10px">
|
||
${_defEditId?`<button class="btn b-cancel" onclick="newDefRow()">Cancel Edit</button>`:''}
|
||
<button class="btn b-primary" ${_defSaving?'disabled':''} onclick="saveDefault()">${_defSaving?'<span class="sp"></span> Saving...':(_defEditId?'Update Default':'Add Default')}</button>
|
||
</div>
|
||
</div>
|
||
<div style="border-top:1px solid var(--border);padding-top:10px">
|
||
${_defLoading?`<div class="empty-msg"><span class="sp" style="border-color:rgba(159,90,253,.25);border-top-color:var(--purple)"></span> Loading...</div>`:
|
||
(!_defaults.length?`<div class="empty-msg">No item defaults configured yet.</div>`:
|
||
_defaults.map(d=>`<div class="def-row">
|
||
<span class="code">${esc(d.itemCode)}</span>
|
||
<span class="name">${esc(d.itemName)||'—'}</span>
|
||
<span class="vals">${d.productType==='CAPD'?'':`Pkg <b>${fmtNum(d.noOfPackage)}</b> · `}Cycle Qty <b>${fmtNum(d.perCycleQty)}</b> · Cycle/Day <b>${fmtNum(d.noOfCycleDay)}</b>${d.productType?` · ${esc(d.productType)}`:''}</span>
|
||
<button class="icon-btn" title="Edit" onclick='openDefEdit(${JSON.stringify(d).replace(/'/g,"'")})'>✎</button>
|
||
<button class="icon-btn danger" title="Delete" onclick='deleteDefault(${JSON.stringify(d).replace(/'/g,"'")})'>🗑</button>
|
||
</div>`).join(''))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function renderRows(){
|
||
const start=_page*_pageSize;const pageRows=_plans.slice(start,start+_pageSize);
|
||
if(!pageRows.length)return `<tr><td colspan="13"><div class="empty-msg">No production plans found.</div></td></tr>`;
|
||
return pageRows.map(p=>`<tr>
|
||
<td>${esc(p.planDate)}</td>
|
||
<td>${esc(p.productName)}</td>
|
||
<td>${esc(p.productType)||'—'}</td>
|
||
<td>${esc(p.market)||'—'}</td>
|
||
<td class="num">${p.productType==='CAPD'?'—':fmtNum(p.noOfPackage)}</td>
|
||
<td class="num">${fmtNum(p.quantity)}</td>
|
||
<td class="num calc">${p.productType==='CAPD'?'—':fmtNum(p.prodAsPerSingle)}</td>
|
||
<td class="num">${fmtNum(p.perCycleQty)}</td>
|
||
<td class="num">${fmtNum(p.noOfCycleDay)}</td>
|
||
<td>${CAN_EDIT?`<span class="pill ${p.status==='DONE'?'pill-done':'pill-planned'}" style="cursor:pointer" title="Click to mark ${p.status==='DONE'?'Planned':'Done'}" onclick='toggleStatus(${JSON.stringify(p).replace(/'/g,"'")})'>${p.status==='DONE'?'Done':'Planned'}</span>`:`<span class="pill ${p.status==='DONE'?'pill-done':'pill-planned'}">${p.status==='DONE'?'Done':'Planned'}</span>`}</td>
|
||
<td>${esc(p.batchNo)||'—'}</td>
|
||
<td>${p.approvedBy?`<span class="pill pill-approved" title="${esc(p.approvedByName||p.approvedBy)} · ${p.approvedAt?new Date(p.approvedAt).toLocaleString():''}">Approved</span>`
|
||
:p.rejectedBy?`<span class="pill pill-rejected" title="${esc(p.rejectedByName||p.rejectedBy)} · ${p.rejectedAt?new Date(p.rejectedAt).toLocaleString():''} — ${esc(p.rejectReason)}">Rejected</span>`
|
||
:`<span class="pill pill-planned">Pending</span>`}</td>
|
||
<td><div class="row-actions">
|
||
${CAN_APPROVE&&!p.approvedBy&&!p.rejectedBy?`<button class="icon-btn" title="Approve" onclick='approvePlan(${JSON.stringify(p).replace(/'/g,"'")})'>✓</button>`:''}
|
||
${CAN_APPROVE&&!p.approvedBy&&!p.rejectedBy?`<button class="icon-btn danger" title="Reject" onclick='rejectPlan(${JSON.stringify(p).replace(/'/g,"'")})'>✕</button>`:''}
|
||
${CAN_EDIT&&!p.approvedBy?`<button class="icon-btn" title="Edit" onclick='openEdit(${JSON.stringify(p).replace(/'/g,"'")})'>✎</button>`:''}
|
||
${CAN_DEL&&!p.approvedBy?`<button class="icon-btn danger" title="Delete" onclick='deletePlan(${JSON.stringify(p).replace(/'/g,"'")})'>🗑</button>`:''}
|
||
${p.approvedBy?`<span title="Approved and locked — reject it first to edit" style="font-size:14px;color:var(--text3);padding:0 4px">🔒</span>`:''}
|
||
</div></td>
|
||
</tr>`).join('');
|
||
}
|
||
|
||
function render(){
|
||
const app=document.getElementById('app');
|
||
if(!CAN_VIEW){app.innerHTML=`<div class="card"><div class="card-bd"><div class="empty-msg">You don't have permission to view Production Planning.</div></div></div>`;return;}
|
||
const active=document.activeElement;
|
||
const activeId=active&&active.id;
|
||
const selStart=active&&typeof active.selectionStart==='number'?active.selectionStart:null;
|
||
const totalPages=Math.max(1,Math.ceil(_plans.length/_pageSize));
|
||
app.innerHTML=`
|
||
<div class="card">
|
||
<div class="card-hd">Production Planning <span class="right">${_plans.length} plan${_plans.length===1?'':'s'}</span></div>
|
||
<div class="card-bd">
|
||
<div class="pp-filters">
|
||
<div class="fg"><label class="fl">Company</label><select class="fi" onchange="_co=this.value;loadList()">${coOptions(_co)}</select></div>
|
||
<div class="fg"><label class="fl">Search Product / Batch</label><input type="text" class="fi" id="pp-list-search" value="${esc(_fSearch)}" placeholder="Search..." oninput="onListSearch(this)"/></div>
|
||
<div class="fg"><label class="fl">From</label><input type="date" class="fi" value="${esc(_fFrom)}" onchange="_fFrom=this.value;loadList()"/></div>
|
||
<div class="fg"><label class="fl">To</label><input type="date" class="fi" value="${esc(_fTo)}" onchange="_fTo=this.value;loadList()"/></div>
|
||
<div class="fg"><label class="fl">Product Type</label><select class="fi" onchange="_fType=this.value;loadList()">
|
||
<option value="ALL" ${_fType==='ALL'?'selected':''}>All</option>
|
||
${PRODUCT_TYPES.map(t=>`<option value="${esc(t)}" ${_fType===t?'selected':''}>${esc(t)}</option>`).join('')}
|
||
</select></div>
|
||
<div class="fg"><label class="fl">Market</label><input type="text" class="fi" value="${esc(_fMarket)}" placeholder="Market" onchange="_fMarket=this.value;loadList()"/></div>
|
||
<div class="fg"><label class="fl">Status</label><select class="fi" onchange="_fStatus=this.value;loadList()">
|
||
<option value="ALL" ${_fStatus==='ALL'?'selected':''}>All</option>
|
||
<option value="PLANNED" ${_fStatus==='PLANNED'?'selected':''}>Planned</option>
|
||
<option value="DONE" ${_fStatus==='DONE'?'selected':''}>Done</option>
|
||
</select></div>
|
||
<div class="fg" style="flex:0 0 auto;align-self:flex-end"><button class="btn b-cancel" ${_exporting?'disabled':''} onclick="exportPlans()">${_exporting?'<span class="sp"></span> Exporting...':'Export'}</button></div>
|
||
${CAN_EDIT?`<div class="fg" style="flex:0 0 auto;align-self:flex-end"><button class="btn b-cancel" onclick="openDefaults()">Item Defaults</button></div>`:''}
|
||
${CAN_ADD?`<div class="fg" style="flex:0 0 auto;align-self:flex-end"><button class="btn b-primary" onclick="openNew()">+ New Plan</button></div>`:''}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="card-bd">
|
||
${_listLoading?`<div class="empty-msg"><span class="sp" style="border-color:rgba(159,90,253,.25);border-top-color:var(--purple)"></span> Loading...</div>`:`
|
||
<div class="pp-tblwrap">
|
||
<table class="pp">
|
||
<thead><tr>
|
||
<th>Date</th><th>Product Name</th><th>Product Type</th><th>Market</th><th>No. of Package</th><th>Quantity</th>
|
||
<th>Prod. As per Single</th><th>Per Cycle Qty</th><th>No. of Cycle/Day</th><th>Status</th><th>Batch No.</th><th>Approval</th><th></th>
|
||
</tr></thead>
|
||
<tbody>${renderRows()}</tbody>
|
||
</table>
|
||
</div>
|
||
<div class="pp-pager">
|
||
<span class="pp-count">Page ${_page+1} / ${totalPages}</span>
|
||
<div style="display:flex;gap:8px">
|
||
<button class="btn b-cancel" ${_page<=0?'disabled':''} onclick="_page--;render()">‹ Prev</button>
|
||
<button class="btn b-cancel" ${_page>=totalPages-1?'disabled':''} onclick="_page++;render()">Next ›</button>
|
||
</div>
|
||
</div>
|
||
`}
|
||
</div>
|
||
</div>
|
||
${renderModal()}
|
||
${renderDefaultsModal()}
|
||
`;
|
||
if(activeId){
|
||
const el=document.getElementById(activeId);
|
||
if(el){el.focus();if(selStart!=null&&el.setSelectionRange)try{el.setSelectionRange(selStart,selStart);}catch(_e){}}
|
||
}
|
||
}
|
||
|
||
(async function init(){
|
||
try{if(window.fetchCompanyList){_companies=await window.fetchCompanyList()||_companies;}}catch(_e){}
|
||
render();
|
||
await Promise.all([loadList(),loadDefaults()]);
|
||
})();
|
||
</script>
|
||
<script src="/sidebar.js?v=25"></script>
|
||
</body>
|
||
</html>
|