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

654 lines
42 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<script src="/auth-guard.js?v=4"></script>
<script src="/company-list.js"></script>
<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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
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> &middot; `}Cycle Qty <b>${fmtNum(d.perCycleQty)}</b> &middot; Cycle/Day <b>${fmtNum(d.noOfCycleDay)}</b>${d.productType?` &middot; ${esc(d.productType)}`:''}</span>
<button class="icon-btn" title="Edit" onclick='openDefEdit(${JSON.stringify(d).replace(/'/g,"&#39;")})'>✎</button>
<button class="icon-btn danger" title="Delete" onclick='deleteDefault(${JSON.stringify(d).replace(/'/g,"&#39;")})'>🗑</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,"&#39;")})'>${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,"&#39;")})'>✓</button>`:''}
${CAN_APPROVE&&!p.approvedBy&&!p.rejectedBy?`<button class="icon-btn danger" title="Reject" onclick='rejectPlan(${JSON.stringify(p).replace(/'/g,"&#39;")})'>✕</button>`:''}
${CAN_EDIT&&!p.approvedBy?`<button class="icon-btn" title="Edit" onclick='openEdit(${JSON.stringify(p).replace(/'/g,"&#39;")})'>✎</button>`:''}
${CAN_DEL&&!p.approvedBy?`<button class="icon-btn danger" title="Delete" onclick='deletePlan(${JSON.stringify(p).replace(/'/g,"&#39;")})'>🗑</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=31"></script>
</body>
</html>