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
+147 -34
View File
@@ -163,6 +163,13 @@ textarea.fi{height:auto;min-height:60px;resize:vertical;line-height:1.5;padding-
<main>
<div id="app"></div>
</main>
<datalist id="uom-list">
<option value="NOS"></option>
<option value="KG"></option>
<option value="BOX"></option>
<option value="LITRE"></option>
<option value="METER"></option>
</datalist>
<script>
'use strict';
@@ -185,6 +192,9 @@ let _listSkip = 0;
let _listTop = 20;
let _listQ = '';
let _listTotal = 0;
let _listGroupCode = ''; // '' = all Item Groups
let _listStatus = ''; // '' = all, 'active', 'inactive'
let _listWarehouse = ''; // '' = all warehouses
const ITEM_TYPE_CONFIG = {
finished: { prefix: 'FG', label: 'Finished Goods', groupName: 'FINISH GOODS', materialType: 'mt_FinishedGoods', hasSeries: false, defaultManual: true, padLen: 6 },
@@ -477,11 +487,16 @@ function setMode(mode, itemCode=null){
_activeTab = 'general';
render();
} else if(itemCode){
loadItemForEdit(itemCode, mode==='view');
loadItemForEdit(itemCode, mode==='view', mode==='duplicate');
}
}
async function loadItemForEdit(itemCode, readonly=false){
// Duplicate: same fields as Edit's own load, minus the Item Code (which
// gets a fresh auto-generated one, same as a normal New Item of that
// category) — so the user only has to change what's actually different,
// typically just the Description, instead of re-entering the whole item
// from scratch.
async function loadItemForEdit(itemCode, readonly=false, duplicate=false){
try{
toast('Loading item...','warn');
const res = await api('GET', `/sap/items/${encodeURIComponent(itemCode)}/detail?company=${encodeURIComponent(_co)}`);
@@ -556,15 +571,48 @@ async function loadItemForEdit(itemCode, readonly=false){
AdditionalIdentifier: d.SWW||'',
};
const grpName = (_itemGroups.find(g=>String(g.code)===String(d.ItemsGroupCode))||{}).name||'';
if(grpName.includes('RAW')) _selectedTypeKey='rm';
else if(grpName.includes('PACK')) _selectedTypeKey='pk';
else if(grpName.includes('CONSUMABLE')) _selectedTypeKey='ico';
else if(grpName.includes('FINISH')) _selectedTypeKey='finished';
else _selectedTypeKey='custom';
// Primary signal: the item's OWN code prefix (RM/PK/ICO/FG) — set by
// Create Item's own auto-numbering, so it's a direct, reliable match to
// the category that created it. The SAP Item Group is a SEPARATE
// classification that doesn't always line up with it — e.g. ICO16020 is
// filed under Item Group "R&M Other" in SAP, nothing containing
// "Consumable" at all, so group-name matching alone wrongly showed
// Custom/Other for a real ICO item. Group-name matching (case-insensitive
// — SAP's names aren't guaranteed ALL CAPS) is kept as the fallback for
// codes that don't follow any of these prefixes at all.
const codeUp = (d.ItemCode||'').toUpperCase();
if(/^RM\d/.test(codeUp)) _selectedTypeKey='rm';
else if(/^PK\d/.test(codeUp)) _selectedTypeKey='pk';
else if(/^ICO\d/.test(codeUp)) _selectedTypeKey='ico';
else if(/^FG\d/.test(codeUp)) _selectedTypeKey='finished';
else{
const grpName = ((_itemGroups.find(g=>String(g.code)===String(d.ItemsGroupCode))||{}).name||'').toUpperCase();
if(grpName.includes('RAW')) _selectedTypeKey='rm';
else if(grpName.includes('PACK')) _selectedTypeKey='pk';
else if(grpName.includes('CONSUMABLE')) _selectedTypeKey='ico';
else if(grpName.includes('FINISH')) _selectedTypeKey='finished';
else _selectedTypeKey='custom';
}
_selectedWhRow = d.DefaultWarehouse||null;
_activeTab = 'general';
if(duplicate){
_mode='create';_currentItemCode=null;
_form.ItemCode='';
// Cleared, not copied — SAP expects these unique per item, same reason
// Item Code itself always gets a fresh one below.
_form.BarCode='';
const badge=q('#mode-badge');
if(badge){badge.className='mode-badge create';badge.textContent='Create Mode';}
render();
const config=ITEM_TYPE_CONFIG[_selectedTypeKey];
if(config&&config.hasSeries) await refreshItemCode();
toast(`Duplicated from ${itemCode} — update the Description (and anything else that should differ), then Save`,'ok');
setTimeout(()=>{ const nm=q('#inp-name'); if(nm){nm.focus();nm.select();} },0);
return;
}
render();
toast(`Loaded: ${itemCode}`, 'ok');
}catch(e){
@@ -578,10 +626,15 @@ async function loadItemForEdit(itemCode, readonly=false){
// ════════════════════════════════════════════════════════════════
async function loadItemList(){
try{
const res = await api('GET', `/sap/items?company=${encodeURIComponent(_co)}&q=${encodeURIComponent(_listQ)}&skip=${_listSkip}&top=${_listTop}`);
const qs=new URLSearchParams({company:_co,q:_listQ,skip:String(_listSkip),top:String(_listTop)});
if(_listGroupCode) qs.set('groupCode',_listGroupCode);
if(_listStatus) qs.set('status',_listStatus);
if(_listWarehouse) qs.set('warehouse',_listWarehouse);
const res = await api('GET', `/sap/items?${qs.toString()}`);
_itemList = res.data||[];
_listTotal = res.count||0;
renderList();
populateListFilterOptions();
// Restore focus to search box after render, preserving cursor
const searchEl = q('#list-search');
if(searchEl) {
@@ -593,6 +646,21 @@ async function loadItemList(){
toast('Failed to load items','err');
}
}
// The list toolbar (incl. these two <select>s) is only ever BUILT once (see
// the `if(!q('#list-toolbar-root'))` guard in renderList() below) — kept
// that way so repeated re-renders while the user is typing in the search
// box never steal its focus. But _itemGroups/_wh (services/... lookups)
// load asynchronously and can still be empty at that first build (e.g.
// landing straight on ?list=1 before loadLookups() resolves), which would
// otherwise leave these two dropdowns permanently stuck on "All" with no
// real options. Refreshing just their <option> lists (not the whole
// toolbar) on every list load is cheap and doesn't touch the search input.
function populateListFilterOptions(){
const gSel=q('#sel-list-group');
if(gSel) gSel.innerHTML=`<option value="">All Item Groups</option>`+_itemGroups.map(g=>`<option value="${esc(g.code)}" ${String(_listGroupCode)===String(g.code)?'selected':''}>${esc(g.name)}</option>`).join('');
const wSel=q('#sel-list-wh');
if(wSel) wSel.innerHTML=`<option value="">All Warehouses</option>`+_wh.map(w=>`<option value="${esc(w.code)}" ${String(_listWarehouse)===String(w.code)?'selected':''}>${esc(w.code)}${w.name?' - '+esc(w.name):''}</option>`).join('');
}
function renderList(){
const app = q('#app');
@@ -616,8 +684,25 @@ function renderList(){
<input type="text" id="list-search" placeholder="Search item code or name..."
value="${esc(_listQ)}" autocomplete="off" spellcheck="false"/>
</div>
<button class="btn b-primary" id="btn-search" style="padding:8px 16px;font-size:12px"><i data-lucide="search" style="width:14px;height:14px"></i> Search</button>
<button class="btn b-success" id="btn-new-item" style="padding:8px 16px;font-size:12px"><i data-lucide="plus" style="width:14px;height:14px"></i> New Item</button>
</div>
<div class="list-toolbar" id="list-filter-root" style="margin-top:8px">
<select class="fi" id="sel-list-group" style="height:32px;font-size:11.5px;max-width:220px">
<option value="">All Item Groups</option>
${_itemGroups.map(g=>`<option value="${esc(g.code)}" ${String(_listGroupCode)===String(g.code)?'selected':''}>${esc(g.name)}</option>`).join('')}
</select>
<select class="fi" id="sel-list-status" style="height:32px;font-size:11.5px;max-width:150px">
<option value="">All Status</option>
<option value="active" ${_listStatus==='active'?'selected':''}>Active</option>
<option value="inactive" ${_listStatus==='inactive'?'selected':''}>Inactive</option>
</select>
<select class="fi" id="sel-list-wh" style="height:32px;font-size:11.5px;max-width:200px">
<option value="">All Warehouses</option>
${_wh.map(w=>`<option value="${esc(w.code)}" ${String(_listWarehouse)===String(w.code)?'selected':''}>${esc(w.code)}${w.name?' - '+esc(w.name):''}</option>`).join('')}
</select>
<button class="btn b-cancel" id="btn-clear-filters" style="padding:6px 14px;font-size:11.5px">Clear Filters</button>
</div>
<div id="list-container" class="item-list"></div>
<div id="list-pagination" class="pagination"></div>
</div>`;
@@ -631,6 +716,32 @@ function renderList(){
clearTimeout(window._searchDebounce);
window._searchDebounce = setTimeout(loadItemList, 400);
};
// Explicit Search button — same effect as the debounced typing above,
// but fires immediately (no 400ms wait, and no need to keep typing) —
// also handy after picking a filter without touching the search box.
q('#btn-search').onclick = function(){
clearTimeout(window._searchDebounce);
_listSkip = 0;
loadItemList();
};
q('#list-search').addEventListener('keydown', function(e){
if(e.key==='Enter'){ clearTimeout(window._searchDebounce); _listSkip=0; loadItemList(); }
});
q('#sel-list-group').onchange = function(){ _listGroupCode=this.value; _listSkip=0; loadItemList(); };
q('#sel-list-status').onchange = function(){ _listStatus=this.value; _listSkip=0; loadItemList(); };
q('#sel-list-wh').onchange = function(){ _listWarehouse=this.value; _listSkip=0; loadItemList(); };
q('#btn-clear-filters').onclick = function(){
_listQ=''; _listGroupCode=''; _listStatus=''; _listWarehouse=''; _listSkip=0;
// The toolbar is only ever built once (see the `if(!q('#list-toolbar-
// root'))` guard above) — later renders just refresh the results, so
// clearing state alone wouldn't visually reset these controls; set
// their DOM values directly too.
if(q('#list-search'))q('#list-search').value='';
if(q('#sel-list-group'))q('#sel-list-group').value='';
if(q('#sel-list-status'))q('#sel-list-status').value='';
if(q('#sel-list-wh'))q('#sel-list-wh').value='';
loadItemList();
};
q('#btn-new-item').onclick = ()=>setMode('create');
}
@@ -664,6 +775,7 @@ function renderListItems(){
<span class="po-actions">
<button class="btn-icon" data-action="view" data-code="${esc(item.ItemCode)}" title="View"><i data-lucide="eye"></i></button>
<button class="btn-icon" data-action="edit" data-code="${esc(item.ItemCode)}" title="Edit"><i data-lucide="pencil"></i></button>
<button class="btn-icon" data-action="duplicate" data-code="${esc(item.ItemCode)}" title="Duplicate — copies every field except Item Code (auto-generated fresh)"><i data-lucide="copy"></i></button>
</span>
</div>
</div>`;
@@ -849,7 +961,7 @@ function render(){
} else if(_mode==='edit'){
actions.innerHTML = `<div class="card-bd"><div class="btn-bar">
<button class="btn b-primary" id="btn-update"><span id="sp-update" style="display:none" class="sp"></span> Update Item</button>
<button class="btn b-success" id="btn-save-as-new">Save as New</button>
<button class="btn b-success" id="btn-save-as-new">⧉ Duplicate</button>
<button class="btn b-danger" id="btn-cancel-item">Cancel Item</button>
<button class="btn b-cancel" id="btn-view-list">Back to List</button>
</div></div>`;
@@ -857,7 +969,7 @@ function render(){
// view mode
actions.innerHTML = `<div class="card-bd"><div class="btn-bar">
<button class="btn b-primary" id="btn-edit">✎ Edit Item</button>
<button class="btn b-success" id="btn-save-as-new">Save as New</button>
<button class="btn b-success" id="btn-save-as-new">⧉ Duplicate</button>
<button class="btn b-cancel" id="btn-view-list">Back to List</button>
</div></div>`;
}
@@ -976,14 +1088,7 @@ function renderPurchasing(tc){
<input class="fi ${isReadonly?'readonly-field':''}" id="p-vendor" placeholder="Vendor code..." value="${esc(_form.DefaultVendor||'')}" ${isReadonly?'readonly':''}/>
</div>
<div class="fg"><label class="fl">Purchasing UoM</label>
<select class="fi ${isReadonly?'readonly-field':''}" id="p-puom" ${isReadonly?'disabled':''}>
<option value="">- Same as inventory -</option>
<option value="NOS" ${_form.PurchaseUnit==='NOS'?'selected':''}>NOS</option>
<option value="KG" ${_form.PurchaseUnit==='KG'?'selected':''}>KG</option>
<option value="BOX" ${_form.PurchaseUnit==='BOX'?'selected':''}>BOX</option>
<option value="LITRE" ${_form.PurchaseUnit==='LITRE'?'selected':''}>LITRE</option>
<option value="METER" ${_form.PurchaseUnit==='METER'?'selected':''}>METER</option>
</select>
<input class="fi ${isReadonly?'readonly-field':''}" id="p-puom" list="uom-list" placeholder="- Same as inventory -" value="${esc(_form.PurchaseUnit||'')}" ${isReadonly?'readonly':''}/>
</div>
</div>
<div class="fi-row">
@@ -1006,7 +1111,7 @@ function renderPurchasing(tc){
tc.appendChild(c);
if(!isReadonly){
const w=(id,f,type='text')=>{const el=q(`#${id}`);if(!el)return;el[type==='select'?'onchange':'oninput']=function(){_form[f]=type==='num'?parseFloat(this.value)||0:this.value;};};
w('p-vendor','DefaultVendor'); w('p-puom','PurchaseUnit','select');
w('p-vendor','DefaultVendor'); w('p-puom','PurchaseUnit');
w('p-qpp','PurchaseQtyPerPackage','num'); w('p-ipu','PurchaseItemsPerUnit','num');
w('p-lead','LeadTime','num'); w('p-moq','MinimumOrderQuantity','num');
}
@@ -1019,13 +1124,7 @@ function renderSales(tc){
<div class="card-bd">
<div class="fi-row">
<div class="fg"><label class="fl">Sales UoM</label>
<select class="fi ${isReadonly?'readonly-field':''}" id="s-suom" ${isReadonly?'disabled':''}>
<option value="">- Same as inventory -</option>
<option value="NOS" ${_form.SalesUnit==='NOS'?'selected':''}>NOS</option>
<option value="KG" ${_form.SalesUnit==='KG'?'selected':''}>KG</option>
<option value="BOX" ${_form.SalesUnit==='BOX'?'selected':''}>BOX</option>
<option value="LITRE" ${_form.SalesUnit==='LITRE'?'selected':''}>LITRE</option>
</select>
<input class="fi ${isReadonly?'readonly-field':''}" id="s-suom" list="uom-list" placeholder="- Same as inventory -" value="${esc(_form.SalesUnit||'')}" ${isReadonly?'readonly':''}/>
</div>
<div class="fg"><label class="fl">Sales QTY Per Package</label>
<input class="fi fi-mono ${isReadonly?'readonly-field':''}" id="s-qpp" type="number" min="0" step="0.001" placeholder="0" value="${_form.SalesQtyPerPackage||''}" ${isReadonly?'readonly':''}/>
@@ -1044,7 +1143,7 @@ function renderSales(tc){
if(!isReadonly){
setTimeout(()=>{
const w=(id,f,type='text')=>{const el=q(`#${id}`);if(!el)return;el[type==='select'?'onchange':'oninput']=function(){_form[f]=type==='num'?parseFloat(this.value)||0:this.value;};};
w('s-suom','SalesUnit','select'); w('s-qpp','SalesQtyPerPackage','num');
w('s-suom','SalesUnit'); w('s-qpp','SalesQtyPerPackage','num');
w('s-moq','SalesMinimumOrderQuantity','num'); w('s-warranty','WarrantyTemplate','num');
},0);
}
@@ -1275,19 +1374,33 @@ function wireHeaderEvents(){
// FIX: wire the Back to List / View Items button in all modes
bind('#btn-view-list','onclick', goToList);
// FIX: wire Save as New button (edit + view modes)
bind('#btn-save-as-new','onclick',()=>{
// FIX: wire Save as New button (edit + view modes) — same "duplicate"
// behavior as the list's own Duplicate action: every field carries over
// except Item Code, which gets a fresh auto-generated one (for a
// series-based category) instead of forcing manual entry, so the user
// only has to change what's actually different (typically just the
// Description) before saving.
bind('#btn-save-as-new','onclick',async()=>{
const oldCode = _form.ItemCode;
_form.ItemCode = '';
_form.BarCode = ''; // unique-per-item in SAP — never copy, same as Item Code
_currentItemCode = null;
_mode = 'create';
_lastSequence = null;
_isManualMode = true;
_sequenceError = null;
const badge = q('#mode-badge');
if(badge){ badge.className='mode-badge create'; badge.textContent='Create Mode'; }
render();
toast(`Cloning from ${oldCode} — enter a new item code`, 'warn');
const config = ITEM_TYPE_CONFIG[_selectedTypeKey];
if(config&&config.hasSeries){
_isManualMode=false;
render();
await refreshItemCode();
}else{
_isManualMode=true;
render();
}
toast(`Duplicated from ${oldCode} — update the Description (and anything else that should differ), then Save`, 'ok');
setTimeout(()=>{ const nm=q('#inp-name'); if(nm){nm.focus();nm.select();} },0);
});
// Auto-init sequence for series categories on first create render
@@ -1539,7 +1652,7 @@ async function init(){
init();
</script>
<script src="/sidebar.js?v=25"></script>
<script src="/sidebar.js?v=31"></script>
</body>
</html>