282 lines
15 KiB
HTML
282 lines
15 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>PWO Source 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;--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: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;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}
|
|
.note{font-size:11px;color:var(--text3);line-height:1.6;margin-bottom:10px;padding:8px 10px;background:var(--surface3);border-radius:8px;border:1px solid var(--border2)}
|
|
.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-green{background:var(--green);color:#04231a}
|
|
.b-green:disabled{opacity:.5;cursor:not-allowed}
|
|
.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 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-portal{background:rgba(13,187,138,.14);color:#0aa457}
|
|
.p-direct{background:rgba(229,62,62,.14);color:#e06666}
|
|
.p-mixed{background:rgba(245,166,35,.14);color:#c78a1a}
|
|
.p-status{background:var(--surface3);color:var(--text2);border:1px solid var(--border2)}
|
|
.empty-msg{text-align:center;padding:26px;color:var(--text3);font-size:12px}
|
|
.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)}
|
|
</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='/';
|
|
|
|
function esc(v){return(v==null?'':String(v)).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
|
|
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 today(){return new Date().toISOString().slice(0,10);}
|
|
function monthAgo(){const d=new Date();d.setMonth(d.getMonth()-1);return d.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=[];
|
|
const _f={from:monthAgo(),to:today()};
|
|
let _loading=false,_ran=false,_rows=[];
|
|
|
|
function coOptions(sel){return _companies.map(c=>`<option value="${esc(c.value)}" ${sel===c.value?'selected':''}>${esc(c.label)}</option>`).join('');}
|
|
|
|
async function runReport(){
|
|
if(!_f.from||!_f.to){toast('Pick a From and To date');return;}
|
|
if(_f.to>today()){toast('"To" date cannot be in the future');return;}
|
|
if(_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);qs.set('from',_f.from);qs.set('to',_f.to);
|
|
try{
|
|
const r=await api('/pwo-source-audit/report?'+qs.toString());
|
|
_rows=r.data||[];
|
|
}catch(e){toast(e.message);_rows=[];}
|
|
_loading=false;render();
|
|
}
|
|
|
|
function pill(v){
|
|
if(v==null)return '<span class="pill p-status">—</span>';
|
|
const cls=v==='Portal'?'p-portal':v==='Mixed'?'p-mixed':'p-direct';
|
|
return `<span class="pill ${cls}">${esc(v)}</span>`;
|
|
}
|
|
function statusPill(s){return `<span class="pill p-status">${esc(s||'—')}</span>`;}
|
|
function stageOptions(sel){
|
|
const opts=[['','All'],['Portal','Portal'],['Direct (SAP)','Direct (SAP)'],['Mixed','Mixed'],[NONE,'Not reached yet']];
|
|
return opts.map(([v,l])=>`<option value="${esc(v)}" ${sel===v?'selected':''}>${esc(l)}</option>`).join('');
|
|
}
|
|
|
|
// Live client-side filters — no re-fetch, just narrow what's already loaded.
|
|
// "__none__" stands for the stage not being reached yet (field is null) —
|
|
// a real select value is needed since HTML options can't carry a literal null.
|
|
const NONE='__none__';
|
|
const _q={text:'',created:'',released:'',issued:'',received:''};
|
|
function stageMatch(val,filter){ return filter===''?true:filter===NONE?val==null:val===filter; }
|
|
function visibleRows(){
|
|
const t=_q.text.trim().toLowerCase();
|
|
let rows=_rows;
|
|
if(t)rows=rows.filter(r=>
|
|
String(r.absoluteEntry).includes(t)||
|
|
String(r.docNum).includes(t)||
|
|
(r.itemCode||'').toLowerCase().includes(t)||
|
|
(r.itemName||'').toLowerCase().includes(t)
|
|
);
|
|
rows=rows.filter(r=>
|
|
stageMatch(r.creationSource,_q.created)&&
|
|
stageMatch(r.releaseSource,_q.released)&&
|
|
stageMatch(r.issueSource,_q.issued)&&
|
|
stageMatch(r.receiptSource,_q.received)
|
|
);
|
|
return rows;
|
|
}
|
|
function refreshResults(){
|
|
const res=q('#results');if(res)res.innerHTML=renderTable();
|
|
const cnt=q('#result-count');if(cnt)cnt.textContent=_ran&&!_loading?`${visibleRows().length.toLocaleString()} order${visibleRows().length===1?'':'s'}`:'';
|
|
const exp=q('#f-export');if(exp)exp.disabled=!(_ran&&visibleRows().length);
|
|
}
|
|
|
|
function renderTable(){
|
|
if(_loading)return `<div class="empty-msg"><span class="sp"></span> Loading…</div>`;
|
|
if(!_ran)return `<div class="empty-msg">Pick a date range and click "Run Report".</div>`;
|
|
if(!_rows.length)return `<div class="empty-msg">No Production Orders in this date range.</div>`;
|
|
const rows=visibleRows();
|
|
if(!rows.length)return `<div class="empty-msg">No rows match your search.</div>`;
|
|
const rowsHtml=rows.map(r=>`<tr>
|
|
<td class="mono">${esc(r.absoluteEntry)}</td>
|
|
<td class="mono">${esc(r.docNum)}</td>
|
|
<td class="mono">${esc(r.itemCode)}</td>
|
|
<td>${esc(r.itemName)}</td>
|
|
<td>${statusPill(r.status)}</td>
|
|
<td>${pill(r.creationSource)}</td>
|
|
<td>${pill(r.releaseSource)}</td>
|
|
<td>${pill(r.issueSource)}</td>
|
|
<td>${pill(r.receiptSource)}</td>
|
|
</tr>`).join('');
|
|
return `<div class="tbl-wrap"><table class="lst">
|
|
<thead><tr><th>AbsEntry</th><th>PWO No.</th><th>Item Code</th><th>Item Name</th><th>Status</th><th>Created</th><th>Released</th><th>Issued</th><th>Received</th></tr></thead>
|
|
<tbody>${rowsHtml}</tbody>
|
|
</table></div>`;
|
|
}
|
|
|
|
function render(){
|
|
const app=q('#app');
|
|
app.innerHTML=`
|
|
<div class="card"><div class="card-hd"><span>🧾 PWO Source Report</span><span class="right" id="result-count">${_ran&&!_loading?`${visibleRows().length.toLocaleString()} order${visibleRows().length===1?'':'s'}`:''}</span></div>
|
|
<div class="card-bd">
|
|
<div class="note">Shows, per Production Order, whether each of Create / Release / Issue / Receipt was done through this portal or directly in SAP B1. Only reliable going forward from when each stage's tracking was added — older activity has no marker either way and shows as "Direct (SAP)" even if it went through the portal. "Mixed" means a stage was split across multiple documents (e.g. a partial issue) with some tagged and some not.</div>
|
|
<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()}"/></div>
|
|
<div><label class="fl">To</label><input class="fi" type="date" id="f-to" value="${_f.to}" min="${_f.from||''}" max="${today()}"/></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:240px;flex:1"><label class="fl">Search</label><input class="fi" id="q-text" placeholder="Item code, item name, PWO No. or AbsEntry…" value="${esc(_q.text)}"/></div>
|
|
<div><label class="fl">Created</label><select class="fi" id="q-created">${stageOptions(_q.created)}</select></div>
|
|
<div><label class="fl">Released</label><select class="fi" id="q-released">${stageOptions(_q.released)}</select></div>
|
|
<div><label class="fl">Issued</label><select class="fi" id="q-issued">${stageOptions(_q.issued)}</select></div>
|
|
<div><label class="fl">Received</label><select class="fi" id="q-received">${stageOptions(_q.received)}</select></div>
|
|
<div><button class="btn b-green" id="f-export" ${_ran&&visibleRows().length?'':'disabled'}>⬇ Export Excel</button></div>
|
|
</div>
|
|
<div style="margin-top:12px" id="results">${renderTable()}</div>
|
|
</div></div>`;
|
|
wire();
|
|
}
|
|
|
|
function wire(){
|
|
if(q('#f-co'))q('#f-co').onchange=function(){_co=this.value;};
|
|
q('#f-from').onchange=function(){_f.from=this.value;render();};
|
|
q('#f-to').onchange=function(){_f.to=this.value;render();};
|
|
q('#f-go').onclick=runReport;
|
|
q('#q-text').oninput=function(){_q.text=this.value;refreshResults();};
|
|
q('#q-created').onchange=function(){_q.created=this.value;refreshResults();};
|
|
q('#q-released').onchange=function(){_q.released=this.value;refreshResults();};
|
|
q('#q-issued').onchange=function(){_q.issued=this.value;refreshResults();};
|
|
q('#q-received').onchange=function(){_q.received=this.value;refreshResults();};
|
|
if(q('#f-export'))q('#f-export').onclick=exportExcel;
|
|
}
|
|
|
|
// Styled .xlsx export via ExcelJS — exports exactly what's on screen (the
|
|
// current date range AND the live search/stage filters via visibleRows()),
|
|
// not the whole unfiltered fetch.
|
|
const XLSX_BLUE='FF1A6FFF', XLSX_INK='FF1E2A44', XLSX_WHITE='FFFFFFFF', XLSX_ZEBRA='FFEFF4FF', XLSX_BORDER='FFB9CCEA';
|
|
function xlsxBorder(color){ const b={style:'thin',color:{argb:color||XLSX_BORDER}}; return {top:b,left:b,bottom:b,right:b}; }
|
|
async function exportExcel(){
|
|
const rows=visibleRows();
|
|
if(!rows.length){toast('Nothing to export');return;}
|
|
if(typeof ExcelJS==='undefined'){toast('Export library failed to load — check your connection and retry');return;}
|
|
const wb=new ExcelJS.Workbook();
|
|
wb.creator='SAP ERP Portal';wb.created=new Date();
|
|
const ws=wb.addWorksheet('PWO Source Report',{views:[{state:'frozen',ySplit:6}]});
|
|
|
|
ws.mergeCells('A1:I1');
|
|
const title=ws.getCell('A1');
|
|
title.value='PWO Source Report — Export';
|
|
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 metaRows=[
|
|
['Company:',_co,'From:',_f.from],
|
|
['To:',_f.to,'Rows:',rows.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:'FF64748B'}};
|
|
row.getCell(2).value=r[1];row.getCell(2).font={color:{argb:'FF0F172A'}};
|
|
row.getCell(3).value=r[2];row.getCell(3).font={bold:true,color:{argb:'FF64748B'}};
|
|
row.getCell(4).value=r[3];row.getCell(4).font={color:{argb:'FF0F172A'}};
|
|
});
|
|
|
|
const hdrRowNum=6;
|
|
const cols=['AbsEntry','PWO No.','Item Code','Item Name','Status','Created','Released','Issued','Received'];
|
|
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'};
|
|
cell.border=xlsxBorder('FF0F172A');
|
|
});
|
|
hdrRow.height=20;
|
|
|
|
const srcFill=v=>v==null?'':v;
|
|
rows.forEach((r,i)=>{
|
|
const row=ws.getRow(hdrRowNum+1+i);
|
|
const vals=[r.absoluteEntry,r.docNum,r.itemCode,r.itemName,r.status,srcFill(r.creationSource),srcFill(r.releaseSource),srcFill(r.issueSource),srcFill(r.receiptSource)];
|
|
vals.forEach((v,ci)=>{
|
|
const cell=row.getCell(ci+1);
|
|
cell.value=v;
|
|
cell.border=xlsxBorder();
|
|
cell.fill={type:'pattern',pattern:'solid',fgColor:{argb:i%2?XLSX_ZEBRA:XLSX_WHITE}};
|
|
if(ci===0)cell.alignment={horizontal:'right'};
|
|
if(ci>=5){
|
|
// Portal = green, Direct (SAP) = red, Mixed = amber, not reached = grey
|
|
cell.alignment={horizontal:'center'};
|
|
const src=[r.creationSource,r.releaseSource,r.issueSource,r.receiptSource][ci-5];
|
|
cell.font={bold:true,color:{argb:src==='Portal'?'FF0AA457':src==='Mixed'?'FFC78A1A':src==null?'FF94A3B8':'FFE06666'}};
|
|
}
|
|
});
|
|
});
|
|
|
|
ws.columns=[{width:11},{width:14},{width:16},{width:32},{width:14},{width:16},{width:16},{width:16},{width:16}];
|
|
|
|
const buf=await wb.xlsx.writeBuffer();
|
|
const blob=new Blob([buf],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});
|
|
const a=document.createElement('a');
|
|
a.href=URL.createObjectURL(blob);
|
|
a.download=`PWO_Source_Report_${_f.from||'all'}_to_${_f.to||'all'}.xlsx`;
|
|
a.click();
|
|
URL.revokeObjectURL(a.href);
|
|
}
|
|
|
|
(async function init(){
|
|
try{const list=await window.fetchCompanyList();if(list.length){_companies=list;if(!_co)_co=list[0].value;}}catch(_e){}
|
|
render();
|
|
})();
|
|
</script>
|
|
<script src="/sidebar.js?v=25"></script>
|
|
</body>
|
|
</html>
|