Files
sap-erp/public/ppc-report.html
T
John 69b4e68baf
SAP-ERP Portal CI/CD / build (push) Failing after 5m20s
first commit
2026-09-23 17:31:02 +05:30

328 lines
19 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.0, maximum-scale=1.0"/>
<title>PPC Report — 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;--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}
main{padding:12px;max-width:1600px;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;display:flex;align-items:center;gap:8px;border-bottom:1px solid var(--border)}
.card-hd .right{margin-left:auto;font-size:11px;color:var(--text3)}
.card-bd{padding:12px 14px}
.fl{font-size:9.5px;font-weight:700;text-transform:uppercase;letter-spacing:.4px;color:var(--text3);margin-bottom:3px;display:block}
.fi{width:100%;padding:7px 9px;border:1.5px solid var(--border2);border-radius:7px;font-size:12.5px;font-family:var(--font);background:var(--surface2);color:var(--text);outline:none;height:34px}
.fi:focus{border-color:var(--blue)}
select.fi{cursor:pointer}
.filters{display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end}
.filters>div{min-width:130px}
.btn{padding:8px 15px;border-radius:8px;border:none;font-family:var(--font);font-size:12px;font-weight:700;cursor:pointer;height:34px}
.b-primary{background:var(--blue);color:#fff}
.b-primary:disabled{opacity:.5;cursor:not-allowed}
.b-cancel{background:var(--surface2);color:var(--text2);border:1px solid var(--border2)}
.lst{width:100%;border-collapse:separate;border-spacing:0;font-size:12px}
.lst th{text-align:left;padding:8px 10px;font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.3px;color:var(--text3);border-bottom:2px solid var(--border2);white-space:nowrap;background:var(--surface3);position:sticky;top:0}
.lst td{padding:7px 10px;border-bottom:1px solid var(--border);vertical-align:top;white-space:nowrap}
.lst td.num,.lst th.num{text-align:right;font-family:var(--mono)}
.lst tfoot td{padding:8px 10px;font-weight:700;color:var(--text);background:var(--surface3);border-top:2px solid var(--border2)}
.lst tfoot td:first-child{text-align:right}
.lst tfoot tr td{position:sticky;bottom:var(--sticky-bottom,0)}
.lst tfoot tr.stock-1a-row td{color:var(--teal)}
.lst tfoot tr.stock-1b-row td{color:var(--amber)}
.lst tfoot tr.balance-1a-row td,.lst tfoot tr.balance-1b-row td{color:var(--purple)}
.b-green{background:var(--green);color:#fff}
.lst tbody tr:hover td{background:rgba(26,111,255,.05)}
.mono{font-family:var(--mono)}
.pill{font-size:9px;font-weight:800;padding:2px 7px;border-radius:20px;text-transform:uppercase;letter-spacing:.3px;white-space:nowrap}
.p-trade{background:rgba(13,187,138,.14);color:#0aa457}
.p-institute{background:rgba(159,90,253,.16);color:#b98bff}
.p-domestic{background:rgba(6,182,212,.14);color:var(--teal)}
.p-export{background:rgba(245,166,35,.14);color:var(--amber)}
.p-ref{background:rgba(26,111,255,.14);color:#5b9bff;font-family:var(--mono)}
.p-ref-ex{background:rgba(245,166,35,.14);color:#c78a1a;font-family:var(--mono)}
.empty-msg{text-align:center;padding:26px;color:var(--text3);font-size:12px}
.count{font-size:11px;font-weight:700;color:var(--blue);background:rgba(26,111,255,.1);padding:3px 11px;border-radius:20px}
.sp{width:14px;height:14px;border:2px solid rgba(26,111,255,.25);border-top-color:var(--blue);border-radius:50%;animation:spin .6s linear infinite;display:inline-block}
@keyframes spin{to{transform:rotate(360deg)}}
.toast-c{position:fixed;top:14px;right:10px;z-index:9999;display:flex;flex-direction:column;gap:6px}
.toast{padding:9px 13px;border-radius:8px;font-size:12px;font-weight:600;box-shadow:var(--shadow)}
.t-err{background:var(--red);color:#fff}
.tbl-wrap{overflow-x:auto;max-height:70vh;overflow-y:auto;border-radius:8px;border:1px solid var(--border2)}
.item-hd{font-size:9px}
.item-hd .code{display:block;font-family:var(--mono);font-weight:700;color:var(--text)}
.item-hd .name{display:block;font-weight:400;text-transform:none;color:var(--text3);max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
</style>
<link rel="stylesheet" href="/theme.css?v=3"/>
</head>
<body>
<div class="toast-c" id="toast-c"></div>
<main id="app"></main>
<script>
'use strict';
let _tok=null;const BE='/api';
try{_tok=sessionStorage.getItem('portal_token');}catch(_e){}
if(!_tok)location.href='/';
const MON=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
function esc(v){return(v==null?'':String(v)).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
function q(s){return document.querySelector(s);}
function toast(m){const tc=q('#toast-c');const e=document.createElement('div');e.className='toast t-err';e.textContent=m;tc.appendChild(e);setTimeout(()=>e.remove(),4500);}
function fmtD(iso){if(!iso)return '—';const d=new Date(iso);if(isNaN(d))return '—';return `${String(d.getDate()).padStart(2,'0')}-${MON[d.getMonth()]}-${d.getFullYear()}`;}
function today(){return new Date().toISOString().slice(0,10);}
async function api(p){
const r=await fetch(BE+p,{headers:{Authorization:'Bearer '+_tok}});
if(r.status===401){location.href='/';throw new Error('Session expired');}
const d=await r.json();
if(!r.ok||d.success===false)throw new Error(d.message||('HTTP '+r.status));
return d;
}
let _co=window.__DEFAULT_COMPANY__||'';
let _companies=[];
let _itemGroups=[];
let _orderTypes=[]; // admin-managed (System Settings → PPC Report → Order Types) — SAP's U_SalesType is free text, so this isn't a fixed enum
// From/To start empty — leave both blank and "Run Report" shows every open
// (pending) order regardless of date; fill them in to narrow by DocDate.
const _f={from:'',to:'',itemGroup:'',salesType:'',customerType:''};
let _loading=false,_ran=false;
let _itemCodes=[],_rows=[];
function coOptions(sel){return _companies.map(c=>`<option value="${esc(c.value)}" ${sel===c.value?'selected':''}>${esc(c.label)}</option>`).join('');}
function grpOptions(sel){return '<option value="">All</option>'+_itemGroups.map(g=>`<option value="${esc(g.code)}" ${String(sel)===String(g.code)?'selected':''}>${esc(g.name)}</option>`).join('');}
async function loadOrderTypes(){
try{const r=await api('/ppc/order-types');_orderTypes=r.data||[];}
catch(_e){_orderTypes=[];}
}
async function loadItemGroups(){
try{const r=await api('/ppc/item-groups?company='+encodeURIComponent(_co));_itemGroups=r.data||[];}
catch(_e){_itemGroups=[];}
}
async function runReport(){
if(_f.to&&_f.to>today()){toast('"To" date cannot be in the future');return;}
if(_f.from&&_f.to&&_f.from>_f.to){toast('"From" date cannot be after "To" date');return;}
_loading=true;_ran=true;render();
const qs=new URLSearchParams();
qs.set('company',_co);
if(_f.from)qs.set('from',_f.from);
if(_f.to)qs.set('to',_f.to);
if(_f.itemGroup)qs.set('itemGroup',_f.itemGroup);
if(_f.salesType)qs.set('salesType',_f.salesType);
if(_f.customerType)qs.set('customerType',_f.customerType);
try{
const r=await api('/ppc/invoice-report?'+qs.toString());
_itemCodes=(r.data&&r.data.itemCodes)||[];
_rows=(r.data&&r.data.rows)||[];
}catch(e){toast(e.message);_itemCodes=[];_rows=[];}
_loading=false;render();
}
function typePill(t){
if(!t)return '—';
const cls=/trade/i.test(t)?'p-trade':/institute/i.test(t)?'p-institute':'';
return `<span class="pill ${cls}">${esc(t)}</span>`;
}
function custTypePill(t){
// Blank means neither Web SO No field is set AND U_CustomerType isn't
// either — i.e. an order entered directly in SAP, not through the portal/
// website. Still a real, included row (see visibleRows()/server-side
// logic — nothing here ever drops it) — just can't be classified
// Domestic/Export, so it's labeled as such instead of a bare dash.
if(!t)return `<span class="pill" style="background:rgba(122,154,184,.14);color:var(--text2)">Direct (SAP)</span>`;
const cls=t==='DOMESTIC'?'p-domestic':t==='EXPORT'?'p-export':'';
return `<span class="pill ${cls}">${esc(t==='DOMESTIC'?'Domestic':t==='EXPORT'?'Export':t)}</span>`;
}
function refBadge(v,cls){
if(v==null||v==='')return '—';
return `<span class="pill ${cls}">${esc(v)}</span>`;
}
// Live client-side search — no re-fetch, just narrows what's already loaded.
// Customer search filters rows by name; Product search filters which item
// columns show AND drops any row left with nothing in those columns.
const _q={customer:'',product:''};
function visibleItemCodes(){
const p=_q.product.trim().toLowerCase();
if(!p)return _itemCodes;
return _itemCodes.filter(it=>it.code.toLowerCase().includes(p)||it.name.toLowerCase().includes(p));
}
function visibleRows(cols){
const c=_q.customer.trim().toLowerCase();
let rows=_rows;
if(c)rows=rows.filter(r=>(r.cardName||'').toLowerCase().includes(c));
if(_q.product.trim())rows=rows.filter(r=>cols.some(it=>r.qtyByItem[it.code]));
return rows;
}
// Per-item column sums across the given (already search-filtered) rows —
// shared by the on-screen totals row and the Excel export.
function columnTotals(cols,rows){
return cols.map(it=>rows.reduce((sum,r)=>sum+(Number(r.qtyByItem[it.code])||0),0));
}
function renderTable(){
if(_loading)return `<div class="empty-msg"><span class="sp"></span> Loading…</div>`;
if(!_ran)return `<div class="empty-msg">Pick your filters and click "Run Report".</div>`;
if(!_rows.length)return `<div class="empty-msg">No pending orders match these filters.</div>`;
const cols=visibleItemCodes();
const rows=visibleRows(cols);
if(!rows.length)return `<div class="empty-msg">No rows match your search.</div>`;
const itemThs=cols.map(it=>`<th class="num item-hd" title="${esc(it.code)} — ${esc(it.name)}"><span class="code">${esc(it.code)}</span><span class="name">(${esc(it.name)})</span></th>`).join('');
const rowsHtml=rows.map((r,i)=>{
const cells=cols.map(it=>{
const v=r.qtyByItem[it.code];
return `<td class="num">${v?esc(v):''}</td>`;
}).join('');
return `<tr>
<td>${i+1}</td>
<td>${esc(r.cardName)}</td>
<td>${esc(r.city)}</td>
<td>${typePill(r.orderType)}</td>
<td>${custTypePill(r.customerType)}</td>
<td>${fmtD(r.dispatchDate)}</td>
<td>${refBadge(r.salesOrderNo,'p-ref')}</td>
<td>${refBadge(r.webSoNo,'p-ref')}</td>
<td>${refBadge(r.webSoNoEx,'p-ref-ex')}</td>
${cells}
</tr>`;
}).join('');
const totals=columnTotals(cols,rows);
// Show only the stock row matching the active Customer Type filter — both
// when no filter is set (nothing to narrow to).
const showDomestic=_f.customerType!=='EXPORT';
const showExport=_f.customerType!=='DOMESTIC';
// Balance = Current Stock − Total Pending Qty, per item — what's left (or
// short) after covering every still-open order shown. Positive = enough
// stock on hand; negative = short by that much, needs producing.
const balance1A=cols.map((it,i)=>(it.stock1A||0)-totals[i]);
const balance1B=cols.map((it,i)=>(it.stock1B||0)-totals[i]);
// Warehouse-wise breakdown, shown as a native hover tooltip on the Current
// Stock cells — full per-warehouse detail without cluttering the table
// with a row per warehouse (see earlier decision: just 1A/1B as rows).
const whTitles=cols.map(it=>(it.byWarehouse&&it.byWarehouse.length)
?it.byWarehouse.map(w=>`${w.code} (${w.name}): ${w.qty}`).join('\n')
:'No stock in any warehouse');
const footRows=[
{cls:'totals-row', label:'Total Pending Qty', show:true, vals:totals},
{cls:'stock-1a-row', label:'Current Stock (Domestic)', show:showDomestic, vals:cols.map(it=>it.stock1A||0), titles:whTitles},
{cls:'stock-1b-row', label:'Current Stock (Export)', show:showExport, vals:cols.map(it=>it.stock1B||0), titles:whTitles},
{cls:'balance-1a-row', label:'Balance (Domestic)', show:showDomestic, vals:balance1A},
{cls:'balance-1b-row', label:'Balance (Export)', show:showExport, vals:balance1B},
].filter(r=>r.show);
const footHtml=footRows.map((r,i)=>{
const bottom=(footRows.length-1-i)*33;
const isBalance=r.cls.startsWith('balance-');
const cells=r.vals.map((v,j)=>{
const t=r.titles?` title="${esc(r.titles[j])}"`:'';
const style=isBalance&&v<0?' style="color:var(--red)"':'';
return `<td class="num"${style}${t}>${esc(v)}</td>`;
}).join('');
return `<tr class="${r.cls}" style="--sticky-bottom:${bottom}px"><td colspan="9">${esc(r.label)}</td>${cells}</tr>`;
}).join('');
return `<div class="tbl-wrap"><table class="lst">
<thead><tr><th>Sr.No</th><th>Name of Customer</th><th>City</th><th>Order Type</th><th>Customer Type</th><th>Committed Dispatch Date</th><th>Sale Order No</th><th>Web SO No (Domestic)</th><th>Web SO No (Export)</th>${itemThs}</tr></thead>
<tbody>${rowsHtml}</tbody>
<tfoot>${footHtml}</tfoot>
</table></div>`;
}
function exportExcel(){
const cols=visibleItemCodes();
const rows=visibleRows(cols);
if(!rows.length){toast('Nothing to export');return;}
const csvCell=v=>{const s=String(v==null?'':v);return /[",\r\n]/.test(s)?'"'+s.replace(/"/g,'""')+'"':s;};
const header=['Sr.No','Name of Customer','City','Order Type','Customer Type','Committed Dispatch Date','Sale Order No','Web SO No (Domestic)','Web SO No (Export)',...cols.map(it=>`${it.code} (${it.name})`)];
const lines=[header.map(csvCell).join(',')];
rows.forEach((r,i)=>{
lines.push([i+1,r.cardName,r.city,r.orderType,r.customerType||'',fmtD(r.dispatchDate),r.salesOrderNo||'',r.webSoNo||'',r.webSoNoEx||'',...cols.map(it=>r.qtyByItem[it.code]||0)].map(csvCell).join(','));
});
const totals=columnTotals(cols,rows);
lines.push(['','','Total Pending Qty','','','','','','',...totals].map(csvCell).join(','));
if(_f.customerType!=='EXPORT')lines.push(['','','Current Stock (Domestic)','','','','','','',...cols.map(it=>it.stock1A||0)].map(csvCell).join(','));
if(_f.customerType!=='DOMESTIC')lines.push(['','','Current Stock (Export)','','','','','','',...cols.map(it=>it.stock1B||0)].map(csvCell).join(','));
if(_f.customerType!=='EXPORT')lines.push(['','','Balance (Domestic)','','','','','','',...cols.map((it,i)=>(it.stock1A||0)-totals[i])].map(csvCell).join(','));
if(_f.customerType!=='DOMESTIC')lines.push(['','','Balance (Export)','','','','','','',...cols.map((it,i)=>(it.stock1B||0)-totals[i])].map(csvCell).join(','));
const blob=new Blob([''+lines.join('\r\n')],{type:'text/csv;charset=utf-8'});
const a=document.createElement('a');
a.href=URL.createObjectURL(blob);
a.download=`PPC_Report_${_f.from||'all'}_to_${_f.to||'all'}.csv`;
a.click();
URL.revokeObjectURL(a.href);
}
function resultCountText(){
const cols=visibleItemCodes(),rows=visibleRows(cols);
return `${rows.length.toLocaleString()} order${rows.length===1?'':'s'} · ${cols.length} item${cols.length===1?'':'s'}`;
}
// Search inputs only ever patch #results + the count badge directly — never
// a full render() — so typing never rebuilds (and loses focus on) the very
// input the user is typing into. Same principle used throughout this app's
// other live-search fields.
function refreshResults(){
const res=q('#results');if(res)res.innerHTML=renderTable();
const cnt=q('#result-count');if(cnt)cnt.textContent=_ran&&!_loading?resultCountText():'';
}
function render(){
const app=q('#app');
app.innerHTML=`
<div class="card"><div class="card-hd"><span>📋 PPC Report</span><span class="right" id="result-count">${_ran&&!_loading?resultCountText():''}</span></div>
<div class="card-bd">
<div class="filters">
${_companies.length>1?`<div><label class="fl">Company</label><select class="fi" id="f-co">${coOptions(_co)}</select></div>`:''}
<div><label class="fl">From</label><input class="fi" type="date" id="f-from" value="${_f.from}" max="${_f.to||today()}" title="Leave blank (with To) to show every open order regardless of date"/></div>
<div><label class="fl">To</label><input class="fi" type="date" id="f-to" value="${_f.to}" min="${_f.from||''}" max="${today()}" title="Leave blank (with From) to show every open order regardless of date"/></div>
<div><label class="fl">Item Group</label><select class="fi" id="f-grp">${grpOptions(_f.itemGroup)}</select></div>
<div><label class="fl">Order Type</label><select class="fi" id="f-order">
<option value="">All</option>
${_orderTypes.map(t=>`<option value="${esc(t)}" ${_f.salesType===t?'selected':''}>${esc(t)}</option>`).join('')}
</select></div>
<div><label class="fl">Customer Type</label><select class="fi" id="f-cust">
<option value="">All</option>
<option value="DOMESTIC" ${_f.customerType==='DOMESTIC'?'selected':''}>Domestic</option>
<option value="EXPORT" ${_f.customerType==='EXPORT'?'selected':''}>Export</option>
</select></div>
<button class="btn b-primary" id="f-go" ${_loading?'disabled':''}>${_loading?'<span class="sp"></span> Running…':'Run Report'}</button>
</div>
<div class="filters" style="margin-top:8px">
<div style="min-width:220px;flex:1"><label class="fl">Search Customer</label><input class="fi" id="q-customer" placeholder="Customer name…" value="${esc(_q.customer)}"/></div>
<div style="min-width:220px;flex:1"><label class="fl">Search Product</label><input class="fi" id="q-product" placeholder="Item code or name…" value="${esc(_q.product)}"/></div>
<button class="btn b-green" id="f-export" ${_ran&&_rows.length?'':'disabled'}>⬇ Download Excel</button>
</div>
<div style="margin-top:12px" id="results">${renderTable()}</div>
</div></div>`;
wire();
}
function wire(){
if(q('#f-co'))q('#f-co').onchange=async function(){_co=this.value;await loadItemGroups();render();};
q('#f-from').onchange=function(){_f.from=this.value;render();};
q('#f-to').onchange=function(){_f.to=this.value;render();};
q('#f-grp').onchange=function(){_f.itemGroup=this.value;};
q('#f-order').onchange=function(){_f.salesType=this.value;};
q('#f-cust').onchange=function(){_f.customerType=this.value;};
q('#f-go').onclick=runReport;
q('#q-customer').oninput=function(){_q.customer=this.value;refreshResults();};
q('#q-product').oninput=function(){_q.product=this.value;refreshResults();};
if(q('#f-export'))q('#f-export').onclick=exportExcel;
}
(async function init(){
try{const list=await window.fetchCompanyList();if(list.length){_companies=list;if(!_co)_co=list[0].value;}}catch(_e){}
await Promise.all([loadItemGroups(),loadOrderTypes()]);
render();
})();
</script>
<script src="/sidebar.js?v=25"></script>
</body>
</html>