442 lines
31 KiB
HTML
442 lines
31 KiB
HTML
<!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>SAP Approvals — 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;--blue:#1a6fff;--amber:#f5a623;--green:#0dbb8a;--red:#e53e3e;--teal:#06b6d4;--purple:#9f5afd;--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}
|
|
main{padding:12px;max-width:800px;margin:0 auto}
|
|
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);margin-bottom:12px}
|
|
.card-hd{background:var(--surface3);padding:10px 14px;font-size:12px;font-weight:700;color:var(--text);display:flex;align-items:center;gap:8px;border-bottom:1px solid var(--border);border-radius:var(--radius) var(--radius) 0 0}
|
|
.card-hd .right{margin-left:auto;font-size:11px;color:var(--text3);font-weight:400}
|
|
.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)}
|
|
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}
|
|
select.fi option{background:var(--surface2)}
|
|
.fi-row{display:grid;grid-template-columns:1fr 1fr;gap:8px}
|
|
@media(max-width:500px){.fi-row{grid-template-columns:1fr}}
|
|
.status-tabs{display:flex;gap:4px;flex-wrap:wrap;margin-bottom:10px}
|
|
.st-tab{padding:6px 14px;border-radius:8px;font-size:11px;font-weight:700;color:var(--text3);cursor:pointer;border:1px solid var(--border2);background:none;font-family:var(--font);transition:all .12s}
|
|
.st-tab:hover{color:var(--text2)}.st-tab.on{color:var(--blue);border-color:var(--blue);background:rgba(26,111,255,.06)}
|
|
.ar-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:12px}
|
|
.ar-card{background:var(--surface2);border:1px solid var(--border);border-radius:12px;padding:12px 14px;cursor:pointer;transition:all .15s;aspect-ratio:1/1;display:flex;flex-direction:column;overflow:hidden}
|
|
.ar-card:hover{border-color:var(--blue);box-shadow:0 6px 18px rgba(26,111,255,.14);transform:translateY(-2px)}
|
|
.ar-card .ar-desc{margin-top:6px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
|
|
.ar-card .ar-meta{margin-top:auto;padding-top:8px;border-top:1px solid var(--border);flex-direction:column;gap:3px}
|
|
.ar-top{display:flex;align-items:center;gap:6px;margin-bottom:4px;flex-wrap:wrap}
|
|
.ar-code{font-family:var(--mono);font-size:12px;color:var(--blue);font-weight:600}
|
|
.ar-badge{font-size:9px;font-weight:700;padding:2px 7px;border-radius:4px}
|
|
.ar-badge.pending{background:rgba(245,166,35,.1);color:var(--amber);border:1px solid rgba(245,166,35,.25)}
|
|
.ar-badge.approved{background:rgba(13,187,138,.1);color:var(--green);border:1px solid rgba(13,187,138,.25)}
|
|
.ar-badge.rejected{background:rgba(229,62,62,.08);color:var(--red);border:1px solid rgba(229,62,62,.2)}
|
|
.ar-badge.type{background:rgba(159,90,253,.08);color:var(--purple);border:1px solid rgba(159,90,253,.2)}
|
|
.ar-badge.draft{background:rgba(26,111,255,.08);color:var(--blue);border:1px solid rgba(26,111,255,.2)}
|
|
.ar-desc{font-size:12px;font-weight:600;color:var(--text)}
|
|
.ar-meta{font-size:10px;color:var(--text3);display:flex;gap:8px;flex-wrap:wrap;margin-top:2px}
|
|
.ar-meta b{color:var(--text2)}
|
|
.btn-bar{display:flex;gap:8px;flex-wrap:wrap}
|
|
.btn{padding:8px 16px;border-radius:8px;border:none;font-family:var(--font);font-size:12px;font-weight:700;cursor:pointer;transition:all .15s;display:inline-flex;align-items:center;gap:5px}.btn:disabled{opacity:.4;cursor:not-allowed}
|
|
.b-approve{background:var(--green);color:#fff;flex:1}.b-approve:hover:not(:disabled){background:#09a077}
|
|
.b-reject{background:rgba(229,62,62,.08);color:var(--red);border:1px solid rgba(229,62,62,.25);flex:1}.b-reject:hover:not(:disabled){background:rgba(229,62,62,.15)}
|
|
.b-cancel{background:var(--surface2);color:var(--text2);border:1px solid var(--border2)}
|
|
.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);animation:sIn .22s ease;max-width:280px;pointer-events:auto}
|
|
.t-ok{background:var(--green);color:#fff}.t-err{background:var(--red);color:#fff}
|
|
@keyframes sIn{from{transform:translateX(110%);opacity:0}to{transform:translateX(0);opacity:1}}
|
|
.modal-bg{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:500;display:none;align-items:flex-start;justify-content:center;padding:12px;overflow-y:auto}
|
|
.modal-bg.show{display:flex}
|
|
.modal{background:var(--ink2);border:1px solid var(--border2);border-radius:var(--radius);width:100%;max-width:650px;margin:auto;box-shadow:0 24px 80px rgba(0,0,0,.5);animation:mIn .2s ease}
|
|
@keyframes mIn{from{transform:translateY(20px);opacity:0}to{transform:translateY(0);opacity:1}}
|
|
.modal-hd{padding:12px 14px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between}
|
|
.modal-close{width:28px;height:28px;border-radius:6px;border:1px solid var(--border);background:var(--surface);color:var(--text2);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:14px}.modal-close:hover{background:var(--red);color:#fff}
|
|
.modal-bd{padding:14px;max-height:75vh;overflow-y:auto}
|
|
.dt-row{display:flex;justify-content:space-between;padding:6px 0;border-bottom:1px solid rgba(28,46,72,.3);font-size:12px}
|
|
.dt-row .dt-l{color:var(--text3);font-weight:600;font-size:10px;text-transform:uppercase;min-width:35%}
|
|
.dt-row .dt-v{color:var(--text);text-align:right;font-family:var(--mono);font-size:11px}
|
|
.stage-card{background:var(--ink3);border:1px solid var(--border);border-radius:6px;padding:8px 10px;margin-bottom:4px}
|
|
.stage-top{display:flex;align-items:center;gap:6px;font-size:11px;flex-wrap:wrap}
|
|
.comment-area{width:100%;padding:8px 10px;border:1.5px solid var(--border2);border-radius:7px;font-size:12px;font-family:var(--font);background:var(--surface2);color:var(--text);outline:none;resize:vertical;min-height:60px}
|
|
</style>
|
|
<link rel="stylesheet" href="/theme.css?v=3"/>
|
|
</head>
|
|
<body>
|
|
<div class="toast-c" id="toast-c"></div>
|
|
<div class="modal-bg" id="ar-modal"><div class="modal" id="modal-inner"></div></div>
|
|
<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='/';
|
|
|
|
let _co=window.__DEFAULT_COMPANY__;
|
|
let _companies=[{value:_co,label:_co}]; // live list — populated at bootstrap
|
|
function coOptions(sel){return _companies.map(c=>`<option value="${esc(c.value)}" ${sel===c.value?'selected':''}>${esc(c.label)}</option>`).join('');}
|
|
let _status='Pending';
|
|
let _objectType='';
|
|
let _dateFrom='';
|
|
let _dateTo='';
|
|
let _searchCode='';
|
|
let _originatorId='';
|
|
let _requests=[];
|
|
let _loading=false;
|
|
let _skip=0;
|
|
// Matches SAP Service Layer's own MaxPageSize (confirmed live: it silently
|
|
// caps a page at 20 rows regardless of a higher $top, same as Purchase
|
|
// Request's search list) — asking for 30 always got 20 back, and
|
|
// "_requests.length>=30" then NEVER held, so Next never even appeared.
|
|
const PAGE_SIZE=20;
|
|
let _showFilters=false;
|
|
let _myApprovalTypes=[]; // Admin → Users → "SAP Approval Types" for the CURRENT user — [] = no restriction (every type)
|
|
|
|
const OBJ_MAP={'112':'Draft','13':'AR Invoice','14':'AR Credit Memo','18':'AP Invoice','19':'AP Credit Note','22':'Purchase Order','20':'Goods Receipt PO','21':'Goods Return','59':'Goods Issue','60':'Goods Receipt','46':'Blanket Agreement','17':'Sales Order','15':'Delivery','16':'Return','1470000113':'Purchase Request','202':'Production Order','67':'Inventory Transfer Request'};
|
|
const OBJ_ENDPOINT={'13':'Invoices','14':'CreditNotes','18':'PurchaseInvoices','19':'PurchaseCreditNotes','22':'PurchaseOrders','20':'PurchaseDeliveryNotes','21':'PurchaseReturns','17':'Orders','15':'DeliveryNotes','16':'Returns','1470000113':'PurchaseRequests','202':'ProductionOrders','59':'InventoryGenExits','60':'InventoryGenEntries','67':'InventoryTransferRequests','46':'BlanketAgreements'};
|
|
const STATUS_MAP={'arsPending':'Pending','arsApproved':'Approved','arsNotApproved':'Rejected','arsGenerated':'Generated','arsGeneratedByAuthorizer':'Generated by Auth','arsRejected':'Rejected'};
|
|
const LINE_STATUS_MAP={'ardPending':'Pending','ardApproved':'Approved','ardNotApproved':'Rejected','ardGenerated':'Generated','ardGeneratedByAuthorizer':'Generated by Auth','ardRejected':'Rejected'};
|
|
|
|
function esc(v){return(v||'').toString().replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
|
|
function q(s){return document.querySelector(s);}
|
|
function mk(c){const d=document.createElement('div');d.className=c;return d;}
|
|
function fmtDate(d){if(!d)return'-';return new Date(d).toLocaleDateString('en-IN',{day:'2-digit',month:'short',year:'2-digit'});}
|
|
|
|
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(),4000);}
|
|
|
|
async function loadRequests(){
|
|
_loading=true;render();
|
|
try{
|
|
let qs=`company=${encodeURIComponent(_co)}&top=${PAGE_SIZE}&skip=${_skip}`;
|
|
if(_status)qs+=`&status=${_status}`;
|
|
if(_objectType)qs+=`&objectType=${encodeURIComponent(_objectType)}`;
|
|
if(_dateFrom)qs+=`&dateFrom=${_dateFrom}`;
|
|
if(_dateTo)qs+=`&dateTo=${_dateTo}`;
|
|
if(_searchCode)qs+=`&code=${_searchCode}`;
|
|
if(_originatorId)qs+=`&originatorId=${_originatorId}`;
|
|
const r=await api('GET',`/sap/approval-requests?${qs}`);
|
|
_requests=r.data||[];
|
|
}catch(e){toast(e.message,'err');_requests=[];}
|
|
_loading=false;render();
|
|
}
|
|
|
|
function render(){
|
|
const app=document.getElementById('app');app.innerHTML='';
|
|
|
|
const c1=mk('card');
|
|
// Restricted to whatever Admin → Users → "SAP Approval Types" allows this
|
|
// user (see routes/sap.js's GET /approval-requests, which enforces the
|
|
// same restriction server-side regardless of what this dropdown offers) —
|
|
// [] = no restriction, every type shown, today's behavior.
|
|
const ALL_DOC_TYPE_OPTS=[{v:'1470000113',l:'Purchase Request'},{v:'22',l:'Purchase Order'},{v:'20',l:'GRPO'},{v:'18',l:'AP Invoice'},{v:'19',l:'AP Credit Note'},{v:'21',l:'Goods Return'},{v:'13',l:'AR Invoice'},{v:'14',l:'AR Credit Memo'},{v:'16',l:'Return'},{v:'17',l:'Sales Order'},{v:'15',l:'Delivery'},{v:'202',l:'Production Order'},{v:'59',l:'Goods Issue'},{v:'60',l:'Goods Receipt'}];
|
|
const docTypeOpts=[{v:'',l:'All Types'},...(_myApprovalTypes.length?ALL_DOC_TYPE_OPTS.filter(o=>_myApprovalTypes.includes(o.v)):ALL_DOC_TYPE_OPTS)];
|
|
c1.innerHTML=`<div class="card-hd">SAP Approval Requests<span class="right"><button class="btn b-cancel" id="btn-toggle-filter" style="padding:4px 10px;font-size:10px">${_showFilters?'Hide Filters':'Filters'}</button></span></div>
|
|
<div class="card-bd">
|
|
<div class="fg"><label class="fl">Company</label><select class="fi" id="sel-co">
|
|
${coOptions(_co)}
|
|
</select></div>
|
|
<div class="fg"><label class="fl">Status</label>
|
|
<div class="status-tabs">
|
|
${['Pending','Approved','Rejected','Generated','Cancelled',''].map(s=>`<button class="st-tab${_status===s?' on':''}" data-st="${s}">${s||'All'}</button>`).join('')}
|
|
</div>
|
|
</div>
|
|
<div id="adv-filters" style="display:${_showFilters?'block':'none'}">
|
|
<div class="fi-row">
|
|
<div class="fg"><label class="fl">Document Type</label><select class="fi" id="f-objtype">
|
|
${docTypeOpts.map(o=>`<option value="${o.v}" ${_objectType===o.v?'selected':''}>${o.l}</option>`).join('')}
|
|
</select></div>
|
|
<div class="fg"><label class="fl">Request Code</label><input class="fi fi-mono" id="f-code" value="${esc(_searchCode)}" placeholder="e.g. 59557"/></div>
|
|
</div>
|
|
<div class="fi-row">
|
|
<div class="fg"><label class="fl">Date From</label><input class="fi" id="f-datefrom" type="date" value="${_dateFrom}"/></div>
|
|
<div class="fg"><label class="fl">Date To</label><input class="fi" id="f-dateto" type="date" value="${_dateTo}"/></div>
|
|
</div>
|
|
<div class="fi-row">
|
|
<div class="fg"><label class="fl">Originator ID</label><input class="fi fi-mono" id="f-originator" value="${esc(_originatorId)}" placeholder="SAP User ID"/></div>
|
|
<div class="fg" style="display:flex;align-items:flex-end;gap:6px;padding-bottom:10px">
|
|
<button class="btn b-primary" id="btn-apply" style="flex:1;height:36px;font-size:12px">Apply Filters</button>
|
|
<button class="btn b-cancel" id="btn-clear-filters" style="height:36px;font-size:11px">Clear</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
app.appendChild(c1);
|
|
|
|
const c2=mk('card');
|
|
let listHtml='';
|
|
if(_loading){
|
|
listHtml='<div class="empty-msg"><span class="sp" style="border-color:rgba(26,111,255,.25);border-top-color:var(--blue)"></span> Loading...</div>';
|
|
}else if(!_requests.length){
|
|
listHtml='<div class="empty-msg">No approval requests found</div>';
|
|
}else{
|
|
listHtml=`<div class="ar-grid">${_requests.map(r=>{
|
|
const st=STATUS_MAP[r.Status]||r.Status;
|
|
const stCls=st==='Pending'?'pending':st==='Approved'?'approved':'rejected';
|
|
const objType=OBJ_MAP[String(r.ObjectType)]||'Doc '+r.ObjectType;
|
|
return`<div class="ar-card" data-ar="${r.Code}">
|
|
<div class="ar-top">
|
|
<span class="ar-code">#${r.Code}</span>
|
|
<span class="ar-badge ${stCls}">${st}</span>
|
|
${r.IsDraft==='Y'?'<span class="ar-badge draft">Draft</span>':''}
|
|
</div>
|
|
<span class="ar-badge type" style="align-self:flex-start;margin-top:6px">${esc(objType)}</span>
|
|
<div class="ar-desc">Entry: ${r.ObjectEntry} · Stage: ${r.CurrentStage||'-'}</div>
|
|
<div class="ar-meta">
|
|
<span>Created <b>${fmtDate(r.CreationDate)}</b></span>
|
|
${r.Remarks?`<span title="${esc(r.Remarks)}">Note: <b>${esc(r.Remarks.substring(0,30))}</b></span>`:''}
|
|
</div>
|
|
</div>`;
|
|
}).join('')}</div>`;
|
|
}
|
|
const pending=_requests.filter(r=>r.Status==='arsPending').length;
|
|
c2.innerHTML=`<div class="card-hd">Requests<span class="right">${_requests.length} found${pending?' | '+pending+' pending':''}</span></div>
|
|
<div class="card-bd">${listHtml}
|
|
${!_loading&&_requests.length>=PAGE_SIZE?`<div style="display:flex;gap:6px;margin-top:8px;justify-content:center">
|
|
${_skip>0?'<button class="btn b-cancel" id="btn-prev" style="font-size:11px">Prev</button>':''}
|
|
<button class="btn b-cancel" id="btn-next" style="font-size:11px">Next</button>
|
|
</div>`:''}
|
|
</div>`;
|
|
app.appendChild(c2);
|
|
|
|
setTimeout(()=>{
|
|
q('#sel-co')&&(q('#sel-co').onchange=function(){_co=this.value;_skip=0;loadRequests();});
|
|
document.querySelectorAll('[data-st]').forEach(b=>{b.onclick=function(){_status=this.dataset.st;_skip=0;loadRequests();};});
|
|
q('#btn-toggle-filter')&&(q('#btn-toggle-filter').onclick=function(){_showFilters=!_showFilters;render();});
|
|
q('#btn-apply')&&(q('#btn-apply').onclick=function(){
|
|
_objectType=q('#f-objtype')?.value||'';
|
|
_searchCode=q('#f-code')?.value?.trim()||'';
|
|
_dateFrom=q('#f-datefrom')?.value||'';
|
|
_dateTo=q('#f-dateto')?.value||'';
|
|
_originatorId=q('#f-originator')?.value?.trim()||'';
|
|
_skip=0;loadRequests();
|
|
});
|
|
q('#btn-clear-filters')&&(q('#btn-clear-filters').onclick=function(){
|
|
_objectType='';_searchCode='';_dateFrom='';_dateTo='';_originatorId='';_skip=0;loadRequests();
|
|
});
|
|
document.querySelectorAll('[data-ar]').forEach(c=>{c.onclick=function(){openDetail(parseInt(this.dataset.ar));};});
|
|
q('#btn-prev')&&(q('#btn-prev').onclick=function(){_skip=Math.max(0,_skip-PAGE_SIZE);loadRequests();});
|
|
q('#btn-next')&&(q('#btn-next').onclick=function(){_skip+=PAGE_SIZE;loadRequests();});
|
|
},0);
|
|
}
|
|
|
|
function fmtAmt(v,c){return(c||'INR')+' '+Number(v||0).toLocaleString('en-IN',{minimumFractionDigits:2,maximumFractionDigits:2});}
|
|
|
|
async function fetchLinkedDoc(d){
|
|
// For drafts with no ObjectEntry, use DraftEntry
|
|
const entry=d.ObjectEntry||d.DraftEntry;
|
|
if(!entry)return null;
|
|
const isDraft=d.IsDraft==='Y';
|
|
const endpoint=isDraft?'Drafts':OBJ_ENDPOINT[String(d.ObjectType)];
|
|
if(!endpoint)return null;
|
|
try{
|
|
const r=await api('GET',`/sap/documents/${endpoint}/${entry}?company=${encodeURIComponent(_co)}`);
|
|
return r.data||null;
|
|
}catch(_e){
|
|
// If not draft endpoint, try Drafts with DraftEntry
|
|
if(!isDraft&&d.DraftEntry){
|
|
try{const r2=await api('GET',`/sap/documents/Drafts/${d.DraftEntry}?company=${encodeURIComponent(_co)}`);return r2.data||null;}catch(_e2){}
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function renderDocDetail(doc,objType){
|
|
if(!doc)return'<div class="empty-msg">Document not accessible</div>';
|
|
const lines=doc.DocumentLines||doc.StockTransferLines||doc.ProductionOrderLines||[];
|
|
const hasAttach=doc.AttachmentEntry&&doc.AttachmentEntry>0;
|
|
|
|
let headerHtml=`
|
|
<div class="dt-row"><span class="dt-l">Doc Number</span><span class="dt-v">${doc.DocNum||doc.DocEntry||'-'}</span></div>
|
|
<div class="dt-row"><span class="dt-l">Date</span><span class="dt-v">${fmtDate(doc.DocDate||doc.PostingDate)}</span></div>
|
|
${doc.CardName?`<div class="dt-row"><span class="dt-l">Business Partner</span><span class="dt-v" style="font-family:var(--font)">${esc(doc.CardName)} (${esc(doc.CardCode||'')})</span></div>`:''}
|
|
${doc.DocTotal!=null?`<div class="dt-row"><span class="dt-l">Total</span><span class="dt-v">${fmtAmt(doc.DocTotal,doc.DocCurrency)}</span></div>`:''}
|
|
${doc.Comments?`<div class="dt-row"><span class="dt-l">Comments</span><span class="dt-v" style="font-family:var(--font);max-width:60%;text-align:right">${esc(doc.Comments)}</span></div>`:''}
|
|
${doc.DocumentStatus?`<div class="dt-row"><span class="dt-l">Status</span><span class="dt-v">${doc.DocumentStatus==='bost_Close'?'Closed':'Open'}</span></div>`:''}
|
|
`;
|
|
|
|
let linesHtml='';
|
|
if(lines.length){
|
|
linesHtml=`<div style="margin-top:10px;font-size:10px;font-weight:700;text-transform:uppercase;color:var(--text3);margin-bottom:6px">Lines (${lines.length})</div>`;
|
|
linesHtml+=`<div class="stage-card" style="font-weight:700;color:var(--text3);font-size:9px;text-transform:uppercase;background:var(--surface3)"><div style="display:flex;gap:6px"><span style="min-width:70px">Item</span><span style="flex:1">Description</span><span style="min-width:50px;text-align:right">Qty</span><span style="min-width:40px">Whse</span><span style="min-width:70px;text-align:right">Price</span><span style="min-width:70px;text-align:right">Total</span></div></div>`;
|
|
linesHtml+=lines.map(l=>`<div class="stage-card"><div style="display:flex;gap:6px;font-size:11px;flex-wrap:wrap">
|
|
<span style="font-family:var(--mono);color:var(--blue);min-width:70px">${esc(l.ItemCode||l.ItemNo||'')}</span>
|
|
<span style="flex:1;color:var(--text2)">${esc(l.ItemDescription||l.ItemName||'')}</span>
|
|
<span style="font-family:var(--mono);min-width:50px;text-align:right">${l.Quantity||l.PlannedQuantity||''}</span>
|
|
<span style="color:var(--text3);min-width:40px">${esc(l.WarehouseCode||l.Warehouse||'')}</span>
|
|
<span style="font-family:var(--mono);color:var(--text3);min-width:70px;text-align:right">${l.UnitPrice?fmtAmt(l.UnitPrice):'-'}</span>
|
|
<span style="font-family:var(--mono);min-width:70px;text-align:right">${l.LineTotal?fmtAmt(l.LineTotal):''}</span>
|
|
</div></div>`).join('');
|
|
}
|
|
|
|
return headerHtml+linesHtml+(hasAttach?`<div style="margin-top:10px;font-size:10px;font-weight:700;text-transform:uppercase;color:var(--text3)">Attachment Entry: ${doc.AttachmentEntry}</div>`:'');
|
|
}
|
|
|
|
async function openDetail(code){
|
|
const modal=document.getElementById('ar-modal'),inner=document.getElementById('modal-inner');
|
|
modal.classList.add('show');
|
|
inner.innerHTML='<div style="padding:40px;text-align:center;color:var(--text3)"><span class="sp" style="border-color:rgba(26,111,255,.25);border-top-color:var(--blue)"></span></div>';
|
|
try{
|
|
const r=await api('GET',`/sap/approval-requests/${code}?company=${encodeURIComponent(_co)}`);
|
|
const d=r.data;
|
|
const st=STATUS_MAP[d.Status]||d.Status;
|
|
const stCls=st==='Pending'?'pending':st==='Approved'?'approved':'rejected';
|
|
const objType=OBJ_MAP[String(d.ObjectType)]||'Doc '+d.ObjectType;
|
|
const isPending=d.Status==='arsPending';
|
|
const lines=d.ApprovalRequestLines||[];
|
|
const decisions=d.ApprovalRequestDecisions||[];
|
|
|
|
const stagesHtml=lines.length?lines.map((l,i)=>{
|
|
const lst=LINE_STATUS_MAP[l.Status]||l.Status;
|
|
const lCls=lst==='Pending'?'color:var(--amber)':lst==='Approved'?'color:var(--green)':'color:var(--red)';
|
|
return`<div class="stage-card"><div class="stage-top">
|
|
<span style="font-weight:700;color:var(--text)">Stage ${l.StageCode}</span>
|
|
<span style="${lCls};font-weight:700;font-size:10px">${lst}</span>
|
|
<span style="color:var(--text3);font-size:10px">User: ${l.UserID||'-'}</span>
|
|
${l.UpdateDate?`<span style="color:var(--text3);font-size:10px;margin-left:auto">${fmtDate(l.UpdateDate)}</span>`:''}
|
|
</div></div>`;
|
|
}).join(''):'<div class="empty-msg">No stages</div>';
|
|
|
|
const decisionsHtml=decisions.length?decisions.map(dec=>{
|
|
const dst=dec.Status==='ardApproved'?'Approved':dec.Status==='ardNotApproved'?'Rejected':'Pending';
|
|
const dCls=dst==='Approved'?'color:var(--green)':dst==='Rejected'?'color:var(--red)':'color:var(--amber)';
|
|
return`<div class="stage-card"><div class="stage-top">
|
|
<span style="${dCls};font-weight:700">${dst}</span>
|
|
<span style="color:var(--text3);font-size:10px">User: ${dec.ApproverUserName||dec.ApproverUserID||'-'}</span>
|
|
${dec.Remarks?`<span style="color:var(--text2);font-size:10px;flex:1">"${esc(dec.Remarks)}"</span>`:''}
|
|
</div></div>`;
|
|
}).join(''):'';
|
|
|
|
// Fetch linked document
|
|
const linkedDoc=await fetchLinkedDoc(d);
|
|
const docHtml=renderDocDetail(linkedDoc,objType);
|
|
|
|
// Fetch attachment if linked doc has one
|
|
let attachHtml='';
|
|
if(linkedDoc&&linkedDoc.AttachmentEntry&&linkedDoc.AttachmentEntry>0){
|
|
try{
|
|
const att=await api('GET',`/sap/attachments/${linkedDoc.AttachmentEntry}?company=${encodeURIComponent(_co)}`);
|
|
const attLines=att.data?.Attachments2_Lines||[];
|
|
if(attLines.length){
|
|
attachHtml=`<div style="margin-top:10px;font-size:10px;font-weight:700;text-transform:uppercase;color:var(--text3);margin-bottom:6px">Attachments (${attLines.length})</div>`+
|
|
attLines.map((a,ai)=>`<div style="display:flex;align-items:center;gap:8px;padding:6px 8px;background:var(--surface3);border:1px solid var(--border);border-radius:6px;margin-bottom:4px"><span style="font-size:14px"></span><span style="font-size:11px;color:var(--text);flex:1">${esc(a.FileName||'')}.${esc(a.FileExtension||'')}</span><button style="padding:3px 8px;border-radius:4px;font-size:10px;font-weight:700;background:rgba(26,111,255,.08);color:var(--blue);border:1px solid rgba(26,111,255,.25);cursor:pointer" onclick="viewAtt(${linkedDoc.AttachmentEntry},${a.LineNum||ai})">View</button></div>`).join('');
|
|
}
|
|
}catch(_e){}
|
|
}
|
|
|
|
inner.innerHTML=`<div class="modal-hd"><div style="font-weight:700;font-size:13px;display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
|
<span>Approval #${d.Code}</span>
|
|
<span class="ar-badge ${stCls}">${st}</span>
|
|
<span class="ar-badge type">${esc(objType)}</span>
|
|
${d.IsDraft==='Y'?'<span class="ar-badge draft">Draft</span>':''}
|
|
</div><button class="modal-close" id="m-close">X</button></div>
|
|
<div class="modal-bd">
|
|
<div style="display:flex;gap:4px;margin-bottom:12px;background:var(--surface3);border-radius:8px;padding:4px">
|
|
<button class="btn m-tab on" data-mt="tab-doc" style="flex:1;padding:6px;font-size:11px;border-radius:6px">Document</button>
|
|
<button class="btn m-tab" data-mt="tab-approval" style="flex:1;padding:6px;font-size:11px;border-radius:6px;background:none;color:var(--text3)">Approval Info</button>
|
|
</div>
|
|
|
|
<div id="tab-doc">
|
|
${docHtml}
|
|
${attachHtml}
|
|
</div>
|
|
|
|
<div id="tab-approval" style="display:none">
|
|
<div class="dt-row"><span class="dt-l">Code</span><span class="dt-v">${d.Code}</span></div>
|
|
<div class="dt-row"><span class="dt-l">Object Type</span><span class="dt-v">${esc(objType)} (${d.ObjectType})</span></div>
|
|
<div class="dt-row"><span class="dt-l">Object Entry</span><span class="dt-v">${d.ObjectEntry}</span></div>
|
|
<div class="dt-row"><span class="dt-l">Draft</span><span class="dt-v">${d.IsDraft==='Y'?'Yes':'No'}</span></div>
|
|
<div class="dt-row"><span class="dt-l">Current Stage</span><span class="dt-v">${d.CurrentStage||'-'}</span></div>
|
|
<div class="dt-row"><span class="dt-l">Originator</span><span class="dt-v">${d.OriginatorID||'-'}</span></div>
|
|
<div class="dt-row"><span class="dt-l">Created</span><span class="dt-v">${fmtDate(d.CreationDate)} ${d.CreationTime||''}</span></div>
|
|
<div class="dt-row"><span class="dt-l">Remarks</span><span class="dt-v" style="font-family:var(--font);max-width:60%;text-align:right">${esc(d.Remarks||'-')}</span></div>
|
|
|
|
<div style="margin-top:14px;font-size:10px;font-weight:700;text-transform:uppercase;color:var(--text3);margin-bottom:6px">Approval Stages (${lines.length})</div>
|
|
${stagesHtml}
|
|
|
|
${decisionsHtml?`<div style="margin-top:14px;font-size:10px;font-weight:700;text-transform:uppercase;color:var(--text3);margin-bottom:6px">Decisions</div>${decisionsHtml}`:''}
|
|
</div>
|
|
|
|
${isPending?`<div style="margin-top:16px;border-top:1px solid var(--border);padding-top:12px">
|
|
<div class="fg"><label class="fl">Comment (optional)</label><textarea class="comment-area" id="ar-comment" placeholder="Add approval note..."></textarea></div>
|
|
<div class="fg"><label class="fl">SAP Password <span style="font-weight:400;text-transform:none;color:var(--text3)">— leave blank to use your saved SAP login (Admin → Users / Profile → My SAP Account), if you have one</span></label><input id="ar-pwd" type="password" class="comment-area" style="height:34px" placeholder="Only needed if you don't have a saved SAP login" autocomplete="off"/></div>
|
|
<div class="btn-bar">
|
|
<button class="btn b-approve" id="btn-ap">Approve</button>
|
|
<button class="btn b-reject" id="btn-rj">Reject</button>
|
|
</div>
|
|
</div>`:''}
|
|
</div>`;
|
|
|
|
q('#m-close').onclick=()=>modal.classList.remove('show');
|
|
// Tab switching
|
|
inner.querySelectorAll('.m-tab').forEach(tb=>{
|
|
tb.onclick=function(){
|
|
inner.querySelectorAll('.m-tab').forEach(t=>{t.classList.remove('on');t.style.background='none';t.style.color='var(--text3)';});
|
|
this.classList.add('on');this.style.background='var(--surface)';this.style.color='var(--blue)';
|
|
const target=this.dataset.mt;
|
|
inner.querySelector('#tab-doc').style.display=target==='tab-doc'?'block':'none';
|
|
inner.querySelector('#tab-approval').style.display=target==='tab-approval'?'block':'none';
|
|
};
|
|
});
|
|
|
|
if(isPending){
|
|
q('#btn-ap')&&(q('#btn-ap').onclick=async function(){
|
|
const comment=(q('#ar-comment')?.value||'').trim();
|
|
const sapPassword=(q('#ar-pwd')?.value||''); // may be blank — server falls back to this user's saved SAP login
|
|
this.disabled=true;this.textContent='...';
|
|
try{
|
|
const patch={ApprovalRequestDecisions:[{Status:'ardApproved',Remarks:comment||'Approved via portal'}],sapPassword};
|
|
const r=await api('PATCH',`/sap/approval-requests/${code}?company=${encodeURIComponent(_co)}`,patch);
|
|
const posted=r.data?.posted;
|
|
if(posted&&posted.posted)toast('Approved — posted to SAP');
|
|
else if(posted&&posted.message)toast(posted.message,'err');
|
|
else toast('Approved!');
|
|
modal.classList.remove('show');loadRequests();
|
|
}catch(e){toast(e.message,'err');this.disabled=false;this.textContent='Approve';}
|
|
});
|
|
q('#btn-rj')&&(q('#btn-rj').onclick=async function(){
|
|
const comment=(q('#ar-comment')?.value||'').trim();
|
|
const sapPassword=(q('#ar-pwd')?.value||''); // may be blank — server falls back to this user's saved SAP login
|
|
this.disabled=true;this.textContent='...';
|
|
try{
|
|
const patch={ApprovalRequestDecisions:[{Status:'ardNotApproved',Remarks:comment||'Rejected via portal'}],sapPassword};
|
|
await api('PATCH',`/sap/approval-requests/${code}?company=${encodeURIComponent(_co)}`,patch);
|
|
toast('Rejected');modal.classList.remove('show');loadRequests();
|
|
}catch(e){toast(e.message,'err');this.disabled=false;this.textContent='Reject';}
|
|
});
|
|
}
|
|
}catch(e){inner.innerHTML=`<div style="padding:30px;text-align:center;color:var(--red)">${esc(e.message)}</div>`;}
|
|
}
|
|
|
|
window.viewAtt=function(entry,lineId){
|
|
fetch(BE+`/sap/attachments/${entry}/download/${lineId}?company=${encodeURIComponent(_co)}`,{headers:{'Authorization':'Bearer '+_tok}})
|
|
.then(r=>{if(!r.ok)throw new Error('File not accessible');return r.blob();})
|
|
.then(blob=>{window.open(URL.createObjectURL(blob),'_blank');})
|
|
.catch(e=>{toast('Cannot open: '+e.message,'err');});
|
|
};
|
|
|
|
(async function(){try{const list=await window.fetchCompanyList();if(list.length)_companies=list;}catch(_e){}
|
|
try{const p=await api('GET','/auth/profile');if(Array.isArray(p?.user?.sapApprovalTypes))_myApprovalTypes=p.user.sapApprovalTypes.map(String);}catch(_e){}
|
|
loadRequests();})();
|
|
</script>
|
|
<script src="/sidebar.js?v=31"></script>
|
|
</body>
|
|
</html>
|