336 lines
41 KiB
HTML
336 lines
41 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<script src="/auth-guard.js?v=4"></script>
|
||
<meta charset="UTF-8"/>
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0"/>
|
||
<title>Sales Admin — SAP B1</title>
|
||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet"/>
|
||
<style>
|
||
:root{--ink:#080e18;--blue:#1a6fff;--amber:#f5a623;--green:#0dbb8a;--red:#e53e3e;--border:#1c2e48;--border2:#243650;--text:#c8d6e8;--text2:#7a9ab8;--text3:#3d5470;--surface:#0f1d30;--surface2:#142338;--surface3:#0b1520;--radius:10px;--font:'Space Grotesk',sans-serif;--mono:'JetBrains Mono',monospace}
|
||
*{box-sizing:border-box;margin:0;padding:0}
|
||
body{font-family:var(--font);background:var(--ink);color:var(--text);min-height:100vh}
|
||
main{padding:12px;max-width:1500px;margin:0 auto}
|
||
.page-title{font-size:16px;font-weight:700;margin-bottom:10px}
|
||
.tabs{display:flex;gap:4px;margin-bottom:10px;border-bottom:1px solid var(--border);overflow-x:auto}
|
||
.tab{padding:8px 14px;font-size:12.5px;font-weight:700;color:var(--text2);cursor:pointer;border-bottom:2px solid transparent;white-space:nowrap}.tab.on{color:var(--blue);border-bottom-color:var(--blue)}
|
||
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);margin-bottom:12px;overflow:hidden}
|
||
.card-hd{background:var(--surface3);padding:10px 14px;font-size:12px;font-weight:700;display:flex;align-items:center;gap:8px;border-bottom:1px solid var(--border);flex-wrap:wrap}
|
||
.card-hd .r{margin-left:auto;display:flex;gap:8px;align-items:center}
|
||
.card-bd{padding:12px 14px}
|
||
.lst{width:100%;border-collapse:separate;border-spacing:0;font-size:12px}
|
||
.lst th{text-align:left;padding:8px 10px;font-size:9px;font-weight:700;text-transform:uppercase;color:var(--text3);border-bottom:2px solid var(--border2);white-space:nowrap;background:var(--surface3);position:sticky;top:0}
|
||
.lst td{padding:7px 10px;border-bottom:1px solid var(--border);vertical-align:top}
|
||
.lst td.n,.lst th.n{text-align:right;font-family:var(--mono)}
|
||
.lst tbody tr.click{cursor:pointer}.lst tbody tr.click:hover td{background:rgba(26,111,255,.06)}
|
||
.tbl-wrap{overflow:auto;max-height:70vh}
|
||
.mono{font-family:var(--mono)}.empty{text-align:center;padding:26px;color:var(--text3);font-size:12px}
|
||
.chk{display:inline-flex;gap:5px;align-items:center;font-size:12px;margin:0 12px 6px 0}
|
||
.perm-grid td{padding:4px 8px}.perm-grid input{transform:scale(1.1)}
|
||
.ac{position:relative}.ac-list{position:absolute;z-index:50;left:0;right:0;top:100%;background:var(--surface2);border:1px solid var(--border2);border-radius:8px;max-height:260px;overflow:auto}
|
||
.ac-list div{padding:7px 10px;font-size:12px;cursor:pointer;border-bottom:1px solid var(--border)}.ac-list div:hover{background:rgba(26,111,255,.12)}
|
||
.off{opacity:.5}.off .sw,.off .sw-l{opacity:1}
|
||
/* on/off switch */
|
||
.sw{position:relative;display:inline-block;width:38px;height:21px;vertical-align:middle;cursor:pointer}
|
||
.sw input{opacity:0;width:0;height:0}
|
||
.sw span{position:absolute;inset:0;background:#64748b;border-radius:21px;transition:.2s}
|
||
.sw span:before{content:'';position:absolute;width:15px;height:15px;left:3px;top:3px;background:#fff;border-radius:50%;transition:.2s}
|
||
.sw input:checked+span{background:var(--green)}.sw input:checked+span:before{transform:translateX(17px)}
|
||
.sw input:disabled+span{opacity:.5}
|
||
.sw-l{font-size:11px;font-weight:700;margin-left:6px;color:var(--red)}.sw-l.on{color:var(--green)}
|
||
</style>
|
||
<link rel="stylesheet" href="/theme.css?v=3"/>
|
||
<script src="/sales-common.js?v=4"></script>
|
||
</head>
|
||
<body>
|
||
<main id="app"><div class="empty"><span class="sc-spin"></span></div></main>
|
||
<script>
|
||
'use strict';
|
||
const { esc, money, dt, q, qa, toast, api } = SC;
|
||
let ME = null, TAB = null, STEPS = [], UTYPES = [];
|
||
const PERMS = ['view', 'add', 'edit', 'approve', 'delete'], PL = { view: 'V', add: 'Ad', edit: 'Ed', approve: 'Ap', delete: 'Del' };
|
||
const MODS = [['sales-orders', 'Sales Orders'], ['sales-samples', 'Sample Requests'], ['sales-reports', 'Sales Reports'], ['sales-admin', 'Sales Admin']];
|
||
const user = (() => { try { return JSON.parse(sessionStorage.getItem('portal_user') || '{}'); } catch (_e) { return {}; } })();
|
||
const isAdmin = () => ME.isAdmin;
|
||
const isUserAdmin = () => ME.isAdmin || ['system_admin', 'sap_adder'].includes(user.role);
|
||
const cap = (k, p) => ME.caps[k] && ME.caps[k].includes(p);
|
||
const ALLDIV = () => ME.allDivisions || ME.divisions; // admin screens always list disabled categories too
|
||
const divName = id => ((ME.allDivisions || ME.divisions).find(d => d.id === id) || {}).name || id; // incl. disabled categories (existing orders)
|
||
const ac = (input, list, onPick) => { let t; input.oninput = () => { clearTimeout(t); const v = input.value.trim(); if (v.length < 2) { list.style.display = 'none'; return; }
|
||
t = setTimeout(async () => { const r = await api('/customers?q=' + encodeURIComponent(v)).catch(e => { toast(e.message); return []; });
|
||
list.innerHTML = r.map(c => `<div data-cc="${esc(c.cardCode)}" data-n="${esc(c.cardName)}" data-e="${esc(c.email)}">${esc(c.cardName)} <span class="sc-muted">${esc(c.cardCode)} · ${esc(c.city)}</span></div>`).join('') || '<div class="sc-muted">No match</div>';
|
||
list.style.display = 'block'; qa('[data-cc]', list).forEach(d => { d.onclick = () => { list.style.display = 'none'; onPick({ cardCode: d.dataset.cc, cardName: d.dataset.n, email: d.dataset.e }); }; }); }, 300); }; };
|
||
|
||
async function init() {
|
||
ME = await api('/me');
|
||
const steps = await fetch('/api/approval-steps', { headers: { Authorization: 'Bearer ' + SC.getToken() } }).then(r => r.json()).catch(() => ({}));
|
||
const groups = (steps && steps.data) || [];
|
||
STEPS = (Array.isArray(groups) ? groups : []).filter(g => /^sales_/.test(g.workflow)).flatMap(g => (g.steps || []).map(s => ({ key: `${g.workflow}:${s.key || s.stepKey}`, label: s.label || s.stepLabel, wf: g.label || g.workflow })));
|
||
if (!STEPS.length) STEPS = Object.keys(ME.caps).map(k => ({ key: k, label: k, wf: k.split(':')[0] }));
|
||
const tabs = available();
|
||
TAB = tabs[0] ? tabs[0][0] : null;
|
||
render();
|
||
}
|
||
function available() {
|
||
return [['users', 'Sales Users', isUserAdmin()], ['types', 'User Types & Permissions', isUserAdmin()], ['products', 'Products & Prices', isAdmin() || cap('sales_master:products', 'view')],
|
||
['persons', 'Sales Persons & Mapping', isAdmin() || cap('sales_master:mapping', 'view')], ['customers', 'Customer Logins', isAdmin() || cap('sales_master:customers', 'view')],
|
||
['divisions', 'Product Categories', isAdmin()], ['settings', 'Settings', isAdmin()]].filter(t => t[2]);
|
||
}
|
||
function render() {
|
||
const tabs = available();
|
||
q('#app').innerHTML = `<div class="page-title">Sales Admin</div>
|
||
${tabs.length ? `<div class="tabs">${tabs.map(([k, l]) => `<div class="tab ${TAB === k ? 'on' : ''}" data-t="${k}">${l}</div>`).join('')}</div><div id="pane"></div>` : '<div class="sc-err">You have no Sales admin permissions.</div>'}`;
|
||
qa('.tab').forEach(t => { t.onclick = () => { TAB = t.dataset.t; render(); }; });
|
||
if (TAB) ({ users, types, products, persons, customers, divisions, settings })[TAB]();
|
||
}
|
||
const pane = () => q('#pane');
|
||
|
||
// ── Sales users ─────────────────────────────────────────────────────────────
|
||
async function users() {
|
||
pane().innerHTML = '<div class="empty"><span class="sc-spin"></span></div>';
|
||
UTYPES = await api('/admin/user-types');
|
||
const list = await api('/admin/users');
|
||
const ut = id => (UTYPES.find(t => t.id === id) || {}).name || '';
|
||
pane().innerHTML = `<div class="sc-info">Assign each employee a <b>Sales user type</b> (same 18 types as msale) and the <b>product categories</b> they handle. Saving with "apply template" writes the type's default
|
||
Sales approval steps & page access onto the user (other modules are left untouched). Fine-tune individual steps afterwards in <a class="sc-link" href="/admin">Admin → Users</a>. Users pick up changes on their next login / token refresh.</div>
|
||
<div class="card"><div class="card-hd">ERP users <span class="r"><input class="sc-in" id="u-q" placeholder="Filter…" style="width:220px"><label class="chk"><input type="checkbox" id="u-only"> Only with a sales profile</label></span></div>
|
||
<div class="tbl-wrap"><table class="lst"><thead><tr><th>User</th><th>Email</th><th>ERP role</th><th>Sales user type</th><th>Categories</th><th>Sales steps</th></tr></thead><tbody id="u-tb"></tbody></table></div></div>`;
|
||
const draw = () => {
|
||
const f = q('#u-q').value.toLowerCase(), only = q('#u-only').checked;
|
||
q('#u-tb').innerHTML = list.filter(u => (!only || u.profile) && (!f || `${u.username} ${u.fullName} ${u.email}`.toLowerCase().includes(f))).map(u => `<tr class="click ${u.active ? '' : 'off'}" data-id="${u.id}">
|
||
<td><b>${esc(u.fullName || u.username)}</b><div class="sc-muted">${esc(u.username)}</div></td><td>${esc(u.email)}</td><td>${esc(u.role)}</td>
|
||
<td>${u.profile ? esc(ut(u.profile.userTypeId)) : '<span class="sc-muted">—</span>'}</td><td>${u.profile ? u.profile.divisions.map(divName).map(esc).join(', ') : ''}</td>
|
||
<td class="sc-muted">${u.salesSteps.length}</td></tr>`).join('');
|
||
qa('#u-tb tr').forEach(tr => { tr.onclick = () => editProfile(list.find(u => u.id === +tr.dataset.id), users); });
|
||
};
|
||
q('#u-q').oninput = draw; q('#u-only').onchange = draw; draw();
|
||
}
|
||
function editProfile(u, after) {
|
||
const p = u.profile || { divisions: [], userTypeId: '', empCode: '', territory: '', ccEmails: '' };
|
||
SC.modal({ title: `Sales profile — ${esc(u.fullName || u.username)}`, body: `
|
||
<div class="sc-row"><div><label class="sc-lbl">Sales user type</label><select class="sc-in" id="p-ut"><option value="">— None —</option>${UTYPES.map(t => `<option value="${t.id}" ${t.id === p.userTypeId ? 'selected' : ''}>${t.id} · ${esc(t.name)} (${esc(t.scope)})</option>`).join('')}</select></div>
|
||
<div><label class="sc-lbl">Employee code</label><input class="sc-in" id="p-ec" value="${esc(p.empCode)}"></div></div>
|
||
<label class="sc-lbl" style="margin-top:8px">Product categories (divisions)</label>
|
||
<div>${ALLDIV().map(d => `<label class="chk"><input type="checkbox" class="p-div" value="${d.id}" ${p.divisions.includes(d.id) ? 'checked' : ''}> ${esc(d.name)} (${esc(d.code)})</label>`).join('')}</div>
|
||
<div class="sc-row" style="margin-top:8px"><div><label class="sc-lbl">Territory</label><input class="sc-in" id="p-tr" value="${esc(p.territory)}"></div><div><label class="sc-lbl">Extra CC emails</label><input class="sc-in" id="p-cc" value="${esc(p.ccEmails)}"></div></div>
|
||
<label class="chk" style="margin-top:10px"><input type="checkbox" id="p-ap" ${u.profile ? '' : 'checked'}> Apply the user type's default Sales permissions & page access (replaces this user's existing sales_* steps)</label>
|
||
${u.salesSteps.length ? `<div class="sc-muted" style="margin-top:6px">Current sales steps: ${u.salesSteps.map(s => esc(typeof s === 'string' ? s : `${s.step} [${(s.perms || []).map(x => PL[x]).join('/')}]`)).join(', ')}</div>` : ''}`,
|
||
buttons: [{ label: 'Cancel' }, { label: 'Save', cls: 'sc-b-primary', onClick: async ({ root }) => {
|
||
await api(`/admin/users/${u.id}/profile`, { method: 'PUT', body: { userTypeId: q('#p-ut', root).value, divisions: qa('.p-div:checked', root).map(c => +c.value), empCode: q('#p-ec', root).value,
|
||
territory: q('#p-tr', root).value, ccEmails: q('#p-cc', root).value, applyTemplate: q('#p-ap', root).checked } });
|
||
toast('Sales profile saved', 'ok'); after(); } }] });
|
||
}
|
||
|
||
// ── User types (permission templates) ───────────────────────────────────────
|
||
async function types() {
|
||
UTYPES = await api('/admin/user-types');
|
||
pane().innerHTML = `<div class="sc-info"><b>Scope</b>: <i>all</i> = every customer in the user's categories · <i>mapped</i> = only customers mapped to them as a sales person (msale SM) · <i>own</i> = only orders they created (msale Tender).
|
||
<b>Default list statuses</b> = the order list's default filter (msale showed Accounts 6–9, Logistics 2,7,8,9). Editing a type does not change existing users until you re-apply it from Sales Users.</div>
|
||
<div class="card"><div class="card-hd">User types ${isAdmin() ? '<span class="r"><button class="sc-btn sc-b-primary" id="t-new">+ New type</button></span>' : ''}</div><div class="tbl-wrap"><table class="lst"><thead><tr><th>ID</th><th>Name</th><th>Scope</th><th>Default statuses</th><th>Pages</th><th>Default steps</th></tr></thead><tbody>
|
||
${UTYPES.map(t => `<tr class="click ${t.active ? '' : 'off'}" data-id="${t.id}"><td>${t.id}</td><td><b>${esc(t.name)}</b></td><td>${esc(t.scope)}</td><td>${esc(t.listStatuses || 'all')}</td>
|
||
<td>${t.defaultModules.map(m => esc((MODS.find(x => x[0] === m) || [m, m])[1])).join(', ')}</td><td class="sc-muted" style="white-space:normal">${t.defaultSteps.map(s => `${esc(s.step)} [${s.perms.map(x => PL[x]).join('/')}]`).join(', ')}</td></tr>`).join('')}
|
||
</tbody></table></div></div>`;
|
||
qa('tr[data-id]').forEach(tr => { tr.onclick = () => editType(UTYPES.find(t => t.id === +tr.dataset.id)); });
|
||
if (q('#t-new')) q('#t-new').onclick = () => editType(null);
|
||
}
|
||
function editType(t) {
|
||
const isNew = !t; t = t || { id: Math.max(...UTYPES.map(x => x.id)) + 1, name: '', scope: 'all', listStatuses: '', defaultSteps: [], defaultModules: [], active: true };
|
||
const has = (k, p) => (t.defaultSteps.find(s => s.step === k) || { perms: [] }).perms.includes(p);
|
||
let lastWf = '';
|
||
SC.modal({ title: isNew ? 'New user type' : `User type ${t.id} — ${esc(t.name)}`, wide: true, body: `
|
||
<div class="sc-row"><div style="max-width:100px"><label class="sc-lbl">ID</label><input class="sc-in" id="y-id" value="${t.id}" ${isNew ? '' : 'disabled'}></div><div><label class="sc-lbl">Name</label><input class="sc-in" id="y-n" value="${esc(t.name)}"></div>
|
||
<div><label class="sc-lbl">Scope</label><select class="sc-in" id="y-s">${['all', 'mapped', 'own'].map(s => `<option ${t.scope === s ? 'selected' : ''}>${s}</option>`).join('')}</select></div>
|
||
<div><label class="sc-lbl">Default list statuses (e.g. 6,7,8,9)</label><input class="sc-in" id="y-l" value="${esc(t.listStatuses)}"></div>
|
||
<div style="max-width:110px"><label class="sc-lbl">Active</label><select class="sc-in" id="y-a"><option value="1">Yes</option><option value="0" ${t.active ? '' : 'selected'}>No</option></select></div></div>
|
||
<label class="sc-lbl" style="margin-top:10px">Pages</label><div>${MODS.map(([k, l]) => `<label class="chk"><input type="checkbox" class="y-mod" value="${k}" ${t.defaultModules.includes(k) ? 'checked' : ''}> ${l}</label>`).join('')}</div>
|
||
<label class="sc-lbl" style="margin-top:10px">Default approval steps</label>
|
||
<table class="sc-tbl perm-grid"><thead><tr><th>Step</th>${PERMS.map(p => `<th>${PL[p]}</th>`).join('')}</tr></thead><tbody>
|
||
${STEPS.map(s => { const head = s.wf !== lastWf ? `<tr><td colspan="6" style="font-weight:700;color:var(--blue)">${esc(s.wf)}</td></tr>` : ''; lastWf = s.wf;
|
||
return head + `<tr><td>${esc(s.label)} <span class="sc-muted">${esc(s.key)}</span></td>${PERMS.map(p => `<td><input type="checkbox" class="y-p" data-k="${esc(s.key)}" data-p="${p}" ${has(s.key, p) ? 'checked' : ''}></td>`).join('')}</tr>`; }).join('')}
|
||
</tbody></table>`,
|
||
buttons: [{ label: 'Cancel' }, ...(isAdmin() ? [{ label: 'Save', cls: 'sc-b-primary', onClick: async ({ root }) => {
|
||
const steps = {}; qa('.y-p:checked', root).forEach(c => { (steps[c.dataset.k] = steps[c.dataset.k] || []).push(c.dataset.p); });
|
||
await api('/admin/user-types', { method: 'POST', body: { id: +q('#y-id', root).value, name: q('#y-n', root).value, scope: q('#y-s', root).value, listStatuses: q('#y-l', root).value,
|
||
active: q('#y-a', root).value === '1', defaultModules: qa('.y-mod:checked', root).map(c => c.value), defaultSteps: Object.entries(steps).map(([step, perms]) => ({ step, perms })) } });
|
||
toast('Saved', 'ok'); types(); } }] : [])] });
|
||
}
|
||
|
||
// ── Products ────────────────────────────────────────────────────────────────
|
||
async function products() {
|
||
const cat = (q('#pr-cat') || {}).value || 'domestic';
|
||
pane().innerHTML = `${ME.settings.productSource === 'sap' ? '<div class="sc-warn">Product source is set to <b>SAP</b> (Settings) — customers order from SAP items & the SAP price list. This catalog is only used for export samples until you switch back to <b>catalog</b>.</div>' : ''}
|
||
<div class="card"><div class="card-hd">Product catalog <span class="r"><select class="sc-in" id="pr-cat" style="width:160px"><option value="domestic">Domestic</option><option value="export">Export</option></select>
|
||
<select class="sc-in" id="pr-div" style="width:200px"><option value="">All categories</option>${ALLDIV().map(d => `<option value="${d.id}">${esc(d.name)}</option>`).join('')}</select>
|
||
<input class="sc-in" id="pr-q" placeholder="Filter…" style="width:200px">${isAdmin() || cap('sales_master:products', 'add') ? '<button class="sc-btn sc-b-primary" id="pr-new">+ Product</button>' : ''}</span></div>
|
||
<div class="tbl-wrap" id="pr-t"><div class="empty"><span class="sc-spin"></span></div></div></div>`;
|
||
q('#pr-cat').value = cat;
|
||
let list = [];
|
||
const draw = () => { const f = q('#pr-q').value.toLowerCase(), dv = q('#pr-div').value;
|
||
q('#pr-t').innerHTML = `<table class="lst"><thead><tr><th>Display code</th><th>SAP item code</th><th>Description</th><th>Category</th><th class="n">Pack size</th><th>HSN</th><th class="n">Unit price</th><th>Active</th></tr></thead><tbody>
|
||
${list.filter(p => (!dv || p.divisionId === +dv) && (!f || `${p.displayCode} ${p.itemCode} ${p.description}`.toLowerCase().includes(f))).map(p => `<tr class="click ${p.active ? '' : 'off'}" data-id="${p.id}">
|
||
<td class="mono">${esc(p.displayCode)}</td><td class="mono">${esc(p.itemCode)}</td><td>${esc(p.description)}</td><td>${esc(divName(p.divisionId))}</td><td class="n">${p.packSize}</td><td>${esc(p.hsn)}</td><td class="n">${money(p.price)}</td><td>${p.active ? 'Yes' : 'No'}</td></tr>`).join('')}</tbody></table>`;
|
||
qa('#pr-t tr[data-id]').forEach(tr => { tr.onclick = () => editProduct(list.find(p => p.id === +tr.dataset.id)); }); };
|
||
const load = async () => { list = await api('/admin/products?catalog=' + q('#pr-cat').value); draw(); };
|
||
q('#pr-cat').onchange = load; q('#pr-div').onchange = draw; q('#pr-q').oninput = draw;
|
||
if (q('#pr-new')) q('#pr-new').onclick = () => editProduct(null);
|
||
load();
|
||
function editProduct(p) {
|
||
p = p || { catalog: q('#pr-cat').value, divisionId: +q('#pr-div').value || ALLDIV()[0].id, itemCode: '', displayCode: '', description: '', shortDesc: '', packSize: 1, hsn: '', price: 0, isInstrument: false, active: true };
|
||
const canEdit = isAdmin() || cap('sales_master:products', p.id ? 'edit' : 'add');
|
||
SC.modal({ title: p.id ? `Product ${esc(p.displayCode)}` : 'New product', body: `
|
||
<div class="sc-row"><div><label class="sc-lbl">Catalog</label><select class="sc-in" id="e-cat"><option value="domestic">Domestic</option><option value="export" ${p.catalog === 'export' ? 'selected' : ''}>Export</option></select></div>
|
||
<div><label class="sc-lbl">Category</label><select class="sc-in" id="e-div">${ALLDIV().map(d => `<option value="${d.id}" ${d.id === p.divisionId ? 'selected' : ''}>${esc(d.name)}</option>`).join('')}</select></div></div>
|
||
<div class="sc-row" style="margin-top:8px"><div><label class="sc-lbl">SAP item code * (posted to SAP)</label><input class="sc-in" id="e-ic" value="${esc(p.itemCode)}"></div><div><label class="sc-lbl">Display code (shown to customers)</label><input class="sc-in" id="e-dc" value="${esc(p.displayCode)}"></div></div>
|
||
<div style="margin-top:8px"><label class="sc-lbl">Description</label><input class="sc-in" id="e-de" value="${esc(p.description)}"></div>
|
||
<div class="sc-row" style="margin-top:8px"><div><label class="sc-lbl">Pack size (units per pack)</label><input class="sc-in" type="number" min="1" id="e-ps" value="${p.packSize}"></div><div><label class="sc-lbl">HSN</label><input class="sc-in" id="e-hs" value="${esc(p.hsn)}"></div>
|
||
<div><label class="sc-lbl">Unit price (₹, before tax)</label><input class="sc-in" type="number" min="0" step="0.01" id="e-pr" value="${p.price}"></div>
|
||
<div><label class="sc-lbl">Active</label><select class="sc-in" id="e-ac"><option value="1">Yes</option><option value="0" ${p.active ? '' : 'selected'}>No</option></select></div></div>`,
|
||
buttons: [{ label: 'Cancel' }, ...(canEdit ? [{ label: 'Save', cls: 'sc-b-primary', onClick: async ({ root }) => {
|
||
await api('/admin/products', { method: 'POST', body: { id: p.id, catalog: q('#e-cat', root).value, divisionId: +q('#e-div', root).value, itemCode: q('#e-ic', root).value.trim(), displayCode: q('#e-dc', root).value.trim(),
|
||
description: q('#e-de', root).value, packSize: q('#e-ps', root).value, hsn: q('#e-hs', root).value, price: q('#e-pr', root).value, active: q('#e-ac', root).value === '1' } });
|
||
toast('Saved', 'ok'); load(); } }] : [])] });
|
||
}
|
||
}
|
||
|
||
// ── Sales persons & mapping ─────────────────────────────────────────────────
|
||
async function persons() {
|
||
const list = await api('/admin/sales-persons');
|
||
const canEdit = isAdmin() || cap('sales_master:mapping', 'edit');
|
||
pane().innerHTML = `<div class="sc-info">Sales persons (msale <i>sales_person_master</i>) are mapped to customers. Users whose Sales user type has scope <b>mapped</b> see only their mapped customers (matched by linked ERP user or the same email);
|
||
mapped persons are also CC'd on that customer's order emails for their categories.</div>
|
||
<div class="card"><div class="card-hd">Sales persons ${canEdit ? '<span class="r"><button class="sc-btn sc-b-primary" id="sp-new">+ Sales person</button></span>' : ''}</div><div class="tbl-wrap"><table class="lst"><thead><tr><th>Name</th><th>Email</th><th>Designation</th><th>Categories</th><th class="n">Customers</th><th></th></tr></thead><tbody>
|
||
${list.map(p => `<tr class="${p.active ? '' : 'off'}"><td><b>${esc(p.name)}</b></td><td>${esc(p.email)}</td><td>${esc(p.designation)}</td><td>${p.divisions.map(divName).map(esc).join(', ')}</td><td class="n">${p.customerCount}</td>
|
||
<td><span class="sc-link" data-map="${p.id}">Customers</span>${canEdit ? ` · <span class="sc-link" data-ed="${p.id}">Edit</span>` : ''}</td></tr>`).join('')}</tbody></table></div></div>`;
|
||
if (q('#sp-new')) q('#sp-new').onclick = () => editPerson(null);
|
||
qa('[data-ed]').forEach(el => { el.onclick = () => editPerson(list.find(p => p.id === +el.dataset.ed)); });
|
||
qa('[data-map]').forEach(el => { el.onclick = () => mapping(list.find(p => p.id === +el.dataset.map)); });
|
||
function editPerson(p) {
|
||
p = p || { name: '', email: '', designation: 'Sales Manager', divisions: [], active: true };
|
||
SC.modal({ title: p.id ? 'Edit sales person' : 'New sales person', body: `<div class="sc-row"><div><label class="sc-lbl">Name *</label><input class="sc-in" id="s-n" value="${esc(p.name)}"></div><div><label class="sc-lbl">Email</label><input class="sc-in" id="s-e" value="${esc(p.email)}"></div></div>
|
||
<div class="sc-row" style="margin-top:8px"><div><label class="sc-lbl">Designation</label><input class="sc-in" id="s-d" value="${esc(p.designation)}"></div><div><label class="sc-lbl">Active</label><select class="sc-in" id="s-a"><option value="1">Yes</option><option value="0" ${p.active ? '' : 'selected'}>No</option></select></div></div>
|
||
<label class="sc-lbl" style="margin-top:8px">Categories</label><div>${ALLDIV().map(d => `<label class="chk"><input type="checkbox" class="s-dv" value="${d.id}" ${p.divisions.includes(d.id) ? 'checked' : ''}> ${esc(d.name)}</label>`).join('')}</div>`,
|
||
buttons: [{ label: 'Cancel' }, { label: 'Save', cls: 'sc-b-primary', onClick: async ({ root }) => {
|
||
await api('/admin/sales-persons', { method: 'POST', body: { id: p.id, name: q('#s-n', root).value, email: q('#s-e', root).value, designation: q('#s-d', root).value, active: q('#s-a', root).value === '1', divisions: qa('.s-dv:checked', root).map(c => +c.value) } });
|
||
toast('Saved', 'ok'); persons(); } }] });
|
||
}
|
||
async function mapping(p) {
|
||
const m = SC.modal({ title: `Customers mapped to ${esc(p.name)}`, wide: true, body: `${isAdmin() || cap('sales_master:mapping', 'add') ? `<div class="ac" style="margin-bottom:10px"><label class="sc-lbl">Add customer</label><input class="sc-in" id="m-q" placeholder="Type code or name…" autocomplete="off"><div class="ac-list" id="m-ac" style="display:none"></div></div>` : ''}
|
||
<div id="m-l"><span class="sc-spin"></span></div>`, buttons: [{ label: 'Close', onClick: () => { persons(); } }] });
|
||
const R = m.root;
|
||
const load = async () => { const l = await api(`/admin/sales-persons/${p.id}/customers`);
|
||
q('#m-l', R).innerHTML = `<table class="sc-tbl"><thead><tr><th>Code</th><th>Name</th><th></th></tr></thead><tbody>${l.map(x => `<tr><td class="mono">${esc(x.cardCode)}</td><td>${esc(x.cardName)}</td>
|
||
<td>${isAdmin() || cap('sales_master:mapping', 'delete') ? `<span class="sc-link" data-rm="${x.id}">Remove</span>` : ''}</td></tr>`).join('') || '<tr><td colspan="3" class="sc-muted">No customers mapped</td></tr>'}</tbody></table>`;
|
||
qa('[data-rm]', R).forEach(el => { el.onclick = async () => { await api('/admin/sp-map/' + el.dataset.rm, { method: 'DELETE' }).catch(e => toast(e.message)); load(); }; }); };
|
||
if (q('#m-q', R)) ac(q('#m-q', R), q('#m-ac', R), async c => { await api(`/admin/sales-persons/${p.id}/customers`, { method: 'POST', body: { cardCodes: [c.cardCode] } }).catch(e => toast(e.message)); q('#m-q', R).value = ''; load(); });
|
||
load();
|
||
}
|
||
}
|
||
|
||
// ── Customer logins ─────────────────────────────────────────────────────────
|
||
async function customers() {
|
||
const canEdit = isAdmin() || cap('sales_master:customers', 'edit') || cap('sales_master:customers', 'add');
|
||
pane().innerHTML = `<div class="sc-info">Customers log in at <a class="sc-link" href="/customer-portal" target="_blank">/customer-portal</a> with their <b>SAP customer code</b>. Name, addresses and sales person come live from SAP.
|
||
Passwords migrated from msale keep working and are upgraded to a secure hash at first login. 5 wrong passwords lock the login.</div>
|
||
<div class="card"><div class="card-hd">Customer logins <span class="r"><input class="sc-in" id="c-q" placeholder="Search code / name…" style="width:240px"><button class="sc-btn sc-b-ghost" id="c-go">Search</button>
|
||
${canEdit ? '<button class="sc-btn sc-b-primary" id="c-new">+ Enable a customer</button>' : ''}</span></div><div class="tbl-wrap" id="c-t"></div></div>`;
|
||
const load = async () => { q('#c-t').innerHTML = '<div class="empty"><span class="sc-spin"></span></div>';
|
||
const l = await api('/admin/customers?q=' + encodeURIComponent(q('#c-q').value.trim()));
|
||
q('#c-t').innerHTML = `<table class="lst"><thead><tr><th>Code</th><th>Name</th><th>Email</th><th>Active</th><th>Locked</th><th>Last login</th><th class="n">Credit limit</th></tr></thead><tbody>
|
||
${l.map(c => `<tr class="click ${c.active ? '' : 'off'}" data-cc="${esc(c.cardCode)}"><td class="mono">${esc(c.cardCode)}</td><td>${esc(c.cardName)}</td><td>${esc(c.email)}</td><td>${c.active ? 'Yes' : 'No'}</td>
|
||
<td>${c.locked ? '<b style="color:var(--red)">Locked</b>' : ''}</td><td>${dt(c.lastLogin)}</td><td class="n">${money(c.creditLimit)}</td></tr>`).join('') || '<tr><td colspan="7" class="empty">No customers (max 500 shown — search to narrow)</td></tr>'}</tbody></table>`;
|
||
qa('#c-t tr[data-cc]').forEach(tr => { tr.onclick = () => edit(l.find(c => c.cardCode === tr.dataset.cc)); }); };
|
||
q('#c-go').onclick = load; q('#c-q').onkeydown = e => { if (e.key === 'Enter') load(); };
|
||
if (q('#c-new')) q('#c-new').onclick = () => edit(null);
|
||
load();
|
||
function edit(c) {
|
||
const isNew = !c; c = c || { cardCode: '', cardName: '', email: '', active: true, locked: false, creditLimit: 0 };
|
||
const m = SC.modal({ title: isNew ? 'Enable customer login' : `Customer login — ${esc(c.cardCode)}`, body: `
|
||
${isNew ? `<div class="ac"><label class="sc-lbl">SAP customer *</label><input class="sc-in" id="k-q" placeholder="Type code or name…" autocomplete="off"><div class="ac-list" id="k-ac" style="display:none"></div></div>` : `<div class="sc-kv"><b>Customer</b><span>${esc(c.cardName)} (${esc(c.cardCode)})</span></div>`}
|
||
<div class="sc-row" style="margin-top:8px"><div><label class="sc-lbl">Notification email</label><input class="sc-in" id="k-e" value="${esc(c.email)}"></div><div><label class="sc-lbl">Credit limit (₹)</label><input class="sc-in" type="number" min="0" id="k-cl" value="${c.creditLimit}"></div></div>
|
||
<div class="sc-row" style="margin-top:8px"><div><label class="sc-lbl">${isNew ? 'Initial password *' : 'Reset password (leave blank to keep)'}</label><input class="sc-in" id="k-p" type="text" autocomplete="off"></div>
|
||
<div><label class="sc-lbl">Active</label><select class="sc-in" id="k-a"><option value="1">Yes</option><option value="0" ${c.active ? '' : 'selected'}>No</option></select></div>
|
||
<div><label class="sc-lbl">Locked</label><select class="sc-in" id="k-l"><option value="0">No</option><option value="1" ${c.locked ? 'selected' : ''}>Yes</option></select></div></div>
|
||
<label class="chk" style="margin-top:8px"><input type="checkbox" id="k-mail" checked> Email the portal link, customer code and ${isNew ? 'initial' : 'new'} password to the customer${ME.settings.emailTestMode !== false ? ` <b style="color:var(--amber)">(Test mode — goes to ${esc(ME.settings.emailTestRecipient || 'nobody')} only)</b>` : ''}</label>
|
||
<div class="sc-muted" style="margin-top:6px">A new / reset password must be changed by the customer at next login. The email is sent only when a login is created or its password is reset.</div>`,
|
||
buttons: [{ label: 'Cancel' }, ...(canEdit ? [{ label: 'Save', cls: 'sc-b-primary', onClick: async ({ root }) => {
|
||
if (!c.cardCode) throw new Error('Select a customer');
|
||
const r = await api('/admin/customers', { method: 'POST', body: { cardCode: c.cardCode, cardName: c.cardName, email: q('#k-e', root).value, creditLimit: q('#k-cl', root).value, password: q('#k-p', root).value,
|
||
active: q('#k-a', root).value === '1', locked: q('#k-l', root).value === '1', sendEmail: q('#k-mail', root).checked } });
|
||
toast(r.emailed ? `Saved — login email sent${ME.settings.emailTestMode !== false ? ' (test mode: to ' + ME.settings.emailTestRecipient + ')' : ''}` : 'Saved', 'ok'); load(); } }] : [])] });
|
||
if (isNew) ac(q('#k-q', m.root), q('#k-ac', m.root), x => { c.cardCode = x.cardCode; c.cardName = x.cardName; q('#k-q', m.root).value = `${x.cardName} (${x.cardCode})`; if (!q('#k-e', m.root).value) q('#k-e', m.root).value = x.email; });
|
||
}
|
||
}
|
||
|
||
// ── Divisions ───────────────────────────────────────────────────────────────
|
||
async function divisions() {
|
||
const list = await api('/admin/divisions');
|
||
pane().innerHTML = `<div class="sc-info">Product categories = msale's <i>product_type</i>. <b>SAP item groups</b> are used when Product source = SAP; <b>Warehouse</b> is set on every SAP order line (blank = SAP default).</div>
|
||
<div class="card"><div class="card-hd">Product categories <span class="r"><button class="sc-btn sc-b-primary" id="d-new">+ Category</button></span></div><div class="tbl-wrap"><table class="lst"><thead><tr><th>ID</th><th>Name</th><th>Code</th><th>SAP item groups</th><th>Warehouse</th><th>Sort</th><th>Enabled</th></tr></thead><tbody>
|
||
${list.map(d => `<tr class="click ${d.active ? '' : 'off'}" data-id="${d.id}"><td>${d.id}</td><td><b>${esc(d.name)}</b></td><td>${esc(d.code)}</td><td>${esc(d.itemGroups)}</td><td>${esc(d.warehouse)}</td><td>${d.sort}</td>
|
||
<td><label class="sw" title="${d.active ? 'Enabled — click to disable' : 'Disabled — click to enable'}"><input type="checkbox" class="d-tg" data-id="${d.id}" ${d.active ? 'checked' : ''}><span></span></label>
|
||
<span class="sw-l ${d.active ? 'on' : ''}">${d.active ? 'Enabled' : 'Disabled'}</span></td></tr>`).join('')}</tbody></table></div></div>
|
||
<div class="sc-muted">A <b>disabled</b> category is hidden when customers or employees create new orders / sample requests, and from the category filters. Orders already raised in it keep flowing (approval, payment, SAP posting, invoices) and stay visible.</div>`;
|
||
qa('.d-tg').forEach(cb => {
|
||
const td = cb.closest('td'); td.onclick = e => e.stopPropagation(); // don't open the edit form
|
||
cb.onchange = async () => {
|
||
const d = list.find(x => x.id === +cb.dataset.id); const on = cb.checked;
|
||
if (!on && !(await SC.confirmBox(`Disable ${esc(d.name)}?`, `Customers and employees will no longer be able to place new orders or sample requests in <b>${esc(d.name)}</b>. Existing orders are not affected.`, 'Disable', 'sc-b-red'))) { cb.checked = true; return; }
|
||
cb.disabled = true;
|
||
try {
|
||
const r = await api(`/admin/divisions/${d.id}/active`, { method: 'PUT', body: { active: on } });
|
||
toast(`${d.name} ${on ? 'enabled' : 'disabled'}${!on && r.openOrders ? ` — ${r.openOrders} open order(s) in it continue as normal` : ''}`, 'ok');
|
||
ME = await api('/me'); divisions();
|
||
} catch (e) { toast(e.message); cb.checked = !on; cb.disabled = false; }
|
||
};
|
||
});
|
||
const edit = d => { const isNew = !d; d = d || { id: '', name: '', code: '', itemGroups: '', warehouse: '01', sort: list.length + 1, active: true };
|
||
SC.modal({ title: isNew ? 'New category' : `Category ${d.id}`, body: `<div class="sc-row"><div><label class="sc-lbl">ID *</label><input class="sc-in" id="v-id" value="${d.id}" ${isNew ? '' : 'disabled'}></div><div><label class="sc-lbl">Name *</label><input class="sc-in" id="v-n" value="${esc(d.name)}"></div><div><label class="sc-lbl">Code</label><input class="sc-in" id="v-c" value="${esc(d.code)}"></div></div>
|
||
<div class="sc-row" style="margin-top:8px"><div><label class="sc-lbl">SAP item groups (comma separated)</label><input class="sc-in" id="v-g" value="${esc(d.itemGroups)}"></div><div><label class="sc-lbl">Warehouse</label><input class="sc-in" id="v-w" value="${esc(d.warehouse)}"></div>
|
||
<div><label class="sc-lbl">Sort</label><input class="sc-in" type="number" id="v-s" value="${d.sort}"></div><div><label class="sc-lbl">Active</label><select class="sc-in" id="v-a"><option value="1">Yes</option><option value="0" ${d.active ? '' : 'selected'}>No</option></select></div></div>`,
|
||
buttons: [{ label: 'Cancel' }, { label: 'Save', cls: 'sc-b-primary', onClick: async ({ root }) => {
|
||
await api('/admin/divisions', { method: 'POST', body: { id: q('#v-id', root).value, name: q('#v-n', root).value, code: q('#v-c', root).value, itemGroups: q('#v-g', root).value, warehouse: q('#v-w', root).value, sort: q('#v-s', root).value, active: q('#v-a', root).value === '1' } });
|
||
toast('Saved', 'ok'); ME = await api('/me'); divisions(); } }] }); };
|
||
qa('tr[data-id]').forEach(tr => { tr.onclick = () => edit(list.find(d => d.id === +tr.dataset.id)); });
|
||
q('#d-new').onclick = () => edit(null);
|
||
}
|
||
|
||
// ── Settings ────────────────────────────────────────────────────────────────
|
||
async function settings() {
|
||
const s = await api('/admin/settings');
|
||
const companies = window.fetchCompanyList ? await window.fetchCompanyList().catch(() => []) : [];
|
||
const f = (k, l, type = 'text', extra = '') => `<div><label class="sc-lbl">${l}</label><input class="sc-in" id="g-${k}" type="${type}" value="${esc(s[k] == null ? '' : s[k])}" ${extra}></div>`;
|
||
pane().innerHTML = `<div class="card"><div class="card-hd">SAP & products</div><div class="card-bd">
|
||
<div class="sc-row"><div><label class="sc-lbl">SAP company for sales orders</label><select class="sc-in" id="g-company">${(companies.length ? companies : [{ value: s.company, label: s.company }]).map(c => `<option value="${esc(c.value)}" ${c.value === s.company ? 'selected' : ''}>${esc(c.label || c.value)}</option>`).join('')}</select></div>
|
||
<div><label class="sc-lbl">Product source</label><select class="sc-in" id="g-productSource"><option value="catalog">ERP catalog (migrated msale products & prices)</option><option value="sap" ${s.productSource === 'sap' ? 'selected' : ''}>SAP items + SAP price list</option></select></div>
|
||
${f('sapPriceList', 'SAP price list no. (SAP source)', 'number')}${f('sapSeries', 'SAP ORDR series (blank = default)', 'number')}</div>
|
||
<label class="chk" style="margin-top:10px"><input type="checkbox" id="g-autoPostToSap" ${s.autoPostToSap ? 'checked' : ''}> Auto-post orders to SAP (shared .env SAP account) as soon as they are ready — otherwise a user with "Post to SAP" does it</label></div></div>
|
||
<div class="card"><div class="card-hd">GST</div><div class="card-bd"><div class="sc-row">${f('companyState', 'Company state code (intra-state)')}${f('intraTaxCode', 'Intra-state SAP tax code')}${f('cgstRate', 'CGST %', 'number', 'step="0.01"')}${f('sgstRate', 'SGST %', 'number', 'step="0.01"')}</div>
|
||
<div class="sc-row" style="margin-top:8px">${f('interTaxCode', 'Inter-state SAP tax code')}${f('igstRate', 'IGST %', 'number', 'step="0.01"')}${f('tcsRate', 'TCS % (0 = off)', 'number', 'step="0.001"')}</div>
|
||
<div class="sc-muted" style="margin-top:6px">Tax is decided by the Ship-To state. Existing orders keep the rates they were created with.</div></div></div>
|
||
<div class="card"><div class="card-hd">Payments & email</div><div class="card-bd">
|
||
<label class="chk" style="margin-bottom:8px"><input type="checkbox" id="g-emailNotifications" ${s.emailNotifications !== false ? 'checked' : ''}> Send Sales email notifications (customers, BUH, Logistics, Accounts, sales persons). The ERP-wide switch in Admin → System Settings → Stage-Change Email Notifications must also be on.</label>
|
||
<label class="chk" style="margin-bottom:8px"><input type="checkbox" id="g-invoiceEmail" ${s.invoiceEmail !== false ? 'checked' : ''}> Email "invoice raised" (invoice no., amount, items, LR details) to the customer, sales person, BUH, Logistics & Accounts when SAP invoices an order
|
||
<span class="sc-muted">— checked every 15 min; invoices dated before ${esc(s.invoiceEmailSince || '—')} are not emailed</span></label>
|
||
<div class="sc-warn" style="color:inherit"><label class="chk" style="margin:0"><input type="checkbox" id="g-emailTestMode" ${s.emailTestMode !== false ? 'checked' : ''}> <b>Test mode</b> — send every Sales email ONLY to the test address below (real recipients are listed inside the email). Untick at go-live.</label>
|
||
<div style="margin-top:6px"><label class="sc-lbl">Test recipient(s), comma separated</label><input class="sc-in" id="g-emailTestRecipient" value="${esc(s.emailTestRecipient || '')}"></div></div>
|
||
<div class="sc-row">${f('roundingTolerance', 'Under-payment tolerance (₹, written off as rebate)', 'number')}${f('orderEmailCc', 'Extra CC on every order email')}</div>
|
||
<div class="sc-row" style="margin-top:8px">${f('bankApiUrl', 'Bank auto-match API URL (optional — msale sapapi check_payment)')}${f('bankApiKey', 'Bank API key', 'password')}</div></div></div>
|
||
<button class="sc-btn sc-b-primary" id="g-save">Save settings</button>`;
|
||
q('#g-save').onclick = async () => {
|
||
const b = { company: q('#g-company').value, productSource: q('#g-productSource').value, autoPostToSap: q('#g-autoPostToSap').checked, emailNotifications: q('#g-emailNotifications').checked, invoiceEmail: q('#g-invoiceEmail').checked,
|
||
emailTestMode: q('#g-emailTestMode').checked, emailTestRecipient: q('#g-emailTestRecipient').value.trim() };
|
||
if (!b.emailTestMode && !(await SC.confirmBox('Turn off email test mode?', 'Sales emails will now go to <b>real customers and employees</b>. Continue?', 'Yes, go live', 'sc-b-red'))) return;
|
||
if (b.emailTestMode && !b.emailTestRecipient) return toast('Enter a test recipient, or untick test mode');
|
||
['sapPriceList', 'sapSeries', 'cgstRate', 'sgstRate', 'igstRate', 'tcsRate', 'roundingTolerance'].forEach(k => { const v = q('#g-' + k).value; b[k] = v === '' ? null : +v; });
|
||
['companyState', 'intraTaxCode', 'interTaxCode', 'orderEmailCc', 'bankApiUrl', 'bankApiKey'].forEach(k => { b[k] = q('#g-' + k).value.trim(); });
|
||
try { await api('/admin/settings', { method: 'PUT', body: b }); toast('Settings saved', 'ok'); ME = await api('/me'); } catch (e) { toast(e.message); }
|
||
};
|
||
}
|
||
|
||
init().catch(e => { q('#app').innerHTML = `<div class="sc-err">${esc(e.message)}</div>`; });
|
||
</script>
|
||
<script src="/company-list.js"></script>
|
||
<script src="/sidebar.js?v=31"></script>
|
||
</body>
|
||
</html>
|