+147
-34
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user