Files
John eead8f5ffd
SAP-ERP Portal CI/CD / build (push) Successful in 3m57s
sale order
2026-10-05 18:45:17 +05:30

363 lines
34 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
/* 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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>`; }
};
})();