sale order
SAP-ERP Portal CI/CD / build (push) Successful in 3m57s

This commit is contained in:
John
2026-10-05 18:45:17 +05:30
parent e725a6571b
commit eead8f5ffd
129 changed files with 14252 additions and 452 deletions
+343
View File
@@ -0,0 +1,343 @@
<!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>Inventory Transfer — 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;--green2:#059669;--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:900px;margin:0 auto}
.page-head{display:flex;align-items:center;gap:10px;margin-bottom:12px}
.page-icon{width:34px;height:34px;border-radius:9px;background:rgba(6,182,212,.14);color:var(--teal);display:flex;align-items:center;justify-content:center}
.page-title{font-size:16px;font-weight:700}
.page-sub{font-size:11px;color:var(--text3)}
.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}
.fg{margin-bottom:10px}
.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(--teal)}
.fi-mono{font-family:var(--mono)}
select.fi{cursor:pointer}
.fi-row{display:grid;grid-template-columns:1fr 1fr;gap:8px}
.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(--teal);color:#042b33}
.b-primary:disabled{opacity:.5;cursor:not-allowed}
.b-cancel{background:var(--surface2);color:var(--text2);border:1px solid var(--border2)}
.search-wrap{position:relative}
.search-dd{position:absolute;top:100%;left:0;right:0;background:var(--surface2);border:1px solid var(--teal);border-radius:7px;box-shadow:0 12px 40px rgba(0,0,0,.7);z-index:300;max-height:220px;overflow-y:auto;display:none;margin-top:4px}
.sd-row{padding:10px 12px;cursor:pointer;border-bottom:1px solid var(--border);display:flex;gap:8px;align-items:center}
.sd-row:hover{background:rgba(6,182,212,.1)}
.sd-code{font-family:var(--mono);font-size:11px;color:var(--teal);font-weight:600;min-width:90px}
.sd-name{font-size:11px;color:var(--text2)}
.sd-msg{padding:12px;text-align:center;color:var(--text3);font-size:12px}
.line-card{background:var(--surface2);border:1px solid var(--border2);border-radius:9px;padding:10px 12px;margin-bottom:10px}
.line-top{display:flex;align-items:center;gap:8px;margin-bottom:8px}
.line-num{font-size:11px;font-weight:700;color:var(--text3)}
.line-remove{margin-left:auto;background:none;border:none;color:var(--red);cursor:pointer;font-size:14px;padding:2px 6px}
.line-grid{display:grid;grid-template-columns:2fr 1fr;gap:8px}
.line-item-code{font-family:var(--mono);font-weight:700;color:var(--teal);font-size:12.5px}
.line-item-name{font-size:11px;color:var(--text2);margin-top:1px}
.batch-sec{margin-top:10px;border-top:1px dashed var(--border2);padding-top:8px}
.batch-title{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.3px;color:var(--text3);margin-bottom:6px}
.batch-auto{color:var(--teal);font-weight:400;text-transform:none;letter-spacing:0}
.batch-row{display:flex;align-items:center;gap:8px;padding:4px 0;font-size:11.5px}
.batch-num{font-family:var(--mono);color:var(--text);min-width:150px}
.batch-avail{color:var(--text3);min-width:90px}
.batch-inp{width:90px;padding:5px 7px;border:1px solid var(--border2);border-radius:6px;background:var(--surface);color:var(--text);font-family:var(--mono);font-size:11.5px;text-align:right}
.batch-total-row{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;padding:6px 8px;border-radius:6px}
.empty-msg{text-align:center;padding:26px;color:var(--text3);font-size:12px}
.sp{width:14px;height:14px;border:2px solid rgba(6,182,212,.25);border-top-color:var(--teal);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}
.t-ok{background:var(--green);color:#fff}
.success-box{text-align:center;padding:30px 20px}
.success-box .big{font-size:40px;color:var(--green);margin-bottom:10px}
</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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
function q(s){return document.querySelector(s);}
function toast(m,kind){const tc=q('#toast-c');const e=document.createElement('div');e.className='toast '+(kind==='ok'?'t-ok':'t-err');e.textContent=m;tc.appendChild(e);setTimeout(()=>e.remove(),4500);}
function fmtQty(n){return(Math.round((parseFloat(n)||0)*1000)/1000).toString();}
async function api(method,p,body){
const headers={Authorization:'Bearer '+_tok};
const opts={method,headers};
if(body!=null){headers['Content-Type']='application/json';opts.body=JSON.stringify(body);}
const r=await fetch(BE+p,opts);
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;
}
// Per-company SAP login (see [[per-company-sap-login]]) — each user can have
// a different SAP User/Password per company, embedded in the JWT. Checked
// client-side BEFORE attempting to post, same pattern as every other SAP-
// posting page (e.g. Receipt from Production), so switching Company here
// re-checks against the newly selected company every time.
function hasSapLogin(){try{const p=JSON.parse(atob(_tok.split('.')[1]));return !!(p&&p.sapLogins&&p.sapLogins[_co]&&p.sapLogins[_co].pwdEnc);}catch(_e){return false;}}
function requireSapLogin(){if(!hasSapLogin()){toast(`No SAP login set for company "${_co}" — open Profile → "My SAP Account" and add one before posting a transfer.`,'err');return false;}return true;}
let _co=window.__DEFAULT_COMPANY__||'';
let _companies=[];
let _wh=[];
let _fromWh='',_toWh='',_postDate='',_remarks='';
let _lines=[]; // [{itemCode,itemName,quantity,batches,batchLoading,selectedBatches}]
let _submitting=false;
let _success=null;
let _seriesInfo=null; // {series,seriesName} — admin-configured document series, resolved to its SAP name
function coOptions(sel){return _companies.map(c=>`<option value="${esc(c.value)}" ${sel===c.value?'selected':''}>${esc(c.label)}</option>`).join('');}
function whOptions(sel){return `<option value="">— select —</option>`+_wh.map(w=>`<option value="${esc(w.code)}" ${sel===w.code?'selected':''}>${esc(w.code)}${w.name?' - '+esc(w.name):''}</option>`).join('');}
async function loadWarehouses(){
try{const r=await api('GET',`/sap/lookup/warehouses?company=${encodeURIComponent(_co)}`);if(r.success&&r.data?.length)_wh=r.data;}catch(_e){}
}
async function loadSeriesInfo(){
try{const r=await api('GET',`/inventory-transfer/series-info?company=${encodeURIComponent(_co)}`);_seriesInfo=r.success?{series:r.series,seriesName:r.seriesName}:null;}
catch(_e){_seriesInfo=null;}
}
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 addLine(){
_lines.push({itemCode:'',itemName:'',quantity:0,batches:[],batchLoading:false,selectedBatches:[]});
render();
}
function removeLine(i){ _lines.splice(i,1); render(); }
// Same FEFO auto-select pattern as Issue for Production (public/issue-
// production.html's autoSelectBatches) — earliest-expiry batches first,
// greedily filled until the requested quantity is covered.
async function loadBatches(line){
if(!line.itemCode||!_fromWh){line.batches=[];line.selectedBatches=[];return;}
line.batchLoading=true;render();
try{
const r=await api('GET',`/sap/lookup/batches?item=${encodeURIComponent(line.itemCode)}&warehouse=${encodeURIComponent(_fromWh)}&company=${encodeURIComponent(_co)}`);
line.batches=r.data||[];
if(line.batches.length) autoSelectBatches(line);
}catch(_e){line.batches=[];}
line.batchLoading=false;render();
}
function autoSelectBatches(line){
let rem=parseFloat(line.quantity)||0;
line.selectedBatches=[];
for(const b of line.batches){
if(rem<=0)break;
const take=Math.min(b.Quantity,rem);
line.selectedBatches.push({BatchNumber:b.BatchNumber,Quantity:take});
rem-=take;
}
}
let _codeSearchTimer=null;
function wireItemSearch(i){
const inp=q(`#li-item-${i}`),dd=q(`#li-dd-${i}`);if(!inp||!dd)return;
inp.oninput=function(){
const l=_lines[i];
l.itemCode=this.value.toUpperCase();l.itemName='';l.batches=[];l.selectedBatches=[];
const v=this.value.trim();
if(v.length<2){dd.style.display='none';return;}
clearTimeout(_codeSearchTimer);
_codeSearchTimer=setTimeout(async()=>{
dd.innerHTML='<div class="sd-msg"><span class="sp"></span></div>';dd.style.display='block';
const items=await fetchItems(v);
if(l.itemCode!==this.value.toUpperCase())return;
if(!items.length){dd.innerHTML='<div class="sd-msg">No results</div>';return;}
dd.innerHTML='';
items.forEach(it=>{
const row=document.createElement('div');row.className='sd-row';
row.innerHTML=`<span class="sd-code">${esc(it.ItemCode)}</span><span class="sd-name">${esc(it.ItemName)}</span>`;
row.onclick=()=>{
l.itemCode=it.ItemCode;l.itemName=it.ItemName||'';
dd.style.display='none';render();loadBatches(l);
};
dd.appendChild(row);
});
},250);
};
}
document.addEventListener('click',e=>{
if(!e.target.closest('.search-wrap'))document.querySelectorAll('.search-dd').forEach(d=>d.style.display='none');
});
function lineBatchHtml(l,i){
if(l.batchLoading)return `<div class="batch-sec"><span class="sp"></span> Loading batches…</div>`;
if(!l.batches.length)return'';
const total=l.selectedBatches.reduce((s,b)=>s+(b.Quantity||0),0);
const ok=Math.abs(total-(parseFloat(l.quantity)||0))<=0.001;
return `<div class="batch-sec">
<div class="batch-title">Batches <span class="batch-auto">(auto-selected, FEFO)</span></div>
<div class="batch-total-row" style="border:1px solid ${ok?'rgba(13,187,138,.35)':'rgba(229,62,62,.4)'};background:${ok?'rgba(13,187,138,.08)':'rgba(229,62,62,.08)'}">
<span style="font-size:11px;font-weight:700;color:${ok?'var(--green)':'var(--red)'}">Total</span>
<span style="font-size:12px;font-weight:700;color:${ok?'var(--green)':'var(--red)'}">${fmtQty(total)}${ok?'':` / ${fmtQty(l.quantity)} required`}</span>
</div>
${l.batches.map((b,bi)=>{
const sel=l.selectedBatches.find(s=>s.BatchNumber===b.BatchNumber);
const qty=sel?sel.Quantity:0;
return `<div class="batch-row">
<span class="batch-num">${esc(b.BatchNumber)}</span>
<span class="batch-avail">Avail: ${fmtQty(b.Quantity)}</span>
<input class="batch-inp" data-li="${i}" data-bi="${bi}" data-bn="${esc(b.BatchNumber)}" type="number" min="0" max="${b.Quantity}" step="0.001" value="${qty}"/>
</div>`;
}).join('')}
</div>`;
}
function render(){
const app=q('#app');
if(_success){
app.innerHTML=`
<div class="page-head">
<div class="page-icon">🔁</div>
<div><div class="page-title">Inventory Transfer</div><div class="page-sub">Move stock between warehouses — same as SAP B1's own Inventory Transfer window</div></div>
</div>
<div class="card"><div class="card-bd success-box">
<div class="big">✓</div>
<div style="font-size:15px;font-weight:700;margin-bottom:6px">Inventory Transfer Posted</div>
<div style="font-size:12px;color:var(--text2);margin-bottom:18px">DocEntry <b>${esc(_success.docEntry)}</b> — DocNum <b>${esc(_success.docNum)}</b><br/>${esc(_fromWh)} → ${esc(_toWh)}</div>
<button class="btn b-primary" id="btn-new">+ New Inventory Transfer</button>
</div></div>`;
q('#btn-new').onclick=()=>{_success=null;_fromWh='';_toWh='';_postDate='';_remarks='';_lines=[];render();};
return;
}
const totalQty=_lines.reduce((s,l)=>s+(parseFloat(l.quantity)||0),0);
app.innerHTML=`
<div class="page-head">
<div class="page-icon">🔁</div>
<div><div class="page-title">Inventory Transfer</div><div class="page-sub">Move stock between warehouses — batches auto-selected (FEFO) once issued, same as Issue for Production</div></div>
</div>
<div class="card">
<div class="card-hd"><span>Transfer Details</span>${_seriesInfo?.seriesName?`<span class="right">Series: <b style="color:var(--teal)">${esc(_seriesInfo.seriesName)}</b></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="fi-row">
<div class="fg"><label class="fl">From Warehouse *</label><select class="fi" id="sel-fromwh">${whOptions(_fromWh)}</select></div>
<div class="fg"><label class="fl">To Warehouse *</label><select class="fi" id="sel-towh">${whOptions(_toWh)}</select></div>
</div>
<div class="fi-row">
<div class="fg"><label class="fl">Posting Date</label><input class="fi" id="inp-postdate" type="date" value="${esc(_postDate)}"/></div>
<div class="fg"><label class="fl">Remarks</label><input class="fi" id="inp-remarks" value="${esc(_remarks)}" placeholder="Optional"/></div>
</div>
</div>
</div>
<div class="card">
<div class="card-hd"><span>Items</span><span class="right">${_lines.length} line(s)${totalQty?` · Total Qty ${fmtQty(totalQty)}`:''}</span></div>
<div class="card-bd">
${!_lines.length?`<div class="empty-msg">No items added yet.</div>`:
_lines.map((l,i)=>`
<div class="line-card">
<div class="line-top"><span class="line-num">#${i+1}</span><button type="button" class="line-remove" data-remove="${i}" title="Remove">✕</button></div>
<div class="line-grid">
<div class="search-wrap">
<label class="fl">Item Code</label>
<input class="fi fi-mono" id="li-item-${i}" value="${esc(l.itemCode)}" placeholder="Search item code / name…" autocomplete="off"/>
<div class="search-dd" id="li-dd-${i}"></div>
${l.itemName?`<div class="line-item-name">${esc(l.itemName)}</div>`:''}
</div>
<div><label class="fl">Quantity</label><input class="fi fi-mono" data-qty="${i}" type="number" min="0.001" step="0.001" value="${l.quantity||''}" style="text-align:right"/></div>
</div>
${lineBatchHtml(l,i)}
</div>`).join('')}
<button type="button" class="btn b-cancel" id="btn-add-line">+ Add Item</button>
</div>
</div>
<div class="card"><div class="card-bd" style="display:flex;gap:10px">
<button class="btn b-primary" id="btn-ok" style="flex:1" ${_submitting?'disabled':''}>${_submitting?'<span class="sp"></span> Posting…':'OK — Post Transfer'}</button>
<button class="btn b-cancel" id="btn-cancel" style="flex:1">Cancel</button>
</div></div>`;
wire();
}
function wire(){
if(_success)return;
q('#sel-co').onchange=async function(){_co=this.value;_wh=[];_fromWh='';_toWh='';_lines=[];await Promise.all([loadWarehouses(),loadSeriesInfo()]);render();};
q('#sel-fromwh').onchange=function(){_fromWh=this.value;_lines.forEach(l=>{l.batches=[];l.selectedBatches=[];if(l.itemCode)loadBatches(l);});render();};
q('#sel-towh').onchange=function(){_toWh=this.value;};
q('#inp-postdate').onchange=function(){_postDate=this.value;};
q('#inp-remarks').oninput=function(){_remarks=this.value;};
q('#btn-add-line').onclick=addLine;
q('#btn-cancel').onclick=()=>{_fromWh='';_toWh='';_postDate='';_remarks='';_lines=[];render();};
q('#btn-ok').onclick=doTransfer;
document.querySelectorAll('[data-remove]').forEach(btn=>{btn.onclick=()=>removeLine(parseInt(btn.dataset.remove));});
document.querySelectorAll('[data-qty]').forEach(inp=>{
inp.onchange=function(){
const i=parseInt(this.dataset.qty);
_lines[i].quantity=parseFloat(this.value)||0;
if(_lines[i].batches.length) autoSelectBatches(_lines[i]);
render();
};
});
document.querySelectorAll('.batch-inp').forEach(inp=>{
inp.onchange=function(){
const li=parseInt(this.dataset.li),bn=this.dataset.bn;
const qty=parseFloat(this.value)||0;
const l=_lines[li];
const existing=l.selectedBatches.find(b=>b.BatchNumber===bn);
if(existing)existing.Quantity=qty;
else if(qty>0)l.selectedBatches.push({BatchNumber:bn,Quantity:qty});
render();
};
});
_lines.forEach((_,i)=>wireItemSearch(i));
}
async function doTransfer(){
if(!_fromWh||!_toWh){toast('Select From and To Warehouse','err');return;}
if(_fromWh===_toWh){toast('From and To Warehouse must be different','err');return;}
const valid=_lines.filter(l=>l.itemCode&&(parseFloat(l.quantity)||0)>0);
if(!valid.length){toast('Add at least one item with a quantity','err');return;}
for(const l of valid){
if(l.batches.length){
const total=l.selectedBatches.reduce((s,b)=>s+(b.Quantity||0),0);
if(Math.abs(total-l.quantity)>0.001){toast(`${l.itemCode}: batch qty (${fmtQty(total)}) must equal quantity (${fmtQty(l.quantity)})`,'err');return;}
}
}
if(!requireSapLogin())return;
_submitting=true;render();
try{
const payload={
company:_co,fromWarehouse:_fromWh,toWarehouse:_toWh,
postingDate:_postDate||undefined,comments:_remarks||undefined,
lines:valid.map(l=>({itemCode:l.itemCode,quantity:l.quantity,batchNumbers:l.selectedBatches})),
};
const r=await api('POST','/inventory-transfer',payload);
_success={docEntry:r.data?.DocEntry||'',docNum:r.data?.DocNum||''};
toast('Inventory Transfer posted!','ok');
}catch(e){toast(e.message||'Failed','err');}
_submitting=false;render();
}
async function init(){
try{const list=await window.fetchCompanyList();if(list.length)_companies=list;}catch(_e){}
await Promise.all([loadWarehouses(),loadSeriesInfo()]);
render();
}
init();
</script>
<script src="/sidebar.js?v=31"></script>
</body>
</html>