1659 lines
86 KiB
HTML
1659 lines
86 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>Item Master — SAP B1</title>
|
||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700;800&display=swap" rel="stylesheet"/>
|
||
<script src="/lucide.min.js?v=399"></script>
|
||
<style>
|
||
:root{
|
||
--blue:#2563eb;--blue2:#1d4ed8;--blue-light:#eff6ff;
|
||
--purple:#7c3aed;
|
||
--text:#0f172a;--text2:#334155;--text3:#64748b;--text4:#94a3b8;
|
||
--bg:#f4f6f9;--ink2:#f8fafc;--surface:#fff;--surface2:#f8fafc;--surface3:#eef1f5;--border:#e2e8f0;--border2:#cbd5e1;
|
||
--font:'Space Grotesk',sans-serif;--mono:'JetBrains Mono',ui-monospace,monospace;
|
||
--red:#dc2626;--green:#16a34a;--amber:#d97706;--radius:10px;
|
||
--shadow:0 1px 3px rgba(15,23,42,.06),0 1px 2px rgba(15,23,42,.04);
|
||
}
|
||
*{box-sizing:border-box;margin:0;padding:0}
|
||
body{font-family:var(--font);background:var(--bg);color:var(--text);min-height:100vh;-webkit-font-smoothing:antialiased}
|
||
main{padding:24px;max-width:1250px;margin:0 auto}
|
||
|
||
.tabs{display:flex;gap:4px;margin-bottom:16px;background:var(--surface3);border-radius:10px;padding:4px}
|
||
.tab{flex:1;padding:9px;border-radius:8px;font-size:12px;font-weight:700;color:var(--text3);cursor:pointer;border:none;background:none;font-family:var(--font);text-align:center;transition:all .15s;white-space:nowrap}
|
||
.tab:hover{color:var(--text2)}
|
||
.tab.on{background:var(--surface);color:var(--blue);box-shadow:var(--shadow)}
|
||
|
||
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow);margin-bottom:16px;overflow:visible}
|
||
.card-hd{background:var(--surface2);padding:12px 18px;font-size:13px;font-weight:700;color:var(--text);display:flex;align-items:center;gap:8px;border-bottom:1px solid var(--border);border-radius:10px 10px 0 0;flex-wrap:wrap}
|
||
.card-hd .ico{width:16px;height:16px;color:var(--blue);flex-shrink:0;display:inline-flex}
|
||
.card-hd .right{margin-left:auto;font-size:11px;color:var(--text3);font-weight:400}
|
||
.card-bd{padding:18px}
|
||
|
||
.fg{margin-bottom:12px}
|
||
.fl{font-size:10.5px;font-weight:700;text-transform:uppercase;letter-spacing:.4px;color:var(--text3);margin-bottom:5px;display:block}
|
||
.fi{width:100%;padding:8px 11px;border:1.5px solid var(--border);border-radius:8px;font-size:13px;font-family:var(--font);background:var(--surface);color:var(--text);outline:none;height:36px}.fi:focus{border-color:var(--blue);box-shadow:0 0 0 3px rgba(37,99,235,.12)}.fi::placeholder{color:var(--text4)}
|
||
select.fi{cursor:pointer;appearance:none;background:var(--surface) 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='%2394a3b8'/%3E%3C/svg%3E") no-repeat right 10px center;padding-right:26px}
|
||
.fi-row{display:grid;grid-template-columns:1fr 1fr;gap:10px}
|
||
.fi-row-3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px}
|
||
@media(max-width:500px){.fi-row,.fi-row-3{grid-template-columns:1fr}}
|
||
.fi-mono{font-family:var(--mono);font-size:12px;text-transform:uppercase}
|
||
textarea.fi{height:auto;min-height:60px;resize:vertical;line-height:1.5;padding-top:8px}
|
||
.chk-row{display:flex;gap:16px;flex-wrap:wrap;margin-bottom:8px}
|
||
.chk-item{display:flex;align-items:center;gap:6px;cursor:pointer;font-size:12.5px;color:var(--text2);user-select:none}
|
||
.chk-item input[type=checkbox]{width:15px;height:15px;accent-color:var(--blue);cursor:pointer}
|
||
.chk-item input[type=radio]{width:15px;height:15px;accent-color:var(--blue);cursor:pointer}
|
||
.divider{border:none;border-top:1px solid var(--border);margin:12px 0}
|
||
|
||
.btn-bar{display:flex;gap:8px;flex-wrap:wrap}
|
||
.btn{padding:10px 20px;border-radius:9px;border:none;font-family:var(--font);font-size:13px;font-weight:700;cursor:pointer;transition:all .15s;display:inline-flex;align-items:center;gap:6px;justify-content:center}.btn:disabled{opacity:.4;cursor:not-allowed}
|
||
.b-primary{background:var(--blue);color:#fff;flex:1}.b-primary:hover:not(:disabled){background:var(--blue2);box-shadow:0 4px 16px rgba(37,99,235,.35)}
|
||
.b-success{background:var(--green);color:#fff}.b-success:hover:not(:disabled){background:#15803d;box-shadow:0 4px 16px rgba(22,163,74,.3)}
|
||
.b-cancel{background:var(--surface2);color:var(--text2);border:1px solid var(--border2)}.b-cancel:hover{border-color:var(--text3)}
|
||
.b-danger{background:rgba(220,38,38,.06);color:var(--red);border:1px solid rgba(220,38,38,.2)}.b-danger:hover{background:rgba(220,38,38,.12)}
|
||
.b-view{background:var(--blue-light);color:var(--blue);border:1px solid #bfdbfe}.b-view:hover{background:#dbeafe}
|
||
.b-amber{background:rgba(217,119,6,.1);color:var(--amber);border:1px solid rgba(217,119,6,.3);font-size:12px;padding:7px 16px;border-radius:8px;font-weight:700;cursor:pointer;font-family:var(--font);transition:all .15s}.b-amber:hover:not(:disabled){background:rgba(217,119,6,.18)}.b-amber:disabled{opacity:.35;cursor:not-allowed}
|
||
|
||
.alert{padding:10px 12px;border-radius:8px;font-size:12px;margin-bottom:12px;line-height:1.5}
|
||
.al-green{background:rgba(22,163,74,.08);border:1px solid rgba(22,163,74,.2);color:#166534}
|
||
.al-red{background:rgba(220,38,38,.08);border:1px solid rgba(220,38,38,.2);color:#b91c1c}
|
||
.al-amber{background:rgba(217,119,6,.08);border:1px solid rgba(217,119,6,.2);color:#92400e}
|
||
|
||
.sp{width:14px;height:14px;border:2px solid rgba(255,255,255,.35);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:16px;right:16px;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:0 8px 28px rgba(15,23,42,.18);animation:sIn .22s ease;max-width:280px;pointer-events:auto}
|
||
.t-ok{background:var(--green);color:#fff}.t-err{background:var(--red);color:#fff}.t-warn{background:var(--amber);color:#fff}
|
||
@keyframes sIn{from{transform:translateX(110%);opacity:0}to{transform:translateX(0);opacity:1}}
|
||
|
||
.badge{display:inline-block;font-size:9px;font-weight:700;padding:2px 7px;border-radius:4px;text-transform:uppercase}
|
||
.badge-purple{background:rgba(124,58,237,.1);color:var(--purple);border:1px solid rgba(124,58,237,.25)}
|
||
.badge-green{background:rgba(22,163,74,.1);color:var(--green);border:1px solid rgba(22,163,74,.25)}
|
||
.badge-amber{background:rgba(217,119,6,.1);color:var(--amber);border:1px solid rgba(217,119,6,.25)}
|
||
.badge-blue{background:var(--blue-light);color:var(--blue);border:1px solid #bfdbfe}
|
||
.badge-red{background:rgba(220,38,38,.1);color:var(--red);border:1px solid rgba(220,38,38,.25)}
|
||
|
||
.sec-label{font-size:10.5px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:var(--text3);margin-bottom:10px;padding-bottom:5px;border-bottom:1px solid var(--border)}
|
||
|
||
.stk-table{width:100%;border-collapse:collapse;font-size:12px;margin-top:8px}
|
||
.stk-table th{padding:8px;text-align:left;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.3px;color:var(--text3);background:var(--surface3);border-bottom:1px solid var(--border)}
|
||
.stk-table td{padding:8px;border-bottom:1px solid var(--border);color:var(--text2);font-family:var(--mono)}
|
||
.stk-table tr.wh-row{cursor:pointer}
|
||
.stk-table tr.wh-row:hover td{background:var(--blue-light)}
|
||
.stk-table tr.wh-row.selected td{background:#dbeafe;color:var(--text)}
|
||
.stk-table tr.wh-row.is-default td{color:var(--text)}
|
||
.stk-table td.bold{font-weight:700;color:var(--text)}
|
||
.stk-table td.green{color:var(--green)}
|
||
.whse-btn-row{display:flex;justify-content:flex-end;margin-top:10px;gap:8px;align-items:center}
|
||
.whse-btn-row .default-label{font-size:11px;color:var(--text3);font-style:italic}
|
||
.tab-panel{display:none}.tab-panel.active{display:block}
|
||
.req{color:var(--red);margin-left:2px}
|
||
.cost-row{display:flex;align-items:center;justify-content:space-between;padding:8px 12px;background:var(--surface2);border:1px solid var(--border);border-radius:8px;margin-bottom:8px}
|
||
.cost-label{font-size:11px;color:var(--text3);font-weight:600;text-transform:uppercase;letter-spacing:.3px}
|
||
.cost-val{font-family:var(--mono);font-size:13px;color:var(--amber);font-weight:600}
|
||
.item-code-display{font-family:var(--mono);font-size:20px;color:var(--blue);font-weight:600;letter-spacing:1px}
|
||
.star-badge{display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;background:rgba(217,119,6,.12);border:1px solid rgba(217,119,6,.35);border-radius:4px;color:var(--amber);margin-left:4px;vertical-align:middle}
|
||
|
||
/* === AUTO-NUMBERING STYLES === */
|
||
.code-prefix{font-family:var(--mono);font-size:16px;color:var(--blue);font-weight:600;letter-spacing:1px;background:var(--blue-light);padding:6px 12px;border-radius:7px;border:1px solid #bfdbfe;display:inline-flex;align-items:center;min-width:60px;justify-content:center}
|
||
.code-input-wrap{display:flex;align-items:center;gap:8px;flex:1}
|
||
.code-input-wrap .fi{flex:1}
|
||
.code-meta{display:flex;align-items:center;gap:10px;margin-top:6px;font-size:11px}
|
||
.code-sequence{color:var(--amber);font-family:var(--mono);font-weight:600}
|
||
.code-status{color:var(--text3);font-style:italic}
|
||
.btn-refresh{padding:6px 10px;border-radius:7px;font-size:10px;background:var(--surface2);border:1px solid var(--border2);color:var(--text2);cursor:pointer;font-family:var(--font);display:inline-flex;align-items:center;gap:4px}.btn-refresh:hover{border-color:var(--blue);color:var(--blue)}
|
||
.btn-refresh:disabled{opacity:.4;cursor:not-allowed}
|
||
.btn-refresh i{width:12px;height:12px}
|
||
.btn-manual{padding:6px 10px;border-radius:7px;font-size:10px;background:var(--surface2);border:1px solid var(--border2);color:var(--blue);cursor:pointer;font-family:var(--font)}.btn-manual:hover{border-color:var(--blue)}
|
||
.btn-manual.on{background:var(--blue-light);border-color:var(--blue);color:var(--blue)}
|
||
.code-row{display:flex;align-items:center;gap:10px;margin-bottom:6px;flex-wrap:wrap}
|
||
.code-manual-hint{font-size:10px;color:var(--text3);margin-top:4px;font-style:italic}
|
||
|
||
/* === ITEM LIST STYLES === */
|
||
.list-toolbar{display:flex;gap:8px;margin-bottom:14px;align-items:center;flex-wrap:wrap}
|
||
.search-box{flex:1;min-width:200px;position:relative}
|
||
.search-box input{width:100%;padding:9px 12px 9px 34px;border:1.5px solid var(--border);border-radius:8px;background:var(--surface);color:var(--text);font-family:var(--font);font-size:13px;outline:none}
|
||
.search-box input:focus{border-color:var(--blue);box-shadow:0 0 0 3px rgba(37,99,235,.12)}
|
||
.search-box svg{position:absolute;left:11px;top:50%;transform:translateY(-50%);width:14px;height:14px;color:var(--text4);pointer-events:none}
|
||
.filter-chip{padding:5px 11px;border-radius:7px;font-size:10.5px;font-weight:600;background:var(--surface2);border:1px solid var(--border2);color:var(--text2);cursor:pointer;font-family:var(--font)}
|
||
.filter-chip.on{background:var(--blue-light);color:var(--blue);border-color:#bfdbfe}
|
||
/* Item tile grid — same vocabulary/values as Production Order's po-grid/
|
||
po-card.tile, so the browse screens across both modules match exactly. */
|
||
.item-list{min-height:1px}
|
||
.po-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:10px}
|
||
.po-card{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:12px;cursor:pointer;transition:all .15s;box-shadow:var(--shadow)}
|
||
.po-card:hover{border-color:var(--blue);box-shadow:0 4px 16px rgba(37,99,235,.12);transform:translateY(-1px)}
|
||
.po-card.tile{aspect-ratio:1/1;display:flex;flex-direction:column;overflow:hidden;padding:12px}
|
||
.po-card.active{border-color:var(--blue);box-shadow:0 0 0 3px rgba(37,99,235,.12)}
|
||
.po-top{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:4px}
|
||
.po-code{font-family:var(--mono);font-size:11px;color:var(--blue);font-weight:700}
|
||
.po-badge{font-size:9px;font-weight:700;padding:2px 7px;border-radius:4px;text-transform:uppercase;flex-shrink:0}
|
||
.po-badge.active{background:rgba(22,163,74,.1);color:var(--green);border:1px solid rgba(22,163,74,.25)}
|
||
.po-badge.inactive{background:rgba(220,38,38,.1);color:var(--red);border:1px solid rgba(220,38,38,.25)}
|
||
.po-icon{width:28px;height:28px;border-radius:7px;background:var(--blue-light);color:var(--blue);display:flex;align-items:center;justify-content:center;margin:4px 0}
|
||
.po-icon svg{width:15px;height:15px}
|
||
.po-name{font-size:11.5px;line-height:1.3;color:var(--text);font-weight:600;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
|
||
.po-badges{display:flex;gap:3px;flex-wrap:wrap;margin-top:4px}
|
||
.po-item-badge{font-size:9px;padding:1px 6px;border-radius:4px;background:var(--surface3);color:var(--text3);border:1px solid var(--border)}
|
||
.po-meta{margin-top:auto;padding-top:6px;border-top:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;gap:6px}
|
||
.po-actions{display:flex;gap:4px}
|
||
.btn-icon{padding:4px 6px;border-radius:6px;background:var(--surface2);border:1px solid var(--border2);color:var(--text2);cursor:pointer;display:inline-flex;align-items:center;justify-content:center}.btn-icon:hover{color:var(--blue);border-color:var(--blue)}
|
||
.btn-icon svg{width:12px;height:12px}
|
||
.empty-state{text-align:center;padding:44px 20px;color:var(--text3);font-size:13px;grid-column:1/-1}
|
||
.empty-state .big{width:34px;height:34px;margin:0 auto 10px;display:block;color:var(--text4)}
|
||
.pagination{display:flex;justify-content:center;gap:8px;margin-top:14px;align-items:center}
|
||
.pagination button{padding:5px 12px;border-radius:7px;font-size:11px;background:var(--surface);border:1px solid var(--border2);color:var(--text2);cursor:pointer;font-family:var(--font)}.pagination button:disabled{opacity:.3;cursor:not-allowed}.pagination button:hover:not(:disabled){border-color:var(--blue);color:var(--blue)}
|
||
.pagination .page-info{font-size:11px;color:var(--text3)}
|
||
.mode-badge{position:fixed;top:16px;left:16px;z-index:100;padding:5px 11px;border-radius:7px;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:1px;background:var(--surface);border:1px solid var(--border);color:var(--text2);box-shadow:var(--shadow)}
|
||
.mode-badge.create{border-color:var(--green);color:var(--green)}
|
||
.mode-badge.edit{border-color:var(--amber);color:var(--amber)}
|
||
.mode-badge.view{border-color:var(--blue);color:var(--blue)}
|
||
.readonly-field{background:var(--surface3)!important;color:var(--text3)!important;cursor:not-allowed!important;border-color:var(--border)!important}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="toast-c" id="toast-c"></div>
|
||
<div class="mode-badge create" id="mode-badge">Create Mode</div>
|
||
|
||
<nav></nav>
|
||
|
||
<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';
|
||
let _tok = null, _user = null;
|
||
const BACKEND = '/api';
|
||
(function(){try{_tok=sessionStorage.getItem('portal_token');const u=sessionStorage.getItem('portal_user');if(u)_user=JSON.parse(u);}catch{}})();
|
||
if(!_tok) window.location.href='/';
|
||
|
||
let _co = window.__DEFAULT_COMPANY__;
|
||
let _companies=[{value:_co,label:_co}]; // live list — populated in init()
|
||
function coOptions(sel){return _companies.map(c=>`<option value="${esc(c.value)}" ${sel===c.value?'selected':''}>${esc(c.label)}</option>`).join('');}
|
||
let _mode = new URLSearchParams(window.location.search).get('list')==='1' ? 'list' : 'create';
|
||
let _activeTab = 'general';
|
||
let _saving = false;
|
||
let _itemGroups = [], _uomGroups = [], _wh = [], _priceLists = [];
|
||
let _selectedWhRow = null;
|
||
let _currentItemCode = null;
|
||
let _itemList = [];
|
||
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 },
|
||
rm: { prefix: 'RM', label: 'Raw Material', groupName: 'RAW MATERIAL', materialType: 'mt_RawMaterial', hasSeries: true, defaultManual: false, padLen: 5 },
|
||
pk: { prefix: 'PK', label: 'Packing Material', groupName: 'PACKING MATERIAL', materialType: 'mt_RawMaterial', hasSeries: true, defaultManual: false, padLen: 5 },
|
||
ico: { prefix: 'ICO', label: 'Consumable/ICO', groupName: 'PROD-CONSUMABLE', materialType: 'mt_RawMaterial', hasSeries: true, defaultManual: false, padLen: 5 },
|
||
custom: { prefix: '', label: 'Custom / Other', groupName: '', materialType: 'mt_FinishedGoods', hasSeries: false, defaultManual: true, padLen: 6 },
|
||
};
|
||
|
||
let _selectedTypeKey = 'finished';
|
||
let _lastSequence = null;
|
||
let _isManualMode = false;
|
||
let _sequenceError = null;
|
||
// Live duplicate-code check — debounced as the user types a manual code (or
|
||
// after the auto-generated one loads, as a final defense-in-depth). Never
|
||
// trust this alone at submit time (see doSave()) — it's also re-checked
|
||
// synchronously right before the POST, since the debounced result here can
|
||
// be stale by the time Save is actually clicked.
|
||
let _codeExists = false;
|
||
let _codeCheckedFor = ''; // which code _codeExists reflects, so a stale check never shows against the wrong value
|
||
let _codeCheckTimer = null;
|
||
|
||
let _form = defaultForm();
|
||
|
||
function defaultForm(){
|
||
return {
|
||
ItemCode: '', ItemName: '', ForeignName: '',
|
||
ItemType: 'itItems',
|
||
ItemsGroupCode: '',
|
||
UoMGroupEntry: -1,
|
||
PriceList: 1,
|
||
InventoryItem: 'tYES', SalesItem: 'tYES', PurchaseItem: 'tYES',
|
||
ManageSerialNumbers: 'tNO',
|
||
ManageBatchNumbers: 'tNO',
|
||
SRIAndBatchManageMethod: 'bomm_OnEveryTransaction',
|
||
ItemCategory: 'Material',
|
||
ItemClass: 'itcMaterial',
|
||
MaterialType: 'mt_FinishedGoods',
|
||
ProductSource: '0',
|
||
GSTRelevant: 'tNO',
|
||
TaxCategory: '',
|
||
GSTTaxCategory: '',
|
||
Excisable: 'tNO',
|
||
Frozen: 'tNO',
|
||
PlanningSystem: 'bop_None', ProcurementMethod: 'bom_Buy',
|
||
ComponentWarehouse: 'bomcw_BOM',
|
||
OrderIntervals: null, OrderMultiple: 0, MinimumOrderQuantity: 0,
|
||
IssueMethod: 'im_Manual',
|
||
ProductionStandardCost: 0,
|
||
IncludeInStdCostRollup: 'tYES',
|
||
HsnCode: '',
|
||
InventoryUOM: '',
|
||
GLMethod: 'glm_ItemClass',
|
||
ValuationMethod: 'bis_MovingAverage',
|
||
UserText: '',
|
||
DefaultWarehouse: '',
|
||
AssessableValue: 0,
|
||
LeadTime: null,
|
||
PurchaseUnit: '', SalesUnit: '',
|
||
PurchaseQtyPerPackage: 0, PurchaseItemsPerUnit: 0,
|
||
SalesQtyPerPackage: 0, SalesMinimumOrderQuantity: 0,
|
||
WarrantyTemplate: '',
|
||
DefaultVendor: '',
|
||
LeadTimeDays: null,
|
||
WTLiable: 'tNO',
|
||
NoDiscounts: 'tNO',
|
||
TreeType: 'iNotATree',
|
||
AssetItem: 'tNO',
|
||
TaxType: 'tt_Yes',
|
||
Valid: 'tYES',
|
||
ManageStockByWarehouse: 'tNO',
|
||
InCostRollup: 'tYES',
|
||
DesiredInventory: 0,
|
||
MinimumInventory: 0,
|
||
MaximumQuantityInWarehouse: 0,
|
||
Weight1: 0,
|
||
ToleranceDays: 0,
|
||
ArTaxCode: '', ApTaxCode: '',
|
||
SalesRevenueAccount: '', PurchaseAccount: '',
|
||
ShippingType: '',
|
||
BarCode: '', AdditionalIdentifier: '',
|
||
};
|
||
}
|
||
|
||
function today(){return new Date().toISOString().slice(0,10);}
|
||
function esc(v){return(v||'').toString().replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
|
||
function q(s){return document.querySelector(s);}
|
||
|
||
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(BACKEND+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'?'✓ ':type==='err'?'✗ ':'⚠ ')+msg;
|
||
tc.appendChild(el);setTimeout(()=>el.remove(),4000);
|
||
}
|
||
|
||
function normalizeWh(raw){
|
||
const code = raw.WarehouseCode || raw.WhouseCode || raw.warehouseCode || raw.WH_Code || raw.whCode || raw.Code || raw.code || '';
|
||
const name = raw.WarehouseName || raw.WhouseName || raw.warehouseName || raw.WH_Name || raw.whName || raw.Name || raw.name || '';
|
||
const branch = raw.Branch || raw.branch || raw.BranchName || '';
|
||
const locked = raw.Locked === 'tYES' || raw.Locked === true || raw.locked === true;
|
||
const onHand = raw.InStock || raw.OnHand || raw.onHand || raw.in_stock || 0;
|
||
const committed = raw.IsCommited || raw.Committed || raw.committed || raw.IsCommitted || 0;
|
||
const onOrder = raw.OnOrder || raw.onOrder || raw.on_order || 0;
|
||
return {code, name, branch, locked, onHand, committed, onOrder};
|
||
}
|
||
|
||
function _apiTimeout(ms){ return new Promise((_,r)=>setTimeout(()=>r(new Error('timeout')),ms)); }
|
||
|
||
async function loadLookups(){
|
||
try{
|
||
const [grpR, whR] = await Promise.all([
|
||
Promise.race([api('GET',`/sap/lookup/item-groups?company=${encodeURIComponent(_co)}`), _apiTimeout(5000)]).catch(()=>({data:[]})),
|
||
Promise.race([api('GET',`/sap/lookup/warehouses?company=${encodeURIComponent(_co)}`), _apiTimeout(5000)]).catch(()=>({data:[]}))
|
||
]);
|
||
_itemGroups = grpR.data||[];
|
||
const rawWh = whR.data||[];
|
||
_wh = rawWh.map(normalizeWh);
|
||
}catch(e){ console.error('[loadLookups]', e); }
|
||
}
|
||
|
||
const ITEM_GROUPS_DEMO = [
|
||
'FINISH GOODS','FINISH GOODS STENT','FINISH GOODS TT DEVICE',
|
||
'FIXED ASSETS','FURNITURE & FIXTURES','GENERAL','HOUSEKEEPING',
|
||
'IT ASSETS','IT CONSUMABLES','Items','MACHINE RAW MATERIAL',
|
||
'MACHINE SPARE PARTS','MACHINE TOOLS','MAINTENANCE','PACKING MATERIAL',
|
||
'PACKING MATERIAL BB EQ','PACKING MATERIAL STENT','PART',
|
||
'PRINTING & STATIONERY','PROD-CONSUMABLE','PRODUCTION TOOLS',
|
||
'RAW MATERIAL','RAW MATERIAL BB EQ','RAW MATERIAL CLIA',
|
||
'RAW MATERIAL STENT','SCRAP GOODS'
|
||
];
|
||
|
||
const WH_DEMO = [
|
||
{code:'01', name:'RAW MATERIAL', branch:'', locked:false, onHand:0, committed:0, onOrder:0},
|
||
{code:'02', name:'PACKING MATERIAL',branch:'', locked:false, onHand:0, committed:0, onOrder:0},
|
||
{code:'03', name:'REJECTION', branch:'', locked:false, onHand:0, committed:0, onOrder:0},
|
||
{code:'04', name:'QC SAMPLES', branch:'', locked:false, onHand:71, committed:0, onOrder:0},
|
||
{code:'05', name:'QA', branch:'', locked:false, onHand:40, committed:0, onOrder:0},
|
||
{code:'06', name:'CONSUMABLE', branch:'', locked:false, onHand:0, committed:0, onOrder:0},
|
||
];
|
||
|
||
const ITEM_TYPES = [{v:'itItems',l:'Items'},{v:'itLabor',l:'Labor'},{v:'itTravel',l:'Travel'},{v:'itFixedAssets',l:'Fixed Assets'},{v:'itService',l:'Service'}];
|
||
const ISSUE_METHODS = [{v:'im_Manual',l:'Manual'},{v:'im_Backflush',l:'Backflush'}];
|
||
const PLAN_SYSTEMS = [{v:'bop_None',l:'None'},{v:'bop_MRP',l:'MRP'}];
|
||
const PROC_METHODS = [{v:'bom_Buy',l:'Buy'},{v:'bom_Make',l:'Make'}];
|
||
const VALUATION_METHODS = [{v:'bis_MovingAverage',l:'Moving Average'},{v:'bis_FIFO',l:'FIFO'},{v:'bis_Standard',l:'Standard'}];
|
||
const TAX_CATEGORIES = [{v:'gtc_Regular',l:'Regular'},{v:'gtc_Exempt',l:'Exempt'},{v:'gtc_ZeroRated',l:'Zero Rated'}];
|
||
const MATERIAL_TYPES = [{v:'mt_FinishedGoods',l:'Finished Goods'},{v:'mt_RawMaterial',l:'Raw Material'},{v:'mt_SemiFinishedGoods',l:'Semi-Finished'}];
|
||
|
||
// ════════════════════════════════════════════════════════════════
|
||
// AUTO-NUMBERING ENGINE
|
||
// ════════════════════════════════════════════════════════════════
|
||
async function fetchLastSequence(prefix) {
|
||
_sequenceError = null;
|
||
if (!prefix) return 0;
|
||
try {
|
||
const res = await api('GET', `/sap/lookup/last-item-code?prefix=${encodeURIComponent(prefix)}&company=${encodeURIComponent(_co)}`);
|
||
if (res.success && res.lastNumber !== undefined && res.lastNumber !== null) {
|
||
const num = parseInt(res.lastNumber);
|
||
_lastSequence = isNaN(num) ? 0 : num;
|
||
return _lastSequence;
|
||
}
|
||
if (res.warning) _sequenceError = res.warning;
|
||
_lastSequence = 0;
|
||
return 0;
|
||
} catch (e) {
|
||
_sequenceError = e.message;
|
||
_lastSequence = 0;
|
||
return 0;
|
||
}
|
||
}
|
||
|
||
// Debounced live duplicate check — patches just the code-meta line (never a
|
||
// full render(), so it never steals focus while the user is still typing).
|
||
function scheduleCodeExistsCheck() {
|
||
clearTimeout(_codeCheckTimer);
|
||
const code = (_form.ItemCode || '').trim().toUpperCase();
|
||
if (!code) { _codeExists = false; _codeCheckedFor = ''; patchCodeMeta(); return; }
|
||
_codeCheckTimer = setTimeout(async () => {
|
||
try {
|
||
const r = await api('GET', `/sap/lookup/item-code-exists?code=${encodeURIComponent(code)}&company=${encodeURIComponent(_co)}`);
|
||
_codeExists = !!(r && r.exists);
|
||
_codeCheckedFor = code;
|
||
} catch (_e) { /* fail open — doSave() re-checks synchronously anyway */ }
|
||
patchCodeMeta();
|
||
}, 350);
|
||
}
|
||
function patchCodeMeta() {
|
||
const el = q('#code-meta');
|
||
if (el) el.innerHTML = codeMetaHtml();
|
||
}
|
||
function codeMetaHtml() {
|
||
const config = ITEM_TYPE_CONFIG[_selectedTypeKey];
|
||
const isAuto = !(_isManualMode || !config.hasSeries);
|
||
const code = (_form.ItemCode || '').trim().toUpperCase();
|
||
const dupWarning = (_codeCheckedFor && _codeCheckedFor === code && _codeExists)
|
||
? `<span class="code-status" style="color:var(--red)">⚠ Item code "${esc(code)}" already exists — choose a different one</span>` : '';
|
||
return `
|
||
${_mode==='create' && isAuto && _lastSequence !== null ? `<span class="code-sequence">Last: ${_lastSequence} → Next: ${_form.ItemCode || '—'}</span>` : ''}
|
||
${_mode!=='create' ? `<span class="code-status">Item Code cannot be changed</span>` : ''}
|
||
${_sequenceError ? `<span class="code-status" style="color:var(--red)">⚠ ${_sequenceError}</span>` : ''}
|
||
${dupWarning}
|
||
`;
|
||
}
|
||
|
||
function generateNextCode(prefix, lastNum, padLen) {
|
||
const nextNum = (lastNum || 0) + 1;
|
||
const pad = (typeof padLen === 'number' && padLen > 0) ? padLen : 6;
|
||
const sequence = String(nextNum).padStart(pad, '0');
|
||
return `${prefix}${sequence}`;
|
||
}
|
||
|
||
async function refreshItemCode(forceManual) {
|
||
const config = ITEM_TYPE_CONFIG[_selectedTypeKey];
|
||
if (!config) return;
|
||
if (forceManual !== undefined) _isManualMode = forceManual;
|
||
else if (config.defaultManual) _isManualMode = true;
|
||
else _isManualMode = false;
|
||
|
||
if (_isManualMode || !config.hasSeries) {
|
||
_form.ItemCode = '';
|
||
_lastSequence = null;
|
||
render();
|
||
return;
|
||
}
|
||
await fetchLastSequence(config.prefix);
|
||
const nextCode = generateNextCode(config.prefix, _lastSequence, config.padLen);
|
||
_form.ItemCode = nextCode;
|
||
_form.MaterialType = config.materialType;
|
||
const matchingGroup = _itemGroups.find(g => (g.name || '').toUpperCase().includes(config.groupName));
|
||
if (matchingGroup) _form.ItemsGroupCode = matchingGroup.code;
|
||
render();
|
||
toast(`Auto-generated: ${nextCode}`, 'ok');
|
||
scheduleCodeExistsCheck();
|
||
}
|
||
|
||
function toggleManualMode() {
|
||
_isManualMode = !_isManualMode;
|
||
if (_isManualMode) {
|
||
_form.ItemCode = '';
|
||
_lastSequence = null;
|
||
_sequenceError = null;
|
||
render();
|
||
} else {
|
||
refreshItemCode(false);
|
||
}
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════
|
||
// NAVIGATION HELPERS
|
||
// ════════════════════════════════════════════════════════════════
|
||
function goToList() {
|
||
_mode = 'list';
|
||
_currentItemCode = null;
|
||
const badge = q('#mode-badge');
|
||
if (badge) { badge.className = 'mode-badge'; badge.textContent = 'Browse Mode'; }
|
||
// Clear app so toolbar rebuilds from scratch
|
||
const app = q('#app');
|
||
if(app) app.innerHTML = '';
|
||
loadItemList();
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════
|
||
// MODE SWITCHING
|
||
// ════════════════════════════════════════════════════════════════
|
||
function setMode(mode, itemCode=null){
|
||
_mode = mode;
|
||
_currentItemCode = itemCode;
|
||
const badge = q('#mode-badge');
|
||
if(badge){
|
||
badge.className = `mode-badge ${mode}`;
|
||
badge.textContent = mode==='create'?'Create Mode':mode==='edit'?'Edit Mode':'View Mode';
|
||
}
|
||
if(mode==='create'){
|
||
_form = defaultForm();
|
||
_selectedWhRow = null;
|
||
_selectedTypeKey = 'finished';
|
||
_lastSequence = null;
|
||
_isManualMode = false;
|
||
_sequenceError = null;
|
||
_activeTab = 'general';
|
||
render();
|
||
} else if(itemCode){
|
||
loadItemForEdit(itemCode, mode==='view', mode==='duplicate');
|
||
}
|
||
}
|
||
|
||
// 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)}`);
|
||
if(!res.success) throw new Error(res.message||'Item not found');
|
||
const d = res.data;
|
||
|
||
_form = {
|
||
...defaultForm(),
|
||
ItemCode: d.ItemCode||'',
|
||
ItemName: d.ItemName||'',
|
||
ForeignName: d.ForeignName||'',
|
||
ItemType: d.ItemType||'itItems',
|
||
ItemsGroupCode: d.ItemsGroupCode!=null?String(d.ItemsGroupCode):'',
|
||
UoMGroupEntry: d.UoMGroupEntry||-1,
|
||
InventoryItem: d.InventoryItem||'tYES',
|
||
SalesItem: d.SalesItem||'tYES',
|
||
PurchaseItem: d.PurchaseItem||'tYES',
|
||
ManageSerialNumbers: d.ManageSerialNumbers||'tNO',
|
||
ManageBatchNumbers: d.ManageBatchNumbers||'tNO',
|
||
SRIAndBatchManageMethod: d.SRIAndBatchManageMethod||'bomm_OnEveryTransaction',
|
||
ItemCategory: d.ItemClass==='itcService'?'Service':'Material',
|
||
ItemClass: d.ItemClass||'itcMaterial',
|
||
MaterialType: d.MaterialType||'mt_FinishedGoods',
|
||
ProductSource: d.ProductSource||'0',
|
||
GSTRelevant: d.GSTRelevnt||d.GSTRelevant||'tNO',
|
||
GSTTaxCategory: d.GSTTaxCategory||'',
|
||
TaxCategory: d.GSTTaxCategory||'',
|
||
Excisable: d.Excisable||'tNO',
|
||
Frozen: d.Frozen||'tNO',
|
||
PlanningSystem: d.PlanningSystem||'bop_None',
|
||
ProcurementMethod: d.ProcurementMethod||'bom_Buy',
|
||
ComponentWarehouse: d.ComponentWarehouse||'bomcw_BOM',
|
||
IssueMethod: d.IssueMethod||'im_Manual',
|
||
ProductionStandardCost: d.ProdStdCost||0,
|
||
IncludeInStdCostRollup: d.InCostRollup||'tYES',
|
||
HsnCode: d.ChapterID!=null?String(d.ChapterID):'',
|
||
InventoryUOM: d.InventoryUOM||'',
|
||
GLMethod: d.GLMethod||'glm_ItemClass',
|
||
ValuationMethod: d.CostAccountingMethod||'bis_MovingAverage',
|
||
UserText: d.User_Text||d.UserText||'',
|
||
DefaultWarehouse: d.DefaultWarehouse||'',
|
||
AssessableValue: d.AssessableValue||0,
|
||
LeadTime: d.LeadTime||null,
|
||
PurchaseUnit: d.PurchaseUnit||'',
|
||
SalesUnit: d.SalesUnit||'',
|
||
PurchaseQtyPerPackage: d.PurchaseQtyPerPackUnit||0,
|
||
PurchaseItemsPerUnit: d.PurchaseItemsPerUnit||0,
|
||
SalesQtyPerPackage: d.SalesQtyPerPackUnit||0,
|
||
SalesMinimumOrderQuantity: d.MinInventory||0,
|
||
WarrantyTemplate: d.WarrantyTemplate||'',
|
||
DefaultVendor: d.Mainsupplier||'',
|
||
LeadTimeDays: d.LeadTime||null,
|
||
WTLiable: d.WTLiable||'tNO',
|
||
NoDiscounts: d.NoDiscounts||'tNO',
|
||
TreeType: d.TreeType||'iNotATree',
|
||
AssetItem: d.AssetItem||'tNO',
|
||
TaxType: d.TaxType||'tt_Yes',
|
||
Valid: d.Valid||'tYES',
|
||
ManageStockByWarehouse: d.ManageStockByWarehouse||'tNO',
|
||
InCostRollup: d.InCostRollup||'tYES',
|
||
DesiredInventory: d.DesiredInventory||0,
|
||
MinimumInventory: d.MinInventory||0,
|
||
MaximumQuantityInWarehouse: d.MaxInventory||0,
|
||
Weight1: d.InventoryWeight||0,
|
||
ToleranceDays: d.ToleranceDays||0,
|
||
ArTaxCode: d.ArTaxCode||'',
|
||
ApTaxCode: d.ApTaxCode||'',
|
||
SalesRevenueAccount: d.IncomeAccount||'',
|
||
PurchaseAccount: d.ExpanseAccount||'',
|
||
ShippingType: d.ShipType!=null?String(d.ShipType):'',
|
||
BarCode: d.BarCode||'',
|
||
AdditionalIdentifier: d.SWW||'',
|
||
};
|
||
|
||
// 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){
|
||
toast(e.message,'err');
|
||
goToList();
|
||
}
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════
|
||
// ITEM LIST
|
||
// ════════════════════════════════════════════════════════════════
|
||
async function loadItemList(){
|
||
try{
|
||
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) {
|
||
const len = searchEl.value.length;
|
||
searchEl.focus();
|
||
searchEl.setSelectionRange(len, len);
|
||
}
|
||
}catch(e){
|
||
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');
|
||
|
||
// Only build the card shell once — if it already exists, just update the list
|
||
if(!q('#list-toolbar-root')){
|
||
app.innerHTML = '';
|
||
|
||
const c = document.createElement('div');
|
||
c.className = 'card';
|
||
c.innerHTML = `<div class="card-hd"><span class="ico"><i data-lucide="package" style="width:16px;height:16px"></i></span> Item Master Data
|
||
<span class="right" style="display:flex;align-items:center;gap:8px">
|
||
<select class="fi" id="sel-list-co" style="height:28px;font-size:11px;width:160px;padding:2px 22px 2px 8px">
|
||
${coOptions(_co)}
|
||
</select>
|
||
</span>
|
||
</div>
|
||
<div class="card-bd">
|
||
<div class="list-toolbar" id="list-toolbar-root">
|
||
<div class="search-box">
|
||
<i data-lucide="search" style="width:14px;height:14px"></i>
|
||
<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>`;
|
||
app.appendChild(c);
|
||
if(window.lucide&&lucide.createIcons)lucide.createIcons();
|
||
|
||
q('#sel-list-co').onchange = function(){_co=this.value;_listSkip=0;loadItemList();};
|
||
q('#list-search').oninput = function(e){
|
||
_listQ = e.target.value;
|
||
_listSkip = 0;
|
||
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');
|
||
}
|
||
|
||
renderListItems();
|
||
renderListPagination();
|
||
}
|
||
|
||
function renderListItems(){
|
||
const listContainer = q('#list-container');
|
||
if(!listContainer) return;
|
||
|
||
if(!_itemList.length){
|
||
listContainer.innerHTML = `<div class="po-grid"><div class="empty-state"><i data-lucide="package-search" class="big"></i>No items found</div></div>`;
|
||
if(window.lucide&&lucide.createIcons)lucide.createIcons();
|
||
return;
|
||
}
|
||
|
||
listContainer.innerHTML = `<div class="po-grid">${_itemList.map(item=>{
|
||
const isActive = item.ItemCode === _currentItemCode;
|
||
const statusCls = item.Frozen==='tYES' ? 'inactive' : 'active';
|
||
const statusLbl = item.Frozen==='tYES' ? 'Inactive' : 'Active';
|
||
return `<div class="po-card tile${isActive?' active':''}" data-code="${esc(item.ItemCode)}">
|
||
<div class="po-top"><span class="po-code">${esc(item.ItemCode)}</span><span class="po-badge ${statusCls}">${statusLbl}</span></div>
|
||
<div class="po-icon"><i data-lucide="package"></i></div>
|
||
<div class="po-name" title="${esc(item.ItemName||'')}">${esc(item.ItemName||'—')}</div>
|
||
<div class="po-badges">
|
||
<span class="po-item-badge">${esc(item.InventoryUOM||'PCS')}</span>
|
||
<span class="po-item-badge">${esc(item.DefaultWarehouse||'—')}</span>
|
||
</div>
|
||
<div class="po-meta">
|
||
<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>`;
|
||
}).join('')}</div>`;
|
||
|
||
listContainer.querySelectorAll('.po-card').forEach(card=>{
|
||
card.onclick = ()=>setMode('view', card.dataset.code);
|
||
});
|
||
listContainer.querySelectorAll('[data-action]').forEach(btn=>{
|
||
btn.onclick = (e)=>{
|
||
e.stopPropagation();
|
||
const code = btn.dataset.code;
|
||
const action = btn.dataset.action;
|
||
setMode(action, code);
|
||
};
|
||
});
|
||
if(window.lucide&&lucide.createIcons)lucide.createIcons();
|
||
}
|
||
|
||
function renderListPagination(){
|
||
const pagination = q('#list-pagination');
|
||
if(!pagination) return;
|
||
|
||
const hasMore = _itemList.length === _listTop;
|
||
pagination.innerHTML = `
|
||
<button ${_listSkip===0?'disabled':''} id="btn-prev">← Prev</button>
|
||
<span class="page-info">${_listSkip+1} – ${_listSkip+_itemList.length}</span>
|
||
<button ${!hasMore?'disabled':''} id="btn-next">Next →</button>
|
||
`;
|
||
|
||
q('#btn-prev').onclick = ()=>{ if(_listSkip>=_listTop){_listSkip-=_listTop;loadItemList();}};
|
||
q('#btn-next').onclick = ()=>{ if(hasMore){_listSkip+=_listTop;loadItemList();}};
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════
|
||
// MAIN RENDER
|
||
// ════════════════════════════════════════════════════════════════
|
||
function render(){
|
||
const app = q('#app');
|
||
if(!app) return;
|
||
|
||
// FIX: only redirect to list if mode is explicitly 'list'
|
||
if(_mode === 'list'){
|
||
loadItemList();
|
||
return;
|
||
}
|
||
|
||
app.innerHTML = '';
|
||
|
||
// Back button bar for edit/view
|
||
if(_mode !== 'create'){
|
||
const backBar = document.createElement('div');
|
||
backBar.style.cssText = 'margin-bottom:12px;display:flex;gap:8px;align-items:center';
|
||
backBar.innerHTML = `
|
||
<button class="btn b-cancel" id="btn-back-list-top"><i data-lucide="arrow-left" style="width:14px;height:14px"></i> Back to List</button>
|
||
<span style="font-size:12px;color:var(--text3)">Item: <strong style="color:var(--text);font-family:var(--mono)">${esc(_form.ItemCode)}</strong></span>
|
||
`;
|
||
app.appendChild(backBar);
|
||
q('#btn-back-list-top').onclick = goToList;
|
||
}
|
||
|
||
const config = ITEM_TYPE_CONFIG[_selectedTypeKey] || ITEM_TYPE_CONFIG.finished;
|
||
const isAuto = !(_isManualMode || !config.hasSeries);
|
||
const prefixDisplay = config.prefix || '—';
|
||
const isReadonly = _mode === 'view';
|
||
|
||
const hdr = document.createElement('div');
|
||
hdr.className = 'card';
|
||
hdr.innerHTML = `<div class="card-hd"><span class="ico"><i data-lucide="id-card" style="width:16px;height:16px"></i></span> Item Master Data
|
||
<span class="right" style="display:flex;align-items:center;gap:8px">
|
||
<select class="fi" id="sel-co" style="height:28px;font-size:11px;width:160px;padding:2px 22px 2px 8px">
|
||
${coOptions(_co)}
|
||
</select>
|
||
${_mode==='edit'?'<span class="badge badge-amber">Editing</span>':''}
|
||
${_mode==='view'?'<span class="badge badge-blue">View Only</span>':''}
|
||
</span>
|
||
</div>
|
||
<div class="card-bd">
|
||
<div class="fi-row">
|
||
<div class="fg">
|
||
<label class="fl">Item Category <span class="req">*</span></label>
|
||
<select class="fi ${isReadonly?'readonly-field':''}" id="sel-cat-type" ${isReadonly?'disabled':''}>
|
||
${Object.entries(ITEM_TYPE_CONFIG).map(([key, cfg]) =>
|
||
`<option value="${key}" ${_selectedTypeKey===key?'selected':''}>${cfg.label}</option>`
|
||
).join('')}
|
||
</select>
|
||
</div>
|
||
<div class="fg">
|
||
<label class="fl">Item Type (SAP)</label>
|
||
<select class="fi ${isReadonly?'readonly-field':''}" id="sel-type" ${isReadonly?'disabled':''}>
|
||
${ITEM_TYPES.map(t=>`<option value="${t.v}" ${_form.ItemType===t.v?'selected':''}>${t.l}</option>`).join('')}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="fg">
|
||
<label class="fl">Item Code <span class="req">*</span></label>
|
||
<div class="code-row">
|
||
<span class="code-prefix">${prefixDisplay}</span>
|
||
<div class="code-input-wrap">
|
||
<input class="fi fi-mono ${_mode==='create' && isAuto?'readonly-field':''}" id="inp-code"
|
||
placeholder="${_mode==='create'?(isAuto ? 'Auto-generated...' : 'Type item code...'):''}}"
|
||
value="${esc(_form.ItemCode)}" autocomplete="off" maxlength="50"
|
||
${_mode==='create' && isAuto ? 'readonly style="background:var(--ink2);color:var(--amber);font-weight:600"' : ''}
|
||
${_mode!=='create'?'readonly style="background:var(--ink2);color:var(--amber);font-weight:600"':''}/>
|
||
</div>
|
||
${_mode==='create' ? `
|
||
<button class="btn-refresh" id="btn-refresh-code" title="Generate next number" ${isAuto ? '' : 'disabled'}>
|
||
↻ Refresh
|
||
</button>
|
||
<button class="btn-manual ${_isManualMode ? 'on' : ''}" id="btn-toggle-manual" title="Toggle manual/auto">
|
||
${_isManualMode ? 'Auto' : '✎ Manual'}
|
||
</button>
|
||
` : ''}
|
||
</div>
|
||
|
||
<div class="code-meta" id="code-meta">${codeMetaHtml()}</div>
|
||
</div>
|
||
|
||
<div class="fg">
|
||
<label class="fl">Description <span class="req">*</span></label>
|
||
<input class="fi ${isReadonly?'readonly-field':''}" id="inp-name" placeholder="Item description..." value="${esc(_form.ItemName)}" ${isReadonly?'readonly':''}/>
|
||
</div>
|
||
<div class="fi-row">
|
||
<div class="fg">
|
||
<label class="fl">Item Group</label>
|
||
<select class="fi ${isReadonly?'readonly-field':''}" id="sel-group" ${isReadonly?'disabled':''}>
|
||
<option value="">- Select Group -</option>
|
||
${(_itemGroups.length?_itemGroups:ITEM_GROUPS_DEMO.map(name=>({code:'',name}))).map(g=>`<option value="${esc(g.code)}" ${String(_form.ItemsGroupCode)===String(g.code)?'selected':''}>${esc(g.name)}</option>`).join('')}
|
||
</select>
|
||
</div>
|
||
<div class="fg">
|
||
<label class="fl">Material Type</label>
|
||
<select class="fi ${isReadonly?'readonly-field':''}" id="sel-mat-type" ${isReadonly?'disabled':''}>
|
||
<option value="">— Select —</option>
|
||
${MATERIAL_TYPES.map(m=>`<option value="${m.v}" ${_form.MaterialType===m.v?'selected':''}>${m.l}</option>`).join('')}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<hr class="divider"/>
|
||
<div class="sec-label">Item Flags</div>
|
||
<div class="chk-row">
|
||
<label class="chk-item"><input type="checkbox" id="chk-stock" ${_form.InventoryItem==='tYES'?'checked':''} ${isReadonly?'disabled':''}/> Stock Item</label>
|
||
<label class="chk-item"><input type="checkbox" id="chk-sales" ${_form.SalesItem==='tYES'?'checked':''} ${isReadonly?'disabled':''}/> Sales Item</label>
|
||
<label class="chk-item"><input type="checkbox" id="chk-purchase" ${_form.PurchaseItem==='tYES'?'checked':''} ${isReadonly?'disabled':''}/> Purchasing Item</label>
|
||
</div>
|
||
</div>`;
|
||
app.appendChild(hdr);
|
||
|
||
const tabs=[
|
||
{id:'general',l:'General'},
|
||
{id:'purchasing',l:'Purchasing'},
|
||
{id:'sales',l:'Sales'},
|
||
{id:'stock',l:'Stock Data'},
|
||
{id:'remarks',l:'Remarks'}
|
||
];
|
||
const tabBar = document.createElement('div');
|
||
tabBar.className = 'tabs';
|
||
tabs.forEach(t=>{
|
||
const b = document.createElement('button');
|
||
b.className = `tab${_activeTab===t.id?' on':''}`;
|
||
b.textContent = t.l; b.dataset.tab = t.id;
|
||
b.onclick = ()=>{ _activeTab=t.id; syncFormState(); renderTabContent(); renderTabButtons(); };
|
||
tabBar.appendChild(b);
|
||
});
|
||
app.appendChild(tabBar);
|
||
|
||
const tabContent = document.createElement('div');
|
||
tabContent.id = 'tab-content';
|
||
app.appendChild(tabContent);
|
||
|
||
// Action bar
|
||
const actions = document.createElement('div');
|
||
actions.className = 'card';
|
||
actions.id = 'action-card';
|
||
|
||
if(_mode==='create'){
|
||
actions.innerHTML = `<div class="card-bd"><div class="btn-bar">
|
||
<button class="btn b-primary" id="btn-save"><span id="sp-save" style="display:none" class="sp"></span> Add Item</button>
|
||
<button class="btn b-cancel" id="btn-reset">Reset</button>
|
||
<button class="btn b-view" id="btn-view-list">View Items</button>
|
||
</div></div>`;
|
||
} 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">⧉ 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>`;
|
||
} else {
|
||
// 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">⧉ Duplicate</button>
|
||
<button class="btn b-cancel" id="btn-view-list">Back to List</button>
|
||
</div></div>`;
|
||
}
|
||
app.appendChild(actions);
|
||
|
||
renderTabContent();
|
||
wireHeaderEvents();
|
||
}
|
||
|
||
function renderTabButtons(){
|
||
document.querySelectorAll('.tab').forEach(b=>{
|
||
b.classList.toggle('on', b.dataset.tab===_activeTab);
|
||
});
|
||
}
|
||
|
||
function renderTabContent(){
|
||
const tc = document.getElementById('tab-content');
|
||
if(!tc) return;
|
||
tc.innerHTML = '';
|
||
switch(_activeTab){
|
||
case 'general': renderGeneral(tc); break;
|
||
case 'purchasing': renderPurchasing(tc); break;
|
||
case 'sales': renderSales(tc); break;
|
||
case 'stock': renderStock(tc); break;
|
||
case 'remarks': renderRemarks(tc); break;
|
||
}
|
||
if(window.lucide&&lucide.createIcons)lucide.createIcons();
|
||
}
|
||
|
||
function renderGeneral(tc){
|
||
const isReadonly = _mode==='view';
|
||
const c = document.createElement('div'); c.className = 'card';
|
||
c.innerHTML = `<div class="card-hd"><span class="ico"><i data-lucide="info" style="width:16px;height:16px"></i></span> General Information</div>
|
||
<div class="card-bd">
|
||
<div class="sec-label">Serial & Batch</div>
|
||
<div class="fi-row">
|
||
<div class="fg"><label class="fl">Manage Serial Numbers</label>
|
||
<select class="fi ${isReadonly?'readonly-field':''}" id="g-serial" ${isReadonly?'disabled':''}>
|
||
<option value="tNO" ${_form.ManageSerialNumbers==='tNO'?'selected':''}>No</option>
|
||
<option value="tYES" ${_form.ManageSerialNumbers==='tYES'?'selected':''}>Yes</option>
|
||
</select>
|
||
</div>
|
||
<div class="fg"><label class="fl">Manage Batch Numbers</label>
|
||
<select class="fi ${isReadonly?'readonly-field':''}" id="g-batch" ${isReadonly?'disabled':''}>
|
||
<option value="tNO" ${_form.ManageBatchNumbers==='tNO'?'selected':''}>No</option>
|
||
<option value="tYES" ${_form.ManageBatchNumbers==='tYES'?'selected':''}>Yes</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<hr class="divider"/>
|
||
<div class="sec-label">Indian Taxation (GST)</div>
|
||
<div class="fi-row-3">
|
||
<div class="fg"><label class="fl">Item Category</label>
|
||
<select class="fi ${isReadonly?'readonly-field':''}" id="g-icat" ${isReadonly?'disabled':''}>
|
||
<option value="Material" ${_form.ItemCategory==='Material'?'selected':''}>Material</option>
|
||
<option value="Service" ${_form.ItemCategory==='Service'?'selected':''}>Service</option>
|
||
</select>
|
||
</div>
|
||
<div class="fg"><label class="fl">Material Type</label>
|
||
<select class="fi ${isReadonly?'readonly-field':''}" id="g-mattype" ${isReadonly?'disabled':''}>
|
||
<option value="">— Select —</option>
|
||
${MATERIAL_TYPES.map(m=>`<option value="${m.v}" ${_form.MaterialType===m.v?'selected':''}>${m.l}</option>`).join('')}
|
||
</select>
|
||
</div>
|
||
<div class="fg"><label class="fl">Tax Category</label>
|
||
<select class="fi ${isReadonly?'readonly-field':''}" id="g-taxcat" ${isReadonly?'disabled':''}>
|
||
<option value="">— Select —</option>
|
||
${TAX_CATEGORIES.map(t=>`<option value="${t.v}" ${(_form.GSTTaxCategory||_form.TaxCategory)===t.v?'selected':''}>${t.l}</option>`).join('')}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="fi-row">
|
||
<div class="fg"><label class="fl">HSN Code</label>
|
||
<input class="fi fi-mono ${isReadonly?'readonly-field':''}" id="g-hsn" placeholder="e.g. 8536.50.90" value="${esc(_form.HsnCode||'')}" ${isReadonly?'readonly':''}/>
|
||
</div>
|
||
<div class="fg"><label class="fl">GST</label>
|
||
<div style="display:flex;align-items:center;height:36px;gap:12px">
|
||
<label class="chk-item"><input type="checkbox" id="g-gst-excisable" ${_form.Excisable==='tYES'?'checked':''} ${isReadonly?'disabled':''}/> Excisable</label>
|
||
<label class="chk-item"><input type="checkbox" id="g-gst-gst" ${_form.GSTRelevant!=='tNO'?'checked':''} ${isReadonly?'disabled':''}/> GST</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="fi-row">
|
||
<div class="fg"><label class="fl">Assessable Value</label>
|
||
<input class="fi fi-mono ${isReadonly?'readonly-field':''}" id="g-assval" type="number" placeholder="0.00" value="${_form.AssessableValue||''}" ${isReadonly?'readonly':''}/>
|
||
</div>
|
||
</div>
|
||
<hr class="divider"/>
|
||
<div class="sec-label">Status</div>
|
||
<div class="chk-row">
|
||
<label class="chk-item"><input type="radio" name="status" value="Active" ${(_form.Frozen==='tNO'||!_form.Frozen)?'checked':''} ${isReadonly?'disabled':''}/> Active</label>
|
||
<label class="chk-item"><input type="radio" name="status" value="Inactive" ${_form.Frozen==='tYES'?'checked':''} ${isReadonly?'disabled':''}/> Inactive</label>
|
||
</div>
|
||
</div>`;
|
||
tc.appendChild(c);
|
||
if(!isReadonly) bindGeneralEvents();
|
||
}
|
||
|
||
function bindGeneralEvents(){
|
||
const w=(id,f,type='text')=>{const el=q(`#${id}`);if(!el)return;el[type==='chk'?'onchange':'oninput']=function(){_form[f]=type==='chk'?(this.checked?'tYES':'tNO'):(type==='num'?parseFloat(this.value)||0:this.value);};};
|
||
w('g-serial','ManageSerialNumbers'); w('g-batch','ManageBatchNumbers');
|
||
w('g-icat','ItemCategory'); w('g-mattype','MaterialType'); w('g-taxcat','GSTTaxCategory');
|
||
w('g-hsn','HsnCode'); w('g-assval','AssessableValue','num');
|
||
w('g-gst-excisable','Excisable','chk'); w('g-gst-gst','GSTRelevant','chk');
|
||
const ic=q('#g-icat'); if(ic) ic.onchange=function(){_form.ItemCategory=this.value;_form.ItemClass=this.value==='Service'?'itcService':'itcMaterial';};
|
||
document.querySelectorAll('[name=status]').forEach(r=>r.onchange=function(){_form.Frozen=this.value==='Inactive'?'tYES':'tNO';});
|
||
}
|
||
|
||
function renderPurchasing(tc){
|
||
const isReadonly = _mode==='view';
|
||
const c = document.createElement('div'); c.className = 'card';
|
||
c.innerHTML = `<div class="card-hd"><span class="ico"><i data-lucide="shopping-cart" style="width:16px;height:16px"></i></span> Purchasing Data</div>
|
||
<div class="card-bd">
|
||
<div class="fi-row">
|
||
<div class="fg"><label class="fl">Preferred Vendor</label>
|
||
<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>
|
||
<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">
|
||
<div class="fg"><label class="fl">Purchase QTY Per Package</label>
|
||
<input class="fi fi-mono ${isReadonly?'readonly-field':''}" id="p-qpp" type="number" min="0" step="0.001" placeholder="0" value="${_form.PurchaseQtyPerPackage||''}" ${isReadonly?'readonly':''}/>
|
||
</div>
|
||
<div class="fg"><label class="fl">Items Per Purchase Unit</label>
|
||
<input class="fi fi-mono ${isReadonly?'readonly-field':''}" id="p-ipu" type="number" min="0" step="0.001" placeholder="1" value="${_form.PurchaseItemsPerUnit||''}" ${isReadonly?'readonly':''}/>
|
||
</div>
|
||
</div>
|
||
<div class="fi-row">
|
||
<div class="fg"><label class="fl">Lead Time (Days)</label>
|
||
<input class="fi fi-mono ${isReadonly?'readonly-field':''}" id="p-lead" type="number" min="0" placeholder="0" value="${_form.LeadTime||''}" ${isReadonly?'readonly':''}/>
|
||
</div>
|
||
<div class="fg"><label class="fl">Min. Order Qty</label>
|
||
<input class="fi fi-mono ${isReadonly?'readonly-field':''}" id="p-moq" type="number" min="0" step="0.001" placeholder="0" value="${_form.MinimumOrderQuantity||''}" ${isReadonly?'readonly':''}/>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
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');
|
||
w('p-qpp','PurchaseQtyPerPackage','num'); w('p-ipu','PurchaseItemsPerUnit','num');
|
||
w('p-lead','LeadTime','num'); w('p-moq','MinimumOrderQuantity','num');
|
||
}
|
||
}
|
||
|
||
function renderSales(tc){
|
||
const isReadonly = _mode==='view';
|
||
const c = document.createElement('div'); c.className = 'card';
|
||
c.innerHTML = `<div class="card-hd"><span class="ico"><i data-lucide="trending-up" style="width:16px;height:16px"></i></span> Sales Data</div>
|
||
<div class="card-bd">
|
||
<div class="fi-row">
|
||
<div class="fg"><label class="fl">Sales UoM</label>
|
||
<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':''}/>
|
||
</div>
|
||
</div>
|
||
<div class="fi-row">
|
||
<div class="fg"><label class="fl">Min. Order Qty (Sales)</label>
|
||
<input class="fi fi-mono ${isReadonly?'readonly-field':''}" id="s-moq" type="number" min="0" step="0.001" placeholder="0" value="${_form.SalesMinimumOrderQuantity||''}" ${isReadonly?'readonly':''}/>
|
||
</div>
|
||
<div class="fg"><label class="fl">Warranty (Months)</label>
|
||
<input class="fi fi-mono ${isReadonly?'readonly-field':''}" id="s-warranty" type="number" min="0" placeholder="0" value="${_form.WarrantyTemplate||''}" ${isReadonly?'readonly':''}/>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
tc.appendChild(c);
|
||
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'); w('s-qpp','SalesQtyPerPackage','num');
|
||
w('s-moq','SalesMinimumOrderQuantity','num'); w('s-warranty','WarrantyTemplate','num');
|
||
},0);
|
||
}
|
||
}
|
||
|
||
function renderStock(tc){
|
||
const warehouses = _wh.length ? _wh : WH_DEMO;
|
||
|
||
function buildRows(){
|
||
if(!warehouses.length){
|
||
return `<tr><td colspan="9" style="text-align:center;padding:16px;color:var(--text3)">No warehouses found</td></tr>`;
|
||
}
|
||
return warehouses.map((wh,i)=>{
|
||
const code = wh.code || '';
|
||
const name = wh.name || '';
|
||
const branch = wh.branch || '';
|
||
const locked = wh.locked ? '✓' : '';
|
||
const onHand = wh.onHand||0;
|
||
const committed = wh.committed||0;
|
||
const onOrder = wh.onOrder||0;
|
||
const avail = onHand - committed + onOrder;
|
||
const isDefault = _form.DefaultWarehouse === code;
|
||
const isSelected = _selectedWhRow === code;
|
||
|
||
return `<tr class="wh-row${isSelected?' selected':''}${isDefault?' is-default':''}" data-code="${esc(code)}">
|
||
<td class="bold">${i+1}</td>
|
||
<td><span style="display:inline-flex;align-items:center;gap:4px">
|
||
<span style="font-size:9px;color:var(--blue)">→</span> ${esc(code)}
|
||
${isDefault?`<span class="star-badge" title="Default Warehouse"><i data-lucide="star" style="width:10px;height:10px"></i></span>`:''}
|
||
</span>
|
||
</td>
|
||
<td class="bold">${esc(name)}</td>
|
||
<td>${esc(branch)}</td>
|
||
<td style="text-align:center">${locked?`<span style="color:var(--red)">${locked}</span>`:''}</td>
|
||
<td style="text-align:right;color:${onHand>0?'var(--green)':'var(--text2)'}">${onHand||''}</td>
|
||
<td style="text-align:right">${committed||''}</td>
|
||
<td style="text-align:right">${onOrder||''}</td>
|
||
<td style="text-align:right;color:${avail>0?'var(--green)':'var(--text2)'}">${avail||''}</td>
|
||
</tr>`;
|
||
}).join('');
|
||
}
|
||
|
||
const totalStock = warehouses.reduce((s,w)=>s+(w.onHand||0),0);
|
||
const totalAvail = warehouses.reduce((s,w)=>s+(w.onHand||0)-(w.committed||0)+(w.onOrder||0),0);
|
||
const isReadonly = _mode==='view';
|
||
|
||
const c = document.createElement('div'); c.className = 'card';
|
||
c.innerHTML = `<div class="card-hd"><span class="ico"><i data-lucide="warehouse" style="width:16px;height:16px"></i></span> Stock Data</div>
|
||
<div class="card-bd">
|
||
<div class="fi-row">
|
||
<div class="fg"><label class="fl">Set G/L Accounts By</label>
|
||
<select class="fi ${isReadonly?'readonly-field':''}" id="stk-gl" ${isReadonly?'disabled':''}>
|
||
<option value="glm_ItemClass" ${_form.GLMethod==='glm_ItemClass'?'selected':''}>Item Group</option>
|
||
<option value="glm_WH" ${_form.GLMethod==='glm_WH'?'selected':''}>Warehouse</option>
|
||
<option value="glm_ItemLevel" ${_form.GLMethod==='glm_ItemLevel'?'selected':''}>Item Level</option>
|
||
</select>
|
||
</div>
|
||
<div class="fg"><label class="fl">UoM Name</label>
|
||
<input class="fi fi-mono ${isReadonly?'readonly-field':''}" id="stk-uomname" placeholder="e.g. Nos" value="${esc(_form.InventoryUOM||'')}" ${isReadonly?'readonly':''}/>
|
||
</div>
|
||
</div>
|
||
|
||
${_form.DefaultWarehouse
|
||
? `<div class="alert al-green" style="margin-bottom:8px;display:flex;align-items:center;gap:8px">
|
||
<span></span>
|
||
Default Warehouse: <strong style="font-family:var(--mono)">${esc(_form.DefaultWarehouse)}</strong>
|
||
— ${esc((warehouses.find(w=>w.code===_form.DefaultWarehouse)||{}).name||'')}
|
||
</div>`
|
||
: ''
|
||
}
|
||
|
||
<div class="sec-label">Warehouse Stock Levels
|
||
<span style="font-size:9px;font-weight:400;color:var(--text3);margin-left:6px;text-transform:none;letter-spacing:0">Click a row to select, then set as default</span>
|
||
</div>
|
||
|
||
<div style="overflow-x:auto">
|
||
<table class="stk-table" id="stk-tbl">
|
||
<thead>
|
||
<tr>
|
||
<th>#</th><th>Whse Code</th><th>Whse Name</th><th>Branch</th>
|
||
<th>Locked</th><th style="text-align:right">In Stock</th>
|
||
<th style="text-align:right">Committed</th><th style="text-align:right">Ordered</th>
|
||
<th style="text-align:right">Available</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="stk-tbody">${buildRows()}</tbody>
|
||
${totalStock>0?`<tfoot>
|
||
<tr>
|
||
<td colspan="5" style="border-top:1px solid var(--border);padding-top:6px"></td>
|
||
<td style="text-align:right;font-family:var(--mono);font-weight:700;color:var(--text);border-top:1px solid var(--border);padding-top:6px">${totalStock}</td>
|
||
<td colspan="2" style="border-top:1px solid var(--border)"></td>
|
||
<td style="text-align:right;font-family:var(--mono);font-weight:700;color:var(--green);border-top:1px solid var(--border);padding-top:6px">${totalAvail}</td>
|
||
</tr>
|
||
</tfoot>`:''}
|
||
</table>
|
||
</div>
|
||
|
||
${!isReadonly ? `
|
||
<div class="whse-btn-row">
|
||
<span class="default-label" id="whse-sel-label">
|
||
${_selectedWhRow ? `Selected: <strong style="color:var(--text);font-family:var(--mono)">${esc(_selectedWhRow)}</strong>` : 'No warehouse selected'}
|
||
</span>
|
||
<button class="b-amber" id="btn-set-default-whse" ${_selectedWhRow?'':'disabled'}>
|
||
Set Default Whse
|
||
</button>
|
||
</div>` : ''}
|
||
|
||
<div class="fi-row" style="margin-top:12px">
|
||
<div class="fg"><label class="fl">Min Inventory</label>
|
||
<input class="fi" id="stk-min-inv" type="number" min="0" value="${_form.MinimumInventory||0}" ${isReadonly?'readonly':''}/>
|
||
</div>
|
||
<div class="fg"><label class="fl">Max Inventory</label>
|
||
<input class="fi" id="stk-max-inv" type="number" min="0" value="${_form.MaximumQuantityInWarehouse||0}" ${isReadonly?'readonly':''}/>
|
||
</div>
|
||
<div class="fg"><label class="fl">Desired Inventory</label>
|
||
<input class="fi" id="stk-desired-inv" type="number" min="0" value="${_form.DesiredInventory||0}" ${isReadonly?'readonly':''}/>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
tc.appendChild(c);
|
||
|
||
setTimeout(()=>{
|
||
if(!isReadonly){
|
||
const glEl=q('#stk-gl'); if(glEl) glEl.onchange=function(){_form.GLMethod=this.value;};
|
||
const uomEl=q('#stk-uomname'); if(uomEl) uomEl.oninput=function(){_form.InventoryUOM=this.value;};
|
||
const minEl=q('#stk-min-inv'); if(minEl) minEl.oninput=function(){_form.MinimumInventory=+this.value;};
|
||
const maxEl=q('#stk-max-inv'); if(maxEl) maxEl.oninput=function(){_form.MaximumQuantityInWarehouse=+this.value;};
|
||
const desEl=q('#stk-desired-inv'); if(desEl) desEl.oninput=function(){_form.DesiredInventory=+this.value;};
|
||
}
|
||
|
||
document.querySelectorAll('#stk-tbody .wh-row').forEach(row=>{
|
||
row.addEventListener('click',function(){
|
||
if(isReadonly) return;
|
||
const code = this.dataset.code;
|
||
_selectedWhRow = (_selectedWhRow===code) ? null : code;
|
||
document.querySelectorAll('#stk-tbody .wh-row').forEach(r=>{
|
||
r.classList.toggle('selected', r.dataset.code===_selectedWhRow);
|
||
});
|
||
const selLabel = q('#whse-sel-label');
|
||
const btn = q('#btn-set-default-whse');
|
||
if(selLabel) selLabel.innerHTML = _selectedWhRow
|
||
? `Selected: <strong style="color:var(--text);font-family:var(--mono)">${esc(_selectedWhRow)}</strong>`
|
||
: 'No warehouse selected';
|
||
if(btn) btn.disabled = !_selectedWhRow;
|
||
});
|
||
});
|
||
|
||
const defBtn = q('#btn-set-default-whse');
|
||
if(defBtn) defBtn.onclick = function(){
|
||
if(!_selectedWhRow){toast('Select a warehouse row first','err');return;}
|
||
_form.DefaultWarehouse = _selectedWhRow;
|
||
toast(`Default warehouse set to: ${_selectedWhRow}`,'ok');
|
||
renderTabContent();
|
||
};
|
||
},0);
|
||
}
|
||
|
||
function renderRemarks(tc){
|
||
const isReadonly = _mode==='view';
|
||
const c = document.createElement('div'); c.className = 'card';
|
||
c.innerHTML = `<div class="card-hd"><span class="ico"><i data-lucide="message-square" style="width:16px;height:16px"></i></span> Remarks</div>
|
||
<div class="card-bd">
|
||
<div class="fg"><label class="fl">Remarks / Notes</label>
|
||
<textarea class="fi ${isReadonly?'readonly-field':''}" id="rem-text" rows="6" placeholder="Additional notes about this item..." ${isReadonly?'readonly':''}>${esc(_form.UserText||'')}</textarea>
|
||
</div>
|
||
<div class="fg"><label class="fl">Attachments</label>
|
||
<div style="border:1.5px dashed var(--border2);border-radius:8px;padding:20px;text-align:center;color:var(--text3);font-size:12px">
|
||
<div style="margin-bottom:6px"><i data-lucide="upload-cloud" style="width:24px;height:24px;color:var(--text4)"></i></div>
|
||
<div>Drag & drop files or click to attach</div>
|
||
<div style="font-size:10px;margin-top:4px;color:var(--text3)">PDF, Images, Excel supported</div>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
tc.appendChild(c);
|
||
if(!isReadonly){
|
||
setTimeout(()=>{const el=q('#rem-text');if(el)el.oninput=function(){_form.UserText=this.value;};},0);
|
||
}
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════
|
||
// WIRE HEADER EVENTS — ALL MODES
|
||
// ════════════════════════════════════════════════════════════════
|
||
function wireHeaderEvents(){
|
||
setTimeout(()=>{
|
||
const bind=(id,event,fn)=>{const el=q(id);if(el)el[event]=fn;};
|
||
|
||
// Category type selector (create mode only)
|
||
bind('#sel-cat-type','onchange',async function(){
|
||
_selectedTypeKey = this.value;
|
||
_sequenceError = null;
|
||
const config = ITEM_TYPE_CONFIG[_selectedTypeKey];
|
||
_isManualMode = config.defaultManual || !config.hasSeries;
|
||
await refreshItemCode();
|
||
});
|
||
|
||
bind('#sel-co','onchange',function(){_co=this.value;});
|
||
bind('#inp-code','oninput',function(){_form.ItemCode=this.value.toUpperCase();scheduleCodeExistsCheck();});
|
||
bind('#inp-name','oninput',function(){_form.ItemName=this.value;});
|
||
bind('#sel-type','onchange',function(){_form.ItemType=this.value;});
|
||
bind('#sel-group','onchange',function(){_form.ItemsGroupCode=this.value;});
|
||
bind('#sel-mat-type','onchange',function(){_form.MaterialType=this.value;});
|
||
bind('#chk-stock','onchange',function(){_form.InventoryItem=this.checked?'tYES':'tNO';});
|
||
bind('#chk-sales','onchange',function(){_form.SalesItem=this.checked?'tYES':'tNO';});
|
||
bind('#chk-purchase','onchange',function(){_form.PurchaseItem=this.checked?'tYES':'tNO';});
|
||
|
||
// ── CREATE MODE ──────────────────────────────────────────────
|
||
bind('#btn-save','onclick', doSave);
|
||
bind('#btn-reset','onclick',()=>{
|
||
if(!confirm('Reset all fields?')) return;
|
||
_form=defaultForm(); _selectedWhRow=null; _selectedTypeKey='finished';
|
||
_lastSequence=null; _isManualMode=false; _sequenceError=null;
|
||
render();
|
||
});
|
||
bind('#btn-refresh-code','onclick',()=>refreshItemCode(false));
|
||
bind('#btn-toggle-manual','onclick', toggleManualMode);
|
||
|
||
// ── EDIT MODE ────────────────────────────────────────────────
|
||
bind('#btn-update','onclick', doUpdate);
|
||
bind('#btn-cancel-item','onclick', doCancel);
|
||
|
||
// ── VIEW MODE ────────────────────────────────────────────────
|
||
// FIX: wire the Edit Item button in view mode
|
||
bind('#btn-edit','onclick',()=>{
|
||
setMode('edit', _currentItemCode);
|
||
});
|
||
|
||
// ── SHARED (edit + view + create) ────────────────────────────
|
||
// 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) — 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;
|
||
_sequenceError = null;
|
||
const badge = q('#mode-badge');
|
||
if(badge){ badge.className='mode-badge create'; badge.textContent='Create Mode'; }
|
||
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
|
||
const config = ITEM_TYPE_CONFIG[_selectedTypeKey];
|
||
if(_mode==='create' && config.hasSeries && !_isManualMode && !_form.ItemCode){
|
||
refreshItemCode();
|
||
}
|
||
}, 0);
|
||
}
|
||
|
||
function syncFormState(){
|
||
const fields=[
|
||
['inp-code','ItemCode',true],['inp-name','ItemName'],
|
||
['g-serial','ManageSerialNumbers'],['g-batch','ManageBatchNumbers'],['g-icat','ItemCategory'],['g-mattype','MaterialType'],
|
||
['g-taxcat','GSTTaxCategory'],['g-hsn','HsnCode'],['p-vendor','DefaultVendor'],['p-puom','PurchaseUnit'],
|
||
['s-suom','SalesUnit'],['stk-gl','GLMethod'],['stk-uomname','InventoryUOM'],['rem-text','UserText']
|
||
];
|
||
fields.forEach(([id,f,upper])=>{const el=q(`#${id}`);if(el)_form[f]=upper?el.value.toUpperCase():el.value;});
|
||
const nums=[
|
||
['g-assval','AssessableValue'],['p-qpp','PurchaseQtyPerPackage'],
|
||
['p-ipu','PurchaseItemsPerUnit'],['p-lead','LeadTime'],['p-moq','MinimumOrderQuantity'],
|
||
['s-qpp','SalesQtyPerPackage'],['s-moq','SalesMinimumOrderQuantity'],['s-warranty','WarrantyTemplate']
|
||
];
|
||
nums.forEach(([id,f])=>{const el=q(`#${id}`);if(el&&el.value!=='')_form[f]=parseFloat(el.value)||0;});
|
||
const bools=[
|
||
['chk-stock','InventoryItem'],['chk-sales','SalesItem'],['chk-purchase','PurchaseItem'],
|
||
['g-gst-excisable','Excisable'],['g-gst-gst','GSTRelevant']
|
||
];
|
||
bools.forEach(([id,f])=>{const el=q(`#${id}`);if(el)_form[f]=el.checked?'tYES':'tNO';});
|
||
_form.ItemClass=_form.ItemCategory==='Service'?'itcService':'itcMaterial';
|
||
}
|
||
|
||
function buildItemPayload(includeCode=true){
|
||
syncFormState();
|
||
const payload={
|
||
ItemName:_form.ItemName.trim(),
|
||
ForeignName:_form.ForeignName||undefined,
|
||
ItemsGroupCode:_form.ItemsGroupCode||undefined,
|
||
CustomsGroupCode:-1,
|
||
ItemType:_form.ItemType||'itItems',
|
||
ItemClass:_form.ItemClass||'itcMaterial',
|
||
InventoryItem:_form.InventoryItem,
|
||
SalesItem:_form.SalesItem,
|
||
PurchaseItem:_form.PurchaseItem,
|
||
VatLiable:'tYES',
|
||
Valid:'tYES',
|
||
Frozen:_form.Frozen||'tNO',
|
||
ManageSerialNumbers:_form.ManageSerialNumbers,
|
||
ManageBatchNumbers:_form.ManageBatchNumbers,
|
||
SRIAndBatchManageMethod:_form.SRIAndBatchManageMethod||'bomm_OnEveryTransaction',
|
||
ForceSelectionOfSerialNumber:'tYES',
|
||
ManageSerialNumbersOnReleaseOnly:'tNO',
|
||
WTLiable:_form.WTLiable||'tNO',
|
||
NoDiscounts:_form.NoDiscounts||'tNO',
|
||
TreeType:_form.TreeType||'iNotATree',
|
||
AssetItem:'tNO',
|
||
GLMethod:_form.GLMethod||'glm_ItemClass',
|
||
CostAccountingMethod:_form.ValuationMethod||'bis_MovingAverage',
|
||
TaxType:'tt_Yes',
|
||
IssueMethod:_form.IssueMethod||'im_Manual',
|
||
PlanningSystem:_form.PlanningSystem||'bop_None',
|
||
ProcurementMethod:_form.ProcurementMethod||'bom_Buy',
|
||
ComponentWarehouse:_form.ComponentWarehouse||'bomcw_BOM',
|
||
MaterialType:_form.MaterialType||'mt_FinishedGoods',
|
||
ProductSource:_form.ProductSource||'0',
|
||
UoMGroupEntry:_form.UoMGroupEntry||-1,
|
||
Excisable:_form.Excisable||'tNO',
|
||
GSTRelevnt:_form.GSTRelevant||'tNO',
|
||
GSTTaxCategory:_form.GSTTaxCategory||undefined,
|
||
User_Text:_form.UserText||undefined,
|
||
InventoryUOM:(_form.InventoryUOM||'').toUpperCase()||undefined,
|
||
SalesUnit:(_form.SalesUnit||'').toUpperCase()||undefined,
|
||
PurchaseUnit:(_form.PurchaseUnit||'').toUpperCase()||undefined,
|
||
Mainsupplier:_form.DefaultVendor||undefined,
|
||
OrderMultiple:_form.OrderMultiple||0,
|
||
LeadTime:_form.LeadTimeDays||_form.LeadTime||undefined,
|
||
MinOrderQuantity:_form.MinimumOrderQuantity||0,
|
||
ProdStdCost:_form.ProductionStandardCost||0,
|
||
InCostRollup:_form.IncludeInStdCostRollup||'tYES',
|
||
AssessableValue:_form.AssessableValue||0,
|
||
ManageStockByWarehouse:'tNO',
|
||
PurchaseItemsPerUnit:_form.PurchaseItemsPerUnit||1,
|
||
PurchaseQtyPerPackUnit:_form.PurchaseQtyPerPackage||1,
|
||
SalesItemsPerUnit:1,
|
||
SalesQtyPerPackUnit:_form.SalesQtyPerPackage||1,
|
||
WarrantyTemplate:_form.WarrantyTemplate||undefined,
|
||
DefaultWarehouse:_form.DefaultWarehouse||undefined,
|
||
HsnCode:_form.HsnCode||undefined,
|
||
MinInventory:_form.MinimumInventory||0,
|
||
MaxInventory:_form.MaximumQuantityInWarehouse||0,
|
||
DesiredInventory:_form.DesiredInventory||0,
|
||
company:_co
|
||
};
|
||
if(includeCode) payload.ItemCode=_form.ItemCode.trim().toUpperCase();
|
||
if(_form.HsnCode&&/^\d+$/.test(String(_form.HsnCode))) payload.ChapterID=parseInt(_form.HsnCode);
|
||
Object.keys(payload).forEach(k=>{if(payload[k]===''||payload[k]===null||payload[k]===undefined)delete payload[k];});
|
||
return payload;
|
||
}
|
||
|
||
async function doSave(){
|
||
syncFormState();
|
||
if(!_form.ItemCode.trim()){toast('Item Code is required','err');return;}
|
||
if(!_form.ItemName.trim()){toast('Description is required','err');return;}
|
||
// Final, synchronous duplicate check right before submit — the debounced
|
||
// live check above can be stale (e.g. Save clicked within the 350ms
|
||
// window, or right after the code field lost focus). Only meaningful for
|
||
// a NEW item; editing an existing one legitimately "finds" its own code.
|
||
if(_mode==='create'){
|
||
const code=_form.ItemCode.trim().toUpperCase();
|
||
try{
|
||
const r=await api('GET',`/sap/lookup/item-code-exists?code=${encodeURIComponent(code)}&company=${encodeURIComponent(_co)}`);
|
||
if(r&&r.exists){
|
||
_codeExists=true;_codeCheckedFor=code;patchCodeMeta();
|
||
toast(`Item code "${code}" already exists — choose a different one`,'err');
|
||
return;
|
||
}
|
||
}catch(_e){ /* fail open — SAP's own create call still independently rejects a real duplicate */ }
|
||
}
|
||
const btn=q('#btn-save'), sp=q('#sp-save');
|
||
btn.disabled=true; sp.style.display='inline-block';
|
||
const payload=buildItemPayload(true);
|
||
|
||
const role=_user?.role||'';
|
||
const isPrivileged = role==='admin'||role==='sap_adder';
|
||
|
||
try{
|
||
if(isPrivileged){
|
||
// admin / sap_adder — push directly to SAP
|
||
const r=await api('POST','/sap/items',payload);
|
||
const itemCode=_form.ItemCode.trim().toUpperCase();
|
||
if(_form.DefaultWarehouse){
|
||
try{
|
||
await api('PATCH',`/sap/items/${encodeURIComponent(itemCode)}`,{DefaultWarehouse:_form.DefaultWarehouse,company:_co});
|
||
}catch(we){
|
||
console.warn('[doSave] DefaultWarehouse PATCH failed:',we.message);
|
||
toast('Item created but default warehouse failed: '+we.message,'warn');
|
||
}
|
||
}
|
||
showSuccess(r.data||{ItemCode:itemCode,ItemName:_form.ItemName});
|
||
} else {
|
||
// user / manager / sr_manager — submit for approval
|
||
const r=await api('POST','/item-approvals/submit',payload);
|
||
if(r.status==='SAP_PUSHED'){
|
||
showSuccess({ItemCode:r.itemCode||_form.ItemCode,ItemName:_form.ItemName});
|
||
} else {
|
||
showPending(_form.ItemCode.trim().toUpperCase(),_form.ItemName,r.id);
|
||
}
|
||
}
|
||
}catch(e){
|
||
toast(e.message||'Failed to create item','err');
|
||
btn.disabled=false; sp.style.display='none';
|
||
}
|
||
}
|
||
|
||
async function doUpdate(){
|
||
if(!_form.ItemCode.trim()){toast('Item Code is required','err');return;}
|
||
if(!_form.ItemName.trim()){toast('Description is required','err');return;}
|
||
const btn=q('#btn-update'), sp=q('#sp-update');
|
||
if(btn) btn.disabled=true; if(sp) sp.style.display='inline-block';
|
||
const itemCode=_form.ItemCode.trim().toUpperCase();
|
||
const payload=buildItemPayload(false);
|
||
if(_form.DefaultWarehouse) payload.DefaultWarehouse=_form.DefaultWarehouse;
|
||
console.log('[doUpdate] payload:', JSON.stringify(payload, null, 2));
|
||
try{
|
||
await api('PATCH',`/sap/items/${encodeURIComponent(itemCode)}`,payload);
|
||
toast('Item updated: '+itemCode,'ok');
|
||
}catch(e){
|
||
toast(e.message||'Failed to update item','err');
|
||
}finally{
|
||
if(btn) btn.disabled=false; if(sp) sp.style.display='none';
|
||
}
|
||
}
|
||
|
||
async function doCancel(){
|
||
if(!_form.ItemCode.trim()){toast('No item to cancel','err');return;}
|
||
if(!confirm(`Cancel item ${_form.ItemCode}?`)) return;
|
||
try{
|
||
await api('POST',`/sap/items/${encodeURIComponent(_form.ItemCode)}/cancel`,{company:_co});
|
||
toast('Item cancelled');
|
||
}catch(e){
|
||
toast(e.message,'err');
|
||
}
|
||
}
|
||
|
||
function showSuccess(data){
|
||
const app=document.getElementById('app');
|
||
app.innerHTML=`<div class="card">
|
||
<div class="card-hd"><span class="ico"><i data-lucide="check-circle" style="width:16px;height:16px"></i></span> Item Created</div>
|
||
<div class="card-bd" style="text-align:center;padding:30px 16px">
|
||
<div style="margin-bottom:10px"><i data-lucide="check-circle-2" style="width:44px;height:44px;color:var(--green)"></i></div>
|
||
<div style="font-family:var(--font);font-weight:800;font-size:22px;color:var(--green);margin-bottom:6px">ITEM ADDED</div>
|
||
<div class="item-code-display" style="margin-bottom:4px">${esc(data.ItemCode||_form.ItemCode)}</div>
|
||
<div style="font-size:13px;color:var(--text3);margin-bottom:12px">${esc(data.ItemName||_form.ItemName)}</div>
|
||
${_form.DefaultWarehouse?`<div style="font-size:11px;color:var(--amber);margin-bottom:10px">Default Warehouse: <strong>${esc(_form.DefaultWarehouse)}</strong></div>`:''}
|
||
<div class="alert al-green" style="max-width:420px;margin:0 auto 16px;text-align:left">
|
||
Item successfully created in <strong>${_co}</strong> with status <strong>Active</strong>
|
||
</div>
|
||
<div class="btn-bar" style="justify-content:center">
|
||
<button class="btn b-primary" id="btn-new-item">+ New Item</button>
|
||
<button class="btn b-view" id="btn-view-items">View Items</button>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
if(window.lucide&&lucide.createIcons)lucide.createIcons();
|
||
setTimeout(()=>{
|
||
q('#btn-new-item').onclick=()=>{
|
||
_form=defaultForm(); _selectedWhRow=null; _selectedTypeKey='finished';
|
||
_lastSequence=null; _isManualMode=false; _sequenceError=null; _activeTab='general';
|
||
render();
|
||
};
|
||
// FIX: properly navigate to list instead of just showing a toast
|
||
q('#btn-view-items').onclick=()=>goToList();
|
||
},0);
|
||
}
|
||
|
||
function showPending(itemCode, itemName, id){
|
||
const app=document.getElementById('app');
|
||
app.innerHTML=`<div class="card">
|
||
<div class="card-hd"><span class="ico"><i data-lucide="clock" style="width:16px;height:16px"></i></span> Approval Pending</div>
|
||
<div class="card-bd" style="text-align:center;padding:30px 16px">
|
||
<div style="margin-bottom:10px"><i data-lucide="clock" style="width:44px;height:44px;color:var(--amber)"></i></div>
|
||
<div style="font-family:var(--font);font-weight:800;font-size:22px;color:var(--amber);margin-bottom:6px">SUBMITTED FOR APPROVAL</div>
|
||
<div class="item-code-display" style="margin-bottom:4px">${esc(itemCode)}</div>
|
||
<div style="font-size:13px;color:var(--text3);margin-bottom:12px">${esc(itemName)}</div>
|
||
<div class="alert" style="background:rgba(245,166,35,.08);border:1px solid rgba(245,166,35,.25);color:var(--amber);max-width:420px;margin:0 auto 16px;text-align:left;padding:10px 14px;border-radius:8px;font-size:12px">
|
||
Your item request has been submitted and is awaiting admin approval before being added to SAP B1.
|
||
</div>
|
||
<div class="btn-bar" style="justify-content:center">
|
||
<button class="btn b-primary" id="btn-new-item">+ New Item</button>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
if(window.lucide&&lucide.createIcons)lucide.createIcons();
|
||
setTimeout(()=>{
|
||
q('#btn-new-item').onclick=()=>{
|
||
_form=defaultForm(); _selectedWhRow=null; _selectedTypeKey='finished';
|
||
_lastSequence=null; _isManualMode=false; _sequenceError=null; _activeTab='general';
|
||
render();
|
||
};
|
||
},0);
|
||
}
|
||
|
||
async function init(){
|
||
render();
|
||
try{const list=await window.fetchCompanyList();if(list.length)_companies=list;}catch(_e){}
|
||
await loadLookups();
|
||
render();
|
||
}
|
||
|
||
init();
|
||
</script>
|
||
<script src="/sidebar.js?v=31"></script>
|
||
</body>
|
||
</html>
|
||
|