Files
sap-erp/public/sales-admin.html
T
John eead8f5ffd
SAP-ERP Portal CI/CD / build (push) Successful in 3m57s
sale order
2026-10-05 18:45:17 +05:30

336 lines
41 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>