363 lines
34 KiB
JavaScript
363 lines
34 KiB
JavaScript
/* public/sales-common.js — shared UI helpers for the Sales Order module
|
||
* (employee pages + customer portal). Exposes window.SC.
|
||
* Configure before use: SC.base ('/api/sales' or '/api/sales-portal') and
|
||
* SC.getToken (fn returning the bearer token), SC.onUnauthorized.
|
||
*/
|
||
(function () {
|
||
'use strict';
|
||
const SC = window.SC = {};
|
||
SC.base = '/api/sales';
|
||
SC.getToken = () => { try { return sessionStorage.getItem('portal_token'); } catch (_e) { return null; } };
|
||
SC.onUnauthorized = () => { location.href = '/'; };
|
||
|
||
// ── styles ────────────────────────────────────────────────────────────────
|
||
const css = `
|
||
.sc-modal-bg{position:fixed;inset:0;background:rgba(3,8,16,.62);z-index:900;display:flex;align-items:flex-start;justify-content:center;overflow-y:auto;padding:28px 10px}
|
||
.sc-modal{background:var(--surface,#0f1d30);border:1px solid var(--border2,#243650);border-radius:12px;width:100%;max-width:760px;box-shadow:0 18px 60px rgba(0,0,0,.55);color:var(--text,#c8d6e8)}
|
||
.sc-modal.wide{max-width:1180px}
|
||
.sc-mh{display:flex;align-items:center;gap:10px;padding:12px 16px;border-bottom:1px solid var(--border,#1c2e48);font-weight:700;font-size:14px}
|
||
.sc-mh .x{margin-left:auto;background:none;border:none;color:var(--text2,#7a9ab8);font-size:20px;cursor:pointer;line-height:1}
|
||
.sc-mb{padding:14px 16px}
|
||
.sc-mf{display:flex;gap:8px;justify-content:flex-end;padding:12px 16px;border-top:1px solid var(--border,#1c2e48);flex-wrap:wrap}
|
||
.sc-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:10px}
|
||
.sc-kv{font-size:12px}.sc-kv b{display:block;font-size:9.5px;text-transform:uppercase;letter-spacing:.4px;color:var(--text3,#3d5470);margin-bottom:2px;font-weight:700}
|
||
.sc-kv span{white-space:pre-wrap}
|
||
.sc-sec{margin-top:14px;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:var(--text2,#7a9ab8);border-bottom:1px solid var(--border,#1c2e48);padding-bottom:5px;margin-bottom:8px}
|
||
.sc-tbl{width:100%;border-collapse:collapse;font-size:12px}
|
||
.sc-tbl th{text-align:left;padding:6px 8px;font-size:9.5px;text-transform:uppercase;letter-spacing:.3px;color:var(--text3,#3d5470);border-bottom:2px solid var(--border2,#243650);white-space:nowrap}
|
||
.sc-tbl td{padding:6px 8px;border-bottom:1px solid var(--border,#1c2e48);vertical-align:top}
|
||
.sc-tbl td.n,.sc-tbl th.n{text-align:right;font-family:var(--mono,monospace)}
|
||
.sc-tbl tr.tot td{font-weight:700;border-top:2px solid var(--border2,#243650)}
|
||
.sc-pill{display:inline-block;font-size:10px;font-weight:700;padding:2px 9px;border-radius:999px;white-space:nowrap}
|
||
.sc-in{width:100%;padding:7px 9px;border:1.5px solid var(--border2,#243650);border-radius:7px;font-size:12.5px;background:var(--surface2,#142338);color:var(--text,#c8d6e8);font-family:inherit;outline:none}
|
||
.sc-in:focus{border-color:var(--blue,#1a6fff)} textarea.sc-in{min-height:58px;resize:vertical}
|
||
.sc-lbl{font-size:9.5px;font-weight:700;text-transform:uppercase;letter-spacing:.4px;color:var(--text3,#3d5470);margin-bottom:3px;display:block}
|
||
.sc-btn{padding:7px 14px;border-radius:8px;border:none;font-size:12px;font-weight:700;cursor:pointer;font-family:inherit}
|
||
.sc-btn:disabled{opacity:.5;cursor:not-allowed}
|
||
.sc-b-primary{background:var(--blue,#1a6fff);color:#fff}.sc-b-green{background:var(--green,#0dbb8a);color:#fff}
|
||
.sc-b-red{background:var(--red,#e53e3e);color:#fff}.sc-b-amber{background:var(--amber,#f5a623);color:#111}
|
||
.sc-b-ghost{background:var(--surface2,#142338);color:var(--text2,#7a9ab8);border:1px solid var(--border2,#243650)}
|
||
.sc-remarks{white-space:pre-wrap;font-size:12px;background:var(--surface3,#0b1520);border:1px solid var(--border,#1c2e48);border-radius:8px;padding:8px 10px;max-height:160px;overflow:auto}
|
||
.sc-warn{background:rgba(245,166,35,.12);border:1px solid rgba(245,166,35,.4);color:var(--amber,#f5a623);border-radius:8px;padding:8px 10px;font-size:12px;margin:8px 0}
|
||
.sc-info{background:rgba(26,111,255,.1);border:1px solid rgba(26,111,255,.35);border-radius:8px;padding:8px 10px;font-size:12px;margin:8px 0}
|
||
.sc-err{background:rgba(229,62,62,.12);border:1px solid rgba(229,62,62,.4);color:#ff8a8a;border-radius:8px;padding:8px 10px;font-size:12px;margin:8px 0;white-space:pre-wrap}
|
||
.sc-diff{background:rgba(229,62,62,.14)!important}
|
||
.sc-toast-c{position:fixed;top:14px;right:10px;z-index:2000;display:flex;flex-direction:column;gap:6px}
|
||
.sc-toast{padding:9px 13px;border-radius:8px;font-size:12px;font-weight:600;box-shadow:0 8px 24px rgba(0,0,0,.4);max-width:420px}
|
||
.sc-t-err{background:#e53e3e;color:#fff}.sc-t-ok{background:#0dbb8a;color:#fff}
|
||
.sc-row{display:flex;gap:10px;flex-wrap:wrap}.sc-row>*{flex:1;min-width:160px}
|
||
.sc-link{color:var(--blue,#1a6fff);cursor:pointer;font-weight:600;text-decoration:none}.sc-link:hover{text-decoration:underline}
|
||
.sc-muted{color:var(--text3,#3d5470);font-size:11px}
|
||
.sc-ps{position:relative}
|
||
.sc-ps-list{position:absolute;z-index:60;left:0;right:0;top:100%;margin-top:2px;background:var(--surface2,#142338);border:1px solid var(--border2,#243650);border-radius:8px;max-height:300px;overflow:auto;box-shadow:0 10px 30px rgba(0,0,0,.45)}
|
||
.sc-ps-list div{padding:7px 10px;font-size:12px;cursor:pointer;border-bottom:1px solid var(--border,#1c2e48);display:flex;gap:6px;align-items:baseline}
|
||
.sc-ps-list div.on,.sc-ps-list div:hover{background:rgba(26,111,255,.14)}
|
||
.sc-ps-price{margin-left:auto;white-space:nowrap;color:var(--text2,#7a9ab8);font-family:var(--mono,monospace);font-size:11px}
|
||
.sc-tl{font-size:11.5px}.sc-tl div{padding:4px 0;border-bottom:1px dashed var(--border,#1c2e48)}
|
||
.sc-spin{width:14px;height:14px;border:2px solid rgba(26,111,255,.25);border-top-color:#1a6fff;border-radius:50%;animation:scspin .6s linear infinite;display:inline-block;vertical-align:middle}
|
||
@keyframes scspin{to{transform:rotate(360deg)}}
|
||
@media (max-width:640px){.sc-modal-bg{padding:0}.sc-modal{border-radius:0;min-height:100vh}.sc-tbl{font-size:11px}}`;
|
||
const st = document.createElement('style'); st.textContent = css; document.head.appendChild(st);
|
||
|
||
// ── basics ────────────────────────────────────────────────────────────────
|
||
SC.esc = v => (v == null ? '' : String(v)).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||
SC.money = v => (v == null || v === '' ? '—' : '₹ ' + Number(v).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }));
|
||
SC.num = v => (v == null || v === '' ? '' : Number(v).toLocaleString('en-IN', { maximumFractionDigits: 3 }));
|
||
const p2 = n => String(n).padStart(2, '0');
|
||
SC.date = v => { if (!v) return '—'; const d = new Date(v); return isNaN(d) ? '—' : `${p2(d.getDate())}-${p2(d.getMonth() + 1)}-${d.getFullYear()}`; };
|
||
SC.dt = v => { if (!v) return '—'; const d = new Date(v); return isNaN(d) ? '—' : `${SC.date(d)} ${p2(d.getHours())}:${p2(d.getMinutes())}`; };
|
||
SC.iso = v => { if (!v) return ''; const d = new Date(v); return isNaN(d) ? '' : `${d.getFullYear()}-${p2(d.getMonth() + 1)}-${p2(d.getDate())}`; };
|
||
SC.today = () => SC.iso(new Date());
|
||
SC.q = (s, root) => (root || document).querySelector(s);
|
||
SC.qa = (s, root) => Array.from((root || document).querySelectorAll(s));
|
||
|
||
SC.toast = (m, kind) => {
|
||
let c = SC.q('.sc-toast-c'); if (!c) { c = document.createElement('div'); c.className = 'sc-toast-c'; document.body.appendChild(c); }
|
||
const e = document.createElement('div'); e.className = 'sc-toast ' + (kind === 'ok' ? 'sc-t-ok' : 'sc-t-err'); e.textContent = m; c.appendChild(e);
|
||
setTimeout(() => e.remove(), kind === 'ok' ? 3500 : 6500);
|
||
};
|
||
|
||
SC.api = async (p, opts = {}) => {
|
||
const headers = { Authorization: 'Bearer ' + SC.getToken() };
|
||
const o = { method: opts.method || 'GET', headers };
|
||
if (opts.form) o.body = opts.form;
|
||
else if (opts.body !== undefined) { headers['Content-Type'] = 'application/json'; o.body = JSON.stringify(opts.body); }
|
||
const r = await fetch(SC.base + p, o);
|
||
if (r.status === 401) { SC.onUnauthorized(); throw new Error('Session expired'); }
|
||
const d = await r.json().catch(() => ({ success: false, message: 'HTTP ' + r.status }));
|
||
if (!r.ok || d.success === false) throw new Error(d.message || ('HTTP ' + r.status));
|
||
return d.data;
|
||
};
|
||
// Open an authenticated file (doc / COA / invoice PDF) in a new tab.
|
||
SC.openFile = async (p) => {
|
||
const w = window.open('', '_blank'); if (w) w.document.write('<p style="font-family:sans-serif">Loading…</p>');
|
||
try {
|
||
const r = await fetch(SC.base + p, { headers: { Authorization: 'Bearer ' + SC.getToken() } });
|
||
if (!r.ok) { const d = await r.json().catch(() => ({})); throw new Error(d.message || 'HTTP ' + r.status); }
|
||
const url = URL.createObjectURL(await r.blob());
|
||
if (w) w.location.href = url; else window.open(url, '_blank');
|
||
} catch (e) { if (w) w.close(); SC.toast(e.message); }
|
||
};
|
||
SC.upload = async (file, fields) => {
|
||
const fd = new FormData(); fd.append('file', file); Object.entries(fields).forEach(([k, v]) => fd.append(k, v));
|
||
return SC.api('/docs', { method: 'POST', form: fd });
|
||
};
|
||
SC.csv = (filename, headers, rows) => {
|
||
const cell = v => { const s = v == null ? '' : String(v); return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; };
|
||
const text = [headers.map(cell).join(','), ...rows.map(r => r.map(cell).join(','))].join('\r\n');
|
||
const a = document.createElement('a'); a.href = URL.createObjectURL(new Blob(['' + text], { type: 'text/csv' })); a.download = filename; a.click();
|
||
};
|
||
|
||
// ── show/hide password (eye button) ───────────────────────────────────────
|
||
const EYE = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></svg>';
|
||
const EYE_OFF = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 19c-6.5 0-10-7-10-7a18.45 18.45 0 0 1 5.06-5.94"/><path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c6.5 0 10 7 10 7a18.5 18.5 0 0 1-2.16 3.19"/><path d="M14.12 14.12a3 3 0 1 1-4.24-4.24"/><line x1="2" y1="2" x2="22" y2="22"/></svg>';
|
||
const eyeCss = document.createElement('style');
|
||
eyeCss.textContent = `.sc-pw{position:relative;display:block}.sc-pw>input{padding-right:42px!important}
|
||
.sc-pw-eye{position:absolute;right:5px;top:50%;transform:translateY(-50%);width:32px;height:30px;border:none;background:transparent;cursor:pointer;color:var(--text2,#7a9ab8);display:flex;align-items:center;justify-content:center;border-radius:6px}
|
||
.sc-pw-eye:hover{color:var(--blue,#1a6fff);background:rgba(26,111,255,.1)}`;
|
||
document.head.appendChild(eyeCss);
|
||
// Wrap each password input in `root` with an eye toggle (idempotent).
|
||
SC.eyeify = (root) => {
|
||
SC.qa('input[type="password"]', root).forEach(inp => {
|
||
if (inp.parentElement.classList.contains('sc-pw')) return;
|
||
// Move the input's bottom margin onto the wrapper so the eye stays vertically centred on the field.
|
||
const mb = getComputedStyle(inp).marginBottom; inp.style.marginBottom = '0';
|
||
const w = document.createElement('span'); w.className = 'sc-pw'; if (mb && mb !== '0px') w.style.marginBottom = mb;
|
||
inp.parentNode.insertBefore(w, inp); w.appendChild(inp);
|
||
const b = document.createElement('button'); b.type = 'button'; b.className = 'sc-pw-eye'; b.title = 'Show password'; b.setAttribute('aria-label', 'Show password'); b.innerHTML = EYE;
|
||
b.addEventListener('mousedown', e => e.preventDefault());
|
||
b.addEventListener('click', () => { const show = inp.type === 'password'; inp.type = show ? 'text' : 'password'; b.innerHTML = show ? EYE_OFF : EYE;
|
||
b.title = show ? 'Hide password' : 'Show password'; b.setAttribute('aria-label', b.title); inp.focus(); });
|
||
w.appendChild(b);
|
||
});
|
||
};
|
||
|
||
// ── status pills ──────────────────────────────────────────────────────────
|
||
const COLORS = { 1: '#f5a623', 2: '#06b6d4', 3: '#06b6d4', 4: '#9f5afd', 5: '#ec4899', 6: '#f5a623', 7: '#1a6fff', 8: '#0dbb8a', 9: '#64748b', 10: '#e53e3e', 11: '#e53e3e' };
|
||
SC.pill = (status, label) => { const c = COLORS[status] || '#64748b'; return `<span class="sc-pill" style="background:${c}22;color:${c};border:1px solid ${c}55">${SC.esc(label)}</span>`; };
|
||
SC.typeLabel = o => (o.orderType === 'DIRECT' ? 'Direct' : o.soType === 1 ? 'Trade' : 'Institution');
|
||
|
||
// ── modal (closes ONLY via its buttons — never on backdrop click) ─────────
|
||
SC.modal = ({ title, body, wide, buttons = [] }) => {
|
||
const bg = document.createElement('div'); bg.className = 'sc-modal-bg';
|
||
bg.innerHTML = `<div class="sc-modal ${wide ? 'wide' : ''}"><div class="sc-mh"><span>${title}</span><button class="x" title="Close">×</button></div>
|
||
<div class="sc-mb">${body}</div>${buttons.length ? '<div class="sc-mf"></div>' : ''}</div>`;
|
||
document.body.appendChild(bg); document.body.style.overflow = 'hidden';
|
||
const close = () => { bg.remove(); if (!SC.q('.sc-modal-bg')) document.body.style.overflow = ''; };
|
||
SC.q('.x', bg).onclick = close;
|
||
const f = SC.q('.sc-mf', bg);
|
||
buttons.forEach(b => {
|
||
const el = document.createElement('button'); el.className = 'sc-btn ' + (b.cls || 'sc-b-ghost'); el.textContent = b.label;
|
||
el.onclick = async () => {
|
||
if (!b.onClick) return close();
|
||
el.disabled = true; const old = el.textContent; el.innerHTML = '<span class="sc-spin"></span> ' + SC.esc(old);
|
||
try { const keep = await b.onClick({ close, root: bg }); if (keep !== true) close(); }
|
||
catch (e) { SC.toast(e.message); } finally { el.disabled = false; el.textContent = old; }
|
||
};
|
||
f.appendChild(el);
|
||
});
|
||
return { root: bg, close };
|
||
};
|
||
SC.confirmBox = (title, html, okLabel, okCls) => new Promise(res => {
|
||
SC.modal({ title, body: html, buttons: [{ label: 'Back', onClick: () => res(false) }, { label: okLabel || 'Confirm', cls: okCls || 'sc-b-primary', onClick: () => res(true) }] });
|
||
});
|
||
|
||
// ── tax/total preview (server recomputes authoritatively) ────────────────
|
||
SC.taxFor = (settings, shipState) => {
|
||
const intra = String(shipState || '').toUpperCase() === String(settings.companyState || '').toUpperCase();
|
||
return intra ? { taxCode: settings.intraTaxCode, igst: 0, cgst: +settings.cgstRate || 0, sgst: +settings.sgstRate || 0, tcs: +settings.tcsRate || 0 }
|
||
: { taxCode: settings.interTaxCode, igst: +settings.igstRate || 0, cgst: 0, sgst: 0, tcs: +settings.tcsRate || 0 };
|
||
};
|
||
const r2 = n => Math.round((+n || 0) * 100) / 100;
|
||
SC.totals = (lines, tax) => {
|
||
const sub = r2(lines.reduce((a, l) => a + l.lineTotal, 0));
|
||
const igst = r2(sub * tax.igst / 100), cgst = r2(sub * tax.cgst / 100), sgst = r2(sub * tax.sgst / 100);
|
||
const tcs = tax.tcs > 0 ? r2((sub + igst + cgst + sgst) * tax.tcs / 100) : 0;
|
||
return { sub, igst, cgst, sgst, tcs, grand: r2(sub + igst + cgst + sgst + tcs) };
|
||
};
|
||
SC.totalsRows = (t, tax, colspan) => `
|
||
<tr class="tot"><td colspan="${colspan}" class="n">Total before taxes</td><td class="n">${SC.money(t.sub)}</td></tr>
|
||
${tax.igst ? `<tr><td colspan="${colspan}" class="n">IGST @ ${tax.igst}%</td><td class="n">${SC.money(t.igst)}</td></tr>` : ''}
|
||
${tax.cgst ? `<tr><td colspan="${colspan}" class="n">CGST @ ${tax.cgst}%</td><td class="n">${SC.money(t.cgst)}</td></tr>` : ''}
|
||
${tax.sgst ? `<tr><td colspan="${colspan}" class="n">SGST @ ${tax.sgst}%</td><td class="n">${SC.money(t.sgst)}</td></tr>` : ''}
|
||
${t.tcs ? `<tr><td colspan="${colspan}" class="n">TCS @ ${tax.tcs}%</td><td class="n">${SC.money(t.tcs)}</td></tr>` : ''}
|
||
<tr class="tot"><td colspan="${colspan}" class="n">Grand total</td><td class="n">${SC.money(t.grand)}</td></tr>`;
|
||
|
||
// ── cart editor ───────────────────────────────────────────────────────────
|
||
// container: element; products: [{key,displayCode,description,price,packSize,hsn}]
|
||
// opts: { lines:[{key,qty,specialPrice,commission}], allowSpecial, allowCommission, settings, getShipState }
|
||
SC.cartEditor = (container, products, opts = {}) => {
|
||
const st = { lines: (opts.lines || []).map(l => ({ ...l })) };
|
||
const byKey = Object.fromEntries(products.map(p => [p.key, p]));
|
||
const compute = () => st.lines.map(l => {
|
||
const p = byKey[l.key] || {}; const eff = +l.specialPrice > 0 ? +l.specialPrice : +p.price || 0;
|
||
return { ...l, p, nop: Math.max(1, Math.ceil((+l.qty || 0) / (p.packSize || 1))), lineTotal: r2((+l.qty || 0) * eff), diff: +l.specialPrice > 0 && Math.abs(+l.specialPrice - (+p.price || 0)) > 0.0001 };
|
||
});
|
||
// Searchable product picker: matches every typed word against item code,
|
||
// display code and description; ↑/↓ to move, Enter to pick, Esc to close.
|
||
function wirePicker() {
|
||
const qi = SC.q('.sc-prod-q', container), hid = SC.q('.sc-prod', container), list = SC.q('.sc-ps-list', container);
|
||
if (!qi) return;
|
||
let hits = [], hi = 0;
|
||
const label = p => `${p.displayCode} — ${p.description}`;
|
||
const draw = () => {
|
||
const words = qi.value.toLowerCase().split(/\s+/).filter(Boolean);
|
||
hits = products.filter(p => { const s = `${p.displayCode} ${p.itemCode} ${p.description} ${p.shortDesc || ''}`.toLowerCase(); return words.every(w => s.includes(w)); }).slice(0, 60);
|
||
hi = Math.min(hi, Math.max(0, hits.length - 1));
|
||
list.innerHTML = hits.length ? hits.map((p, i) => `<div data-k="${SC.esc(p.key)}" class="${i === hi ? 'on' : ''}"><b class="mono">${SC.esc(p.displayCode)}</b> — ${SC.esc(p.description)}
|
||
<span class="sc-ps-price">${SC.money(p.price)}${st.lines.some(l => l.key === p.key) ? ' · in cart' : ''}</span></div>`).join('')
|
||
: '<div class="sc-muted" style="cursor:default">No matching product in this category</div>';
|
||
list.style.display = 'block';
|
||
SC.qa('[data-k]', list).forEach(el => { el.onmousedown = e => { e.preventDefault(); pick(el.dataset.k); }; });
|
||
};
|
||
const pick = key => { const p = byKey[key]; if (!p) return; hid.value = key; qi.value = label(p); list.style.display = 'none'; const qq = SC.q('.sc-qty', container); qq.focus(); qq.select(); };
|
||
qi.oninput = () => { hid.value = ''; hi = 0; draw(); };
|
||
qi.onfocus = () => { if (!hid.value) draw(); };
|
||
qi.onblur = () => setTimeout(() => { list.style.display = 'none'; }, 120);
|
||
qi.onkeydown = e => {
|
||
if (list.style.display === 'none' && ['ArrowDown', 'ArrowUp'].includes(e.key)) { draw(); return; }
|
||
if (e.key === 'ArrowDown') { hi = Math.min(hi + 1, hits.length - 1); draw(); e.preventDefault(); }
|
||
else if (e.key === 'ArrowUp') { hi = Math.max(hi - 1, 0); draw(); e.preventDefault(); }
|
||
else if (e.key === 'Enter') { if (hits[hi]) pick(hits[hi].key); e.preventDefault(); }
|
||
else if (e.key === 'Escape') list.style.display = 'none';
|
||
};
|
||
SC.q('.sc-qty', container).onkeydown = e => { if (e.key === 'Enter') { e.preventDefault(); SC.q('.sc-add', container).click(); } };
|
||
}
|
||
function render() {
|
||
const ls = compute();
|
||
const tax = SC.taxFor(opts.settings || {}, opts.getShipState ? opts.getShipState() : '');
|
||
const t = SC.totals(ls, tax);
|
||
const cols = 6 + (opts.allowSpecial ? 1 : 0) + (opts.allowCommission ? 1 : 0);
|
||
container.innerHTML = `
|
||
<div class="sc-row" style="align-items:flex-end;margin-bottom:8px">
|
||
<div style="flex:3" class="sc-ps"><label class="sc-lbl">Product${opts.categoryName ? ` — ${SC.esc(opts.categoryName)}` : ''} (${products.length})</label>
|
||
<input class="sc-in sc-prod-q" autocomplete="off" ${products.length ? '' : 'disabled'}
|
||
placeholder="${products.length ? 'Search by item code or description…' : 'No products in this category'}">
|
||
<input type="hidden" class="sc-prod"><div class="sc-ps-list" style="display:none"></div></div>
|
||
<div style="flex:1;min-width:90px"><label class="sc-lbl">Qty</label><input class="sc-in sc-qty" type="number" min="1" step="1"></div>
|
||
<div style="flex:0 0 auto;min-width:auto"><button type="button" class="sc-btn sc-b-primary sc-add">+ Add</button></div>
|
||
</div>
|
||
<div style="overflow-x:auto"><table class="sc-tbl"><thead><tr><th>#</th><th>Item</th><th>Description</th><th class="n">Qty</th><th class="n">Packs</th><th class="n">Price</th>
|
||
${opts.allowSpecial ? '<th class="n">Special price</th>' : ''}${opts.allowCommission ? '<th class="n">Comm %</th>' : ''}<th class="n">Amount</th><th></th></tr></thead><tbody>
|
||
${ls.length ? ls.map((l, i) => `<tr data-i="${i}">
|
||
<td>${i + 1}</td><td class="mono">${SC.esc(l.p.displayCode || l.key)}</td><td style="min-width:160px">${SC.esc(l.p.description || '')}<div class="sc-muted">HSN ${SC.esc(l.p.hsn || '—')}</div></td>
|
||
<td class="n"><input class="sc-in sc-lq" type="number" min="1" step="1" value="${SC.esc(l.qty)}" style="width:90px;text-align:right"></td>
|
||
<td class="n">${l.nop}</td><td class="n">${SC.money(l.p.price)}</td>
|
||
${opts.allowSpecial ? `<td class="n ${l.diff ? 'sc-diff' : ''}"><input class="sc-in sc-lsp" type="number" min="0" step="0.01" value="${+l.specialPrice > 0 ? l.specialPrice : ''}" placeholder="—" style="width:100px;text-align:right"></td>` : ''}
|
||
${opts.allowCommission ? `<td class="n"><input class="sc-in sc-lcm" type="number" min="0" step="0.01" value="${+l.commission || ''}" style="width:70px;text-align:right"></td>` : ''}
|
||
<td class="n">${SC.money(l.lineTotal)}</td><td><button type="button" class="sc-btn sc-b-red sc-del" style="padding:3px 9px">×</button></td></tr>`).join('')
|
||
: `<tr><td colspan="${cols + 2}" class="sc-muted" style="text-align:center;padding:16px">No products added yet</td></tr>`}
|
||
${ls.length ? SC.totalsRows(t, tax, cols) + '<tr><td></td></tr>' : ''}
|
||
</tbody></table></div>
|
||
${ls.some(l => l.diff) ? '<div class="sc-warn">A special price differs from the list price — the order will be reviewed by MIPL before approval.</div>' : ''}`;
|
||
wirePicker();
|
||
SC.q('.sc-add', container).onclick = () => {
|
||
const key = SC.q('.sc-prod', container).value; const qty = +SC.q('.sc-qty', container).value;
|
||
if (!key) return SC.toast('Search and select a product from the list');
|
||
if (!(qty > 0)) return SC.toast('Enter a quantity');
|
||
const ex = st.lines.find(l => l.key === key);
|
||
if (ex) ex.qty = (+ex.qty || 0) + qty; else st.lines.push({ key, qty, specialPrice: 0, commission: 0 });
|
||
render(); opts.onChange && opts.onChange();
|
||
};
|
||
SC.qa('tr[data-i]', container).forEach(tr => {
|
||
const i = +tr.dataset.i;
|
||
const upd = (cls, f) => { const el = SC.q(cls, tr); if (el) el.onchange = () => { st.lines[i][f] = el.value === '' ? 0 : +el.value; render(); opts.onChange && opts.onChange(); }; };
|
||
upd('.sc-lq', 'qty'); upd('.sc-lsp', 'specialPrice'); upd('.sc-lcm', 'commission');
|
||
SC.q('.sc-del', tr).onclick = () => { st.lines.splice(i, 1); render(); opts.onChange && opts.onChange(); };
|
||
});
|
||
}
|
||
render();
|
||
return { count: () => st.lines.length, getLines: () => st.lines.filter(l => +l.qty > 0).map(l => ({ key: l.key, qty: +l.qty, specialPrice: +l.specialPrice || 0, commission: +l.commission || 0 })),
|
||
computed: compute, refresh: render };
|
||
};
|
||
|
||
// ── order detail view ─────────────────────────────────────────────────────
|
||
SC.orderHtml = (o, { employee, divisions, labels } = {}) => {
|
||
const divName = ((divisions || []).find(d => d.id === o.divisionId) || {}).name || o.divisionId;
|
||
const tax = { igst: o.igstRate, cgst: o.cgstRate, sgst: o.sgstRate, tcs: o.tcsRate };
|
||
const t = { sub: o.subTotal, igst: o.igstVal, cgst: o.cgstVal, sgst: o.sgstVal, tcs: o.tcsVal, grand: o.grandTotal };
|
||
const showSp = o.items.some(i => i.specialPrice > 0);
|
||
const showCm = employee && o.items.some(i => i.commission > 0);
|
||
const cols = 6 + (showSp ? 1 : 0) + (showCm ? 1 : 0);
|
||
return `
|
||
<div class="sc-grid">
|
||
<div class="sc-kv"><b>Order ref.</b><span class="mono">${SC.esc(o.refNo)}</span></div>
|
||
<div class="sc-kv"><b>Status</b>${SC.pill(o.status, employee ? o.statusLabel : o.customerStatusLabel)}</div>
|
||
<div class="sc-kv"><b>Type</b><span>${SC.typeLabel(o)}${o.legacy ? ' · migrated from msale' : ''}</span></div>
|
||
<div class="sc-kv"><b>Product category</b><span>${SC.esc(divName)}</span></div>
|
||
<div class="sc-kv"><b>Customer</b><span>${SC.esc(o.cardName)} (${SC.esc(o.cardCode)})</span></div>
|
||
<div class="sc-kv"><b>Customer ref. order no.</b><span>${SC.esc(o.custOrderNo || '—')}</span></div>
|
||
<div class="sc-kv"><b>Order date</b><span>${SC.date(o.orderDate || o.createdAt)}</span></div>
|
||
<div class="sc-kv"><b>Committed Dispatch Date</b><span>${SC.date(o.deliveryDate)}</span></div>
|
||
<div class="sc-kv"><b>Contact person</b><span>${SC.esc(o.contactPerson || '—')}</span></div>
|
||
<div class="sc-kv"><b>Sales employee</b><span>${SC.esc(o.salesEmployee || '—')}</span></div>
|
||
${o.creditDays != null ? `<div class="sc-kv"><b>Credit days</b><span>${SC.esc(o.creditDays)}</span></div>` : ''}
|
||
${o.sapDocNum ? `<div class="sc-kv"><b>SAP sales order</b><span class="mono">${SC.esc(o.sapDocNum)} · ${SC.date(o.sapOrderDate || o.sapPostedAt)}</span></div>` : ''}
|
||
<div class="sc-kv"><b>Created by</b><span>${SC.esc(o.createdByName)} · ${SC.dt(o.createdAt)}</span></div>
|
||
</div>
|
||
<div class="sc-row" style="margin-top:10px">
|
||
<div class="sc-kv"><b>Ship to — ${SC.esc(o.shipToCode)}</b><span>${SC.esc(o.shipToText)}</span></div>
|
||
<div class="sc-kv"><b>Bill to — ${SC.esc(o.billToCode)}</b><span>${SC.esc(o.billToText)}</span></div>
|
||
</div>
|
||
${o.sapError && employee ? `<div class="sc-err">Last SAP posting error: ${SC.esc(o.sapError)}</div>` : ''}
|
||
<div class="sc-sec">Items</div>
|
||
<div style="overflow-x:auto"><table class="sc-tbl"><thead><tr><th>#</th><th>Item</th><th>Description</th><th class="n">Qty</th><th class="n">Packs</th><th class="n">Price</th>
|
||
${showSp ? '<th class="n">Special price</th>' : ''}${showCm ? '<th class="n">Comm %</th>' : ''}<th class="n">Amount</th></tr></thead><tbody>
|
||
${o.items.map((i, n) => `<tr><td>${n + 1}</td><td class="mono">${SC.esc(i.displayCode)}${i.displayCode !== i.itemCode ? `<div class="sc-muted">SAP ${SC.esc(i.itemCode)}</div>` : ''}</td>
|
||
<td>${SC.esc(i.description)}<div class="sc-muted">HSN ${SC.esc(i.hsn || '—')}</div></td><td class="n">${SC.num(i.qty)}</td><td class="n">${SC.esc(i.nop || '')}</td>
|
||
<td class="n">${SC.money(i.price)}</td>${showSp ? `<td class="n ${i.specialPrice > 0 && Math.abs(i.specialPrice - i.price) > 0.0001 ? 'sc-diff' : ''}">${i.specialPrice > 0 ? SC.money(i.specialPrice) : '—'}</td>` : ''}
|
||
${showCm ? `<td class="n">${i.commission || ''}</td>` : ''}<td class="n">${SC.money(i.lineTotal)}</td></tr>`).join('')}
|
||
${SC.totalsRows(t, tax, cols)}</tbody></table></div>
|
||
${o.consignees && o.consignees.length ? `<div class="sc-sec">Consignees</div><table class="sc-tbl"><thead><tr><th>Ship to</th><th>Sales emp.</th><th>Items</th><th>SAP SO</th></tr></thead><tbody>
|
||
${o.consignees.map(c => `<tr><td><b>${SC.esc(c.shipToCode)}</b><div class="sc-muted" style="white-space:pre-wrap">${SC.esc(c.shipToText)}</div></td><td>${SC.esc(c.seName)}<div class="sc-muted">${SC.esc(c.seEmail)}</div></td>
|
||
<td>${c.items.map(x => `${SC.esc(x.itemCode)} × ${SC.num(x.qty)}`).join('<br>')}</td><td class="mono">${SC.esc(c.sapDocNum || '—')}</td></tr>`).join('')}</tbody></table>` : ''}
|
||
${o.paidAmount != null || o.payments && o.payments.length ? `<div class="sc-sec">Payment</div>
|
||
<div class="sc-grid">
|
||
<div class="sc-kv"><b>Paid amount</b><span>${SC.money(o.paidAmount)}</span></div>
|
||
<div class="sc-kv"><b>TDS</b><span>${SC.money(o.tdsVal)}</span></div>
|
||
<div class="sc-kv"><b>Adjusted from wallet</b><span>${SC.money(o.walletUsed)}</span></div>
|
||
${o.rebate ? `<div class="sc-kv"><b>Rounding rebate</b><span>${SC.money(o.rebate)}</span></div>` : ''}
|
||
<div class="sc-kv"><b>Mode / ref.</b><span>${SC.esc(o.paymentMode || '—')} ${SC.esc(o.paymentRef || '')}</span></div>
|
||
<div class="sc-kv"><b>Payment date</b><span>${SC.date(o.paymentDate)}</span></div>
|
||
<div class="sc-kv"><b>Payment status</b><span>${SC.esc(o.paidStatus || '—')}</span></div>
|
||
</div>
|
||
${(o.payments || []).length ? `<table class="sc-tbl" style="margin-top:8px"><thead><tr><th>Txn</th><th>Type</th><th class="n">Amount</th><th>Status</th><th>Ref</th><th>When</th></tr></thead><tbody>
|
||
${o.payments.map(p => `<tr><td>#${p.id}</td><td>${SC.esc(p.refType)} (${SC.esc(p.type)})</td><td class="n">${SC.money(p.amount)}</td><td>${SC.esc(p.status)}</td><td>${SC.esc(p.paymentRef || p.remarks || '')}</td><td>${SC.dt(p.createdAt)}</td></tr>`).join('')}</tbody></table>` : ''}` : ''}
|
||
<div class="sc-sec">Remarks</div>
|
||
<div class="sc-row">
|
||
<div><label class="sc-lbl">Customer remarks</label><div class="sc-remarks">${SC.esc(o.customerRemarks || '—')}</div></div>
|
||
<div><label class="sc-lbl">MIPL remarks</label><div class="sc-remarks">${SC.esc(o.employeeRemarks || '—')}</div></div>
|
||
${employee ? `<div><label class="sc-lbl">Internal remarks (not visible to customer)</label><div class="sc-remarks">${SC.esc(o.internalRemarks || '—')}</div></div>` : ''}
|
||
</div>
|
||
${(o.docs || []).length ? `<div class="sc-sec">Documents</div>${o.docs.map(d => `<div><span class="sc-link" data-doc="${d.id}">📄 ${SC.esc(d.title || d.origName || d.fileName)}</span>
|
||
<span class="sc-muted">${SC.esc(d.docType)} · ${SC.esc(d.createdByName)} · ${SC.dt(d.createdAt)}</span></div>`).join('')}` : ''}
|
||
<div class="sc-sec">History</div>
|
||
<div class="sc-tl">${(o.log || []).map(l => `<div><b>${SC.dt(l.at)}</b> — ${SC.esc(l.actorName)}: ${SC.esc(l.action.replace(/_/g, ' '))}${l.toStatus != null && l.toStatus !== l.fromStatus ? ` → <i>${SC.esc((labels || {})[l.toStatus] || l.toStatus)}</i>` : ''}${l.remarks ? ` — <span class="sc-muted">${SC.esc(l.remarks)}</span>` : ''}</div>`).join('') || '<span class="sc-muted">—</span>'}</div>
|
||
<div class="sc-sec">Invoices, dispatch & COA</div><div class="sc-inv" data-order="${o.id}">${[8, 9].includes(o.status) ? '<span class="sc-link sc-inv-load">Load from SAP</span>' : '<span class="sc-muted">Available once the order is placed in SAP.</span>'}</div>`;
|
||
};
|
||
// Wire the doc links + invoice loader inside a rendered order view.
|
||
SC.wireOrder = (root, orderId) => {
|
||
SC.qa('[data-doc]', root).forEach(el => { el.onclick = () => SC.openFile(`/docs/${el.dataset.doc}`); });
|
||
const box = SC.q('.sc-inv', root); const ld = box && SC.q('.sc-inv-load', box);
|
||
if (ld) ld.onclick = () => SC.loadInvoices(box, orderId);
|
||
};
|
||
SC.loadInvoices = async (box, orderId) => {
|
||
box.innerHTML = '<span class="sc-spin"></span> Loading from SAP…';
|
||
try {
|
||
const d = await SC.api(`/orders/${orderId}/invoices`);
|
||
if (!d.invoices.length) { box.innerHTML = '<span class="sc-muted">No invoice raised yet.</span>'; return; }
|
||
box.innerHTML = d.invoices.map(inv => `<div style="border:1px solid var(--border,#1c2e48);border-radius:8px;padding:8px 10px;margin-bottom:8px">
|
||
<div class="sc-row" style="align-items:center"><div><b>Invoice ${SC.esc(inv.invoiceNo)}</b> · ${SC.date(inv.invoiceDate)} · ${SC.money(inv.total)}</div>
|
||
<div style="text-align:right">${inv.hasPdf ? `<span class="sc-link" data-pdf="${SC.esc(inv.invoiceNo)}">📄 Invoice PDF</span>` : '<span class="sc-muted">Invoice PDF not uploaded yet</span>'}</div></div>
|
||
<div class="sc-muted" style="margin:4px 0">Dispatch — LR/GR/AWB: <b>${SC.esc(inv.dispatch.lrNo || '—')}</b>${inv.dispatch.lrDate ? ' · ' + SC.date(inv.dispatch.lrDate) : ''}${inv.dispatch.transporter ? ' · ' + SC.esc(inv.dispatch.transporter) : ''}${inv.dispatch.vehicle ? ' · ' + SC.esc(inv.dispatch.vehicle) : ''}${inv.dispatch.ewayBill ? ' · e-Way ' + SC.esc(inv.dispatch.ewayBill) : ''}</div>
|
||
${inv.attachments.length ? `<div>${inv.attachments.map(a => `<span class="sc-link" data-att="${a.absEntry}/${a.line}" style="margin-right:12px">📎 ${SC.esc(a.fileName)}.${SC.esc(a.ext)}</span>`).join('')}</div>` : ''}
|
||
<table class="sc-tbl" style="margin-top:6px"><thead><tr><th>Item</th><th class="n">Qty</th><th>Batches / COA</th></tr></thead><tbody>
|
||
${inv.lines.map(l => { const bs = inv.batches.filter(b => b.itemCode === l.itemCode); return `<tr><td>${SC.esc(l.itemCode)}<div class="sc-muted">${SC.esc(l.description)}</div></td><td class="n">${SC.num(l.qty)}</td>
|
||
<td>${bs.map(b => `${SC.esc(b.batch)} <span class="sc-muted">(exp ${SC.date(b.expDate)})</span> ${b.coa ? `<span class="sc-link" data-att="${b.coa.absEntry}/${b.coa.line}">COA</span>` : '<span class="sc-muted">no COA</span>'}`).join('<br>') || '<span class="sc-muted">—</span>'}</td></tr>`; }).join('')}
|
||
</tbody></table></div>`).join('');
|
||
SC.qa('[data-pdf]', box).forEach(el => { el.onclick = () => SC.openFile(`/invoice-pdf/${el.dataset.pdf}?orderId=${orderId}`); });
|
||
SC.qa('[data-att]', box).forEach(el => { el.onclick = () => SC.openFile(`/attachment/${el.dataset.att}?orderId=${orderId}`); });
|
||
} catch (e) { box.innerHTML = `<div class="sc-err">${SC.esc(e.message)}</div>`; }
|
||
};
|
||
})();
|