352 lines
34 KiB
HTML
352 lines
34 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<script src="/auth-guard.js?v=4"></script>
|
|
<meta charset="UTF-8"/>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0"/>
|
|
<title>Sales Orders — 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;--purple:#9f5afd;--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;-webkit-font-smoothing:antialiased}
|
|
main{padding:12px;max-width:1500px;margin:0 auto}
|
|
.page-head{display:flex;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap}
|
|
.page-title{font-size:16px;font-weight:700}.page-sub{font-size:11px;color:var(--text3)}
|
|
.head-actions{margin-left:auto;display:flex;gap:8px;flex-wrap:wrap}
|
|
.tabs{display:flex;gap:4px;margin-bottom:10px;border-bottom:1px solid var(--border)}
|
|
.tab{padding:8px 14px;font-size:12.5px;font-weight:700;color:var(--text2);cursor:pointer;border-bottom:2px solid transparent}
|
|
.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)}
|
|
.card-bd{padding:12px 14px}
|
|
.filters{display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end}.filters>div{min-width:140px}
|
|
.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;letter-spacing:.3px;color:var(--text3);border-bottom:2px solid var(--border2);white-space:nowrap;background:var(--surface3);position:sticky;top:0;z-index:1}
|
|
.lst td{padding:7px 10px;border-bottom:1px solid var(--border);white-space:nowrap}
|
|
.lst td.n,.lst th.n{text-align:right;font-family:var(--mono)}
|
|
.lst tbody tr{cursor:pointer}.lst tbody tr:hover td{background:rgba(26,111,255,.06)}
|
|
.tbl-wrap{overflow:auto;max-height:68vh;border-radius:8px;border:1px solid var(--border2)}
|
|
.mono{font-family:var(--mono)}
|
|
.empty{text-align:center;padding:26px;color:var(--text3);font-size:12px}
|
|
.count{font-size:11px;font-weight:700;color:var(--blue);background:rgba(26,111,255,.1);padding:3px 11px;border-radius:20px}
|
|
.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)}
|
|
</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> Loading…</div></main>
|
|
<script>
|
|
'use strict';
|
|
const { esc, money, num, date, dt, q, qa, toast, api, pill } = SC;
|
|
let ME = null, DIVS = [], ROWS = [], TAB = 'orders';
|
|
const F = { type: '', divisionId: '', statuses: 'default', search: '', from: '', to: '' };
|
|
const cap = (k, p) => ME && ME.caps[k] && ME.caps[k].includes(p);
|
|
const divName = id => ((ME && ME.allDivisions) || DIVS).find(d => d.id === id)?.name || id;
|
|
const lbl = o => (o.orderType === 'DIRECT' ? ME.directStatusLabels : ME.statusLabels)[o.status] || o.statusLabel;
|
|
|
|
async function init() {
|
|
ME = await api('/me');
|
|
DIVS = ME.divisions;
|
|
const p = new URLSearchParams(location.search);
|
|
if (p.get('tab') === 'payments') TAB = 'payments';
|
|
render();
|
|
if (p.get('id')) openOrder(p.get('id'));
|
|
}
|
|
|
|
function render() {
|
|
const canPay = cap('sales_order:payment_verify', 'view') || cap('sales_order:payment_entry', 'view');
|
|
const prof = ME.profile;
|
|
q('#app').innerHTML = `
|
|
<div class="page-head"><div><div class="page-title">Sales Orders</div>
|
|
<div class="page-sub">${esc(prof.userTypeName || 'No sales user type assigned')} · divisions: ${prof.divisions.map(divName).map(esc).join(', ') || '<b style="color:var(--red)">none — ask an admin to set your Sales profile</b>'}${prof.scope !== 'all' ? ` · scope: ${esc(prof.scope === 'mapped' ? 'mapped customers only' : 'own orders only')}` : ''}</div></div>
|
|
<div class="head-actions">
|
|
${cap('sales_order:sap_post', 'approve') ? '<button class="sc-btn sc-b-ghost" id="b-sync" title="Link orders already in SAP and close fully-invoiced ones">⟳ Sync with SAP</button>' : ''}
|
|
${cap('sales_order:create_direct', 'add') ? '<button class="sc-btn sc-b-primary" id="b-new">+ New Direct Order</button>' : ''}
|
|
</div></div>
|
|
${canPay ? `<div class="tabs"><div class="tab ${TAB === 'orders' ? 'on' : ''}" data-t="orders">Orders</div><div class="tab ${TAB === 'payments' ? 'on' : ''}" data-t="payments">Payments & Wallet</div></div>` : ''}
|
|
<div id="pane"></div>`;
|
|
qa('.tab').forEach(t => { t.onclick = () => { TAB = t.dataset.t; render(); }; });
|
|
if (q('#b-new')) q('#b-new').onclick = () => directOrderForm();
|
|
if (q('#b-sync')) q('#b-sync').onclick = async (e) => { e.target.disabled = true; try { const s = await api('/sync-sap', { method: 'POST' }); toast(`SAP sync done — ${s.linked || 0} linked, ${s.closed || 0} closed`, 'ok'); loadOrders(); } catch (er) { toast(er.message); } e.target.disabled = false; };
|
|
if (TAB === 'payments') renderPayments(); else renderOrdersPane();
|
|
}
|
|
|
|
// ── Orders list ─────────────────────────────────────────────────────────────
|
|
function renderOrdersPane() {
|
|
const def = ME.profile.listStatuses.length ? ME.profile.listStatuses.join(',') : '';
|
|
q('#pane').innerHTML = `<div class="card"><div class="card-bd"><div class="filters">
|
|
<div><label class="sc-lbl">Type</label><select class="sc-in" id="f-type"><option value="">All</option><option value="trade">Trade</option><option value="inst">Institution (in-direct)</option><option value="direct">Direct</option></select></div>
|
|
<div><label class="sc-lbl">Product category</label><select class="sc-in" id="f-div"><option value="">All mine</option>${DIVS.filter(d => ME.profile.divisions.includes(d.id)).map(d => `<option value="${d.id}">${esc(d.name)}</option>`).join('')}</select></div>
|
|
<div style="min-width:220px"><label class="sc-lbl">Status</label><select class="sc-in" id="f-st">
|
|
<option value="default">${def ? 'My queue (default for my role)' : 'Open orders'}</option><option value="">All (incl. closed/cancelled)</option>
|
|
${Object.entries(ME.statusLabels).map(([k, v]) => `<option value="${k}">${k} · ${esc(v)}</option>`).join('')}</select></div>
|
|
<div style="min-width:200px;flex:1"><label class="sc-lbl">Search</label><input class="sc-in" id="f-q" placeholder="Ref / customer / PO / SAP no."></div>
|
|
<div><label class="sc-lbl">From</label><input class="sc-in" type="date" id="f-from"></div>
|
|
<div><label class="sc-lbl">To</label><input class="sc-in" type="date" id="f-to"></div>
|
|
<div style="min-width:auto"><button class="sc-btn sc-b-primary" id="f-go">Search</button></div>
|
|
</div></div></div>
|
|
<div class="card"><div class="card-hd">Orders <span class="count" id="cnt">—</span></div><div class="tbl-wrap" id="tbl"></div></div>`;
|
|
q('#f-type').value = F.type; q('#f-div').value = F.divisionId; q('#f-st').value = F.statuses; q('#f-q').value = F.search; q('#f-from').value = F.from; q('#f-to').value = F.to;
|
|
q('#f-go').onclick = () => { F.type = q('#f-type').value; F.divisionId = q('#f-div').value; F.statuses = q('#f-st').value; F.search = q('#f-q').value.trim(); F.from = q('#f-from').value; F.to = q('#f-to').value; loadOrders(); };
|
|
q('#f-q').onkeydown = e => { if (e.key === 'Enter') q('#f-go').click(); };
|
|
loadOrders();
|
|
}
|
|
async function loadOrders() {
|
|
if (!q('#tbl')) return;
|
|
q('#tbl').innerHTML = '<div class="empty"><span class="sc-spin"></span> Loading…</div>';
|
|
const p = new URLSearchParams();
|
|
if (F.type === 'trade') p.set('soType', 1); if (F.type === 'inst') { p.set('soType', 2); p.set('orderType', 'IN-DIRECT'); } if (F.type === 'direct') p.set('orderType', 'DIRECT');
|
|
if (F.divisionId) p.set('divisionId', F.divisionId);
|
|
const st = F.statuses === 'default' ? (ME.profile.listStatuses.length ? ME.profile.listStatuses.join(',') : '1,2,3,4,5,6,7,8') : F.statuses;
|
|
if (st) p.set('statuses', st);
|
|
if (F.search) p.set('search', F.search); if (F.from) p.set('from', F.from); if (F.to) p.set('to', F.to);
|
|
try { ROWS = await api('/orders?' + p.toString()); } catch (e) { q('#tbl').innerHTML = `<div class="sc-err">${esc(e.message)}</div>`; return; }
|
|
q('#cnt').textContent = ROWS.length;
|
|
q('#tbl').innerHTML = ROWS.length ? `<table class="lst"><thead><tr><th>Ref no.</th><th>Date</th><th>Type</th><th>Customer</th><th>Category</th><th>Cust PO</th><th class="n">Qty</th><th class="n">Value</th><th>Status</th><th>SAP SO</th></tr></thead><tbody>
|
|
${ROWS.map(o => `<tr data-id="${o.id}"><td class="mono">${esc(o.refNo)}</td><td>${date(o.createdAt)}</td><td>${SC.typeLabel(o)}</td><td>${esc(o.cardName)}<div class="sc-muted">${esc(o.cardCode)}</div></td>
|
|
<td>${esc(divName(o.divisionId))}</td><td>${esc(o.custOrderNo)}</td><td class="n">${num(o.orderQty)}</td><td class="n">${money(o.grandTotal)}</td><td>${pill(o.status, lbl(o))}</td><td class="mono">${esc(o.sapDocNum || '')}</td></tr>`).join('')}
|
|
</tbody></table>` : '<div class="empty">No orders match these filters.</div>';
|
|
qa('#tbl tr[data-id]').forEach(tr => { tr.onclick = () => openOrder(tr.dataset.id); });
|
|
}
|
|
|
|
// ── Order detail + actions ──────────────────────────────────────────────────
|
|
async function openOrder(id) {
|
|
let o;
|
|
try { o = await api('/orders/' + id); } catch (e) { return toast(e.message); }
|
|
const labels = o.orderType === 'DIRECT' ? ME.directStatusLabels : ME.statusLabels;
|
|
const acts = o.actions || [];
|
|
const body = SC.orderHtml(o, { employee: true, divisions: ME.allDivisions || DIVS, labels }) + (acts.length ? `
|
|
<div class="sc-sec">Take action</div>
|
|
<div class="sc-row"><div><label class="sc-lbl">Action</label><select class="sc-in" id="a-act"><option value="">— Select —</option>${acts.map(a => `<option value="${a.key}">${esc(a.label)}</option>`).join('')}</select></div></div>
|
|
<div id="a-fields" style="margin-top:8px"></div>` : '');
|
|
const m = SC.modal({ title: `Sales Order ${esc(o.refNo)} — ${esc(o.cardName)}`, wide: true, body,
|
|
buttons: [{ label: 'Close' }, ...(acts.length ? [{ label: 'Submit action', cls: 'sc-b-primary', onClick: () => submitAction(o, m) }] : [])] });
|
|
SC.wireOrder(m.root, o.id);
|
|
const sel = q('#a-act', m.root);
|
|
if (sel) sel.onchange = () => {
|
|
const a = acts.find(x => x.key === sel.value); const box = q('#a-fields', m.root);
|
|
if (!a) { box.innerHTML = ''; return; }
|
|
if (a.key === 'resubmit') { box.innerHTML = '<div class="sc-info">Opens the order editor so you can change items and resubmit to Logistics.</div>'; return; }
|
|
if (a.key === 'sap_post') { box.innerHTML = `<div class="sc-info">Creates the Sales Order in SAP company <b>${esc(o.company)}</b> using <b>your</b> SAP login (Profile → My SAP Account).${o.isMultiConsignee ? ' One SAP order is created per consignee.' : ''}</div>`; return; }
|
|
box.innerHTML = `<div class="sc-row">
|
|
<div><label class="sc-lbl">Remarks — visible to customer ${a.need.includes('employeeRemarks') ? '<b style="color:var(--red)">*</b>' : ''}</label><textarea class="sc-in" id="a-er"></textarea></div>
|
|
<div><label class="sc-lbl">Internal remarks ${a.need.includes('internalRemarks') ? '<b style="color:var(--red)">*</b>' : ''}</label><textarea class="sc-in" id="a-ir"></textarea></div></div>
|
|
${a.key === 'final_approve' && o.orderType === 'DIRECT' ? `<div class="sc-row" style="margin-top:8px"><div><label class="sc-lbl">Credit days</label><input class="sc-in" id="a-cd" type="number" min="0" value="${esc(o.creditDays ?? '')}"></div>
|
|
<div style="flex:3"><label class="sc-lbl">Payment detail</label><input class="sc-in" id="a-pd" value="${esc(o.paymentDetail || '')}"></div></div>` : ''}
|
|
${a.key === 'cancel' && o.status >= 6 ? '<div class="sc-warn">Payment was already submitted for this order. Any amount already debited goes back to the customer\'s wallet; a still-unverified payment stays pending for Accounts.</div>' : ''}`;
|
|
};
|
|
}
|
|
async function submitAction(o, m) {
|
|
const key = q('#a-act', m.root).value;
|
|
if (!key) { toast('Select an action'); return true; }
|
|
if (key === 'resubmit') { m.close(); directOrderForm(o); return true; }
|
|
if (key === 'sap_post' && !(await SC.confirmBox('Post to SAP', `Create SAP Sales Order(s) for <b>${esc(o.refNo)}</b>?`, 'Post to SAP', 'sc-b-green'))) return true;
|
|
// company: the order's SAP company — lets the server's SAP-login middleware pick the right per-company login.
|
|
const body = { action: key, company: o.company, employeeRemarks: (q('#a-er', m.root) || {}).value || '', internalRemarks: (q('#a-ir', m.root) || {}).value || '' };
|
|
if (q('#a-cd', m.root)) { body.creditDays = q('#a-cd', m.root).value; body.paymentDetail = q('#a-pd', m.root).value; }
|
|
const fresh = await api(`/orders/${o.id}/action`, { method: 'POST', body });
|
|
toast(`Order ${fresh.refNo}: ${lbl(fresh)}`, 'ok');
|
|
loadOrders();
|
|
m.close(); openOrder(o.id);
|
|
return true;
|
|
}
|
|
|
|
// ── Direct order (create / modify & resubmit) ───────────────────────────────
|
|
async function directOrderForm(existing) {
|
|
const myDivs = DIVS.filter(d => ME.profile.divisions.includes(d.id));
|
|
const S = { cust: null, products: [], cart: null, consignees: existing ? existing.consignees.map(c => ({ ...c })) : [] };
|
|
const m = SC.modal({ title: existing ? `Modify Direct Order ${esc(existing.refNo)}` : 'New Direct Order', wide: true, body: `
|
|
<div class="sc-row">
|
|
<div class="ac" style="flex:2"><label class="sc-lbl">Customer *</label><input class="sc-in" id="d-cust" placeholder="Type customer code or name…" ${existing ? 'disabled' : ''} autocomplete="off"><div class="ac-list" id="d-ac" style="display:none"></div></div>
|
|
<div><label class="sc-lbl">Product category *</label><select class="sc-in" id="d-div" ${existing ? 'disabled' : ''}><option value="">— Select —</option>${myDivs.map(d => `<option value="${d.id}">${esc(d.name)}</option>`).join('')}</select></div>
|
|
</div>
|
|
<div id="d-rest" class="sc-muted" style="margin-top:10px">Select a customer and product category to continue.</div>`,
|
|
buttons: [{ label: 'Cancel' }, { label: existing ? 'Resubmit to Logistics' : 'Create order', cls: 'sc-b-primary', onClick: () => save() }] });
|
|
const R = m.root;
|
|
if (existing) { q('#d-cust', R).value = `${existing.cardName} (${existing.cardCode})`; q('#d-div', R).value = existing.divisionId; loadCustomer(existing.cardCode); }
|
|
let t = null;
|
|
q('#d-cust', R).oninput = () => {
|
|
clearTimeout(t); const v = q('#d-cust', R).value.trim(); const ac = q('#d-ac', R);
|
|
if (v.length < 2) { ac.style.display = 'none'; return; }
|
|
t = setTimeout(async () => {
|
|
try { const list = await api('/customers?q=' + encodeURIComponent(v));
|
|
ac.innerHTML = list.map(c => `<div data-cc="${esc(c.cardCode)}">${esc(c.cardName)} <span class="sc-muted">${esc(c.cardCode)} · ${esc(c.city)}${c.frozen ? ' · INACTIVE' : ''}</span></div>`).join('') || '<div class="sc-muted">No match</div>';
|
|
ac.style.display = 'block';
|
|
qa('[data-cc]', ac).forEach(d => { d.onclick = () => { ac.style.display = 'none'; q('#d-cust', R).value = d.textContent; loadCustomer(d.dataset.cc); }; });
|
|
} catch (e) { toast(e.message); }
|
|
}, 300);
|
|
};
|
|
// Changing category: keep the header fields already typed, only swap the
|
|
// product list (asks first if products were already added).
|
|
let lastDiv = existing ? String(existing.divisionId) : '';
|
|
q('#d-div', R).onchange = async () => {
|
|
const sel = q('#d-div', R);
|
|
if (S.cart && S.cart.count() && !(await SC.confirmBox('Change product category?', 'Products already added belong to the current category and will be removed.', 'Change category', 'sc-b-amber'))) { sel.value = lastDiv; return; }
|
|
lastDiv = sel.value;
|
|
if (!S.cust) return;
|
|
if (q('#d-cart', R) && sel.value) { S.products = await api('/products?divisionId=' + sel.value); S.consignees = []; makeCart([]); renderCons(); }
|
|
else buildRest();
|
|
};
|
|
async function loadCustomer(cc) { try { S.cust = await api('/customers/' + encodeURIComponent(cc)); buildRest(); } catch (e) { toast(e.message); } }
|
|
// Product list = only the selected category's products (server-filtered).
|
|
function makeCart(lines) {
|
|
const c = S.cust; const shipState = () => (c.shipTo.find(a => a.code === q('#d-ship', R).value) || {}).state;
|
|
S.cart = SC.cartEditor(q('#d-cart', R), S.products, { lines, allowSpecial: true, allowCommission: true, settings: ME.settings, getShipState: shipState,
|
|
onChange: renderCons, categoryName: divName(+q('#d-div', R).value) });
|
|
}
|
|
async function buildRest() {
|
|
const div = +q('#d-div', R).value; const box = q('#d-rest', R);
|
|
if (!S.cust || !div) return;
|
|
box.className = ''; box.innerHTML = '<span class="sc-spin"></span> Loading products…';
|
|
S.products = await api('/products?divisionId=' + div);
|
|
const c = S.cust; const e = existing || {};
|
|
const addrOpt = (list, sel) => list.map(a => `<option value="${esc(a.code)}" ${a.code === sel ? 'selected' : ''}>${esc(a.code)} — ${esc(a.city)}, ${esc(a.stateName || a.state)}</option>`).join('');
|
|
box.innerHTML = `
|
|
${c.frozen ? '<div class="sc-err">This customer is inactive (frozen) in SAP — the order cannot be saved.</div>' : ''}
|
|
<div class="sc-row">
|
|
<div><label class="sc-lbl">Ship to *</label><select class="sc-in" id="d-ship">${addrOpt(c.shipTo, e.shipToCode)}</select><div class="sc-muted" id="d-ship-t" style="white-space:pre-wrap;margin-top:3px"></div></div>
|
|
<div><label class="sc-lbl">Bill to *</label><select class="sc-in" id="d-bill">${addrOpt(c.billTo, e.billToCode)}</select><div class="sc-muted" id="d-bill-t" style="white-space:pre-wrap;margin-top:3px"></div></div>
|
|
</div>
|
|
<div class="sc-row" style="margin-top:8px">
|
|
<div><label class="sc-lbl">Customer ref. order no. *</label><input class="sc-in" id="d-po" value="${esc(e.custOrderNo || '')}"></div>
|
|
<div><label class="sc-lbl">Order date</label><input class="sc-in" type="date" id="d-od" value="${SC.iso(e.orderDate) || SC.today()}" ${existing ? 'disabled' : ''}></div>
|
|
<div><label class="sc-lbl">Committed Dispatch Date *</label><input class="sc-in" type="date" id="d-dd" value="${SC.iso(e.deliveryDate)}"></div>
|
|
<div><label class="sc-lbl">Contact person</label><input class="sc-in" id="d-cp" value="${esc(e.contactPerson || c.contactPerson)}" ${existing ? 'disabled' : ''}></div>
|
|
<div><label class="sc-lbl">Sales employee</label><input class="sc-in" id="d-se" value="${esc(e.salesEmployee || c.slpName)}" ${existing ? 'disabled' : ''}></div>
|
|
</div>
|
|
${existing ? '' : `<div class="sc-row" style="margin-top:8px"><div><label class="sc-lbl">Credit days</label><input class="sc-in" type="number" min="0" id="d-cd"></div>
|
|
<div style="flex:3"><label class="sc-lbl">Payment detail</label><input class="sc-in" id="d-pd"></div></div>`}
|
|
<div class="sc-sec">Products</div><div id="d-cart"></div>
|
|
<label style="display:flex;gap:6px;align-items:center;font-size:12px;margin-top:10px"><input type="checkbox" id="d-mc" ${existing ? (existing.isMultiConsignee ? 'checked disabled' : 'disabled') : ''}> Multiple consignees (split delivery to several ship-to addresses — one SAP order each)</label>
|
|
<div id="d-cons"></div>
|
|
<div class="sc-row" style="margin-top:10px"><div><label class="sc-lbl">Remarks</label><textarea class="sc-in" id="d-rm"></textarea></div>
|
|
<div><label class="sc-lbl">Customer PO copy (PDF / image)</label><input class="sc-in" type="file" id="d-file" accept=".pdf,.jpg,.jpeg,.png,.bmp"></div></div>`;
|
|
const addrText = () => { q('#d-ship-t', R).textContent = (c.shipTo.find(a => a.code === q('#d-ship', R).value) || {}).text || ''; q('#d-bill-t', R).textContent = (c.billTo.find(a => a.code === q('#d-bill', R).value) || {}).text || ''; };
|
|
addrText();
|
|
makeCart((e.items || []).map(i => ({ key: i.productId ? `P:${i.productId}` : `S:${i.itemCode}`, qty: i.qty, specialPrice: i.specialPrice, commission: i.commission })));
|
|
q('#d-ship', R).onchange = () => { addrText(); S.cart.refresh(); };
|
|
q('#d-bill', R).onchange = addrText;
|
|
q('#d-mc', R).onchange = renderCons;
|
|
renderCons();
|
|
}
|
|
function renderCons() {
|
|
const box = q('#d-cons', R); if (!box) return;
|
|
if (!q('#d-mc', R).checked) { box.innerHTML = ''; return; }
|
|
const items = S.cart.computed();
|
|
const sum = code => S.consignees.reduce((a, c) => a + (+((c.items || []).find(x => x.itemCode === code) || {}).qty || 0), 0);
|
|
box.innerHTML = `<div class="sc-sec">Consignees</div>
|
|
${S.consignees.map((c, i) => `<div class="card" style="margin-bottom:8px"><div class="card-bd">
|
|
<div class="sc-row"><div style="flex:2"><label class="sc-lbl">Ship to</label><select class="sc-in" data-c="${i}" data-f="shipToCode">${S.cust.shipTo.map(a => `<option value="${esc(a.code)}" ${a.code === c.shipToCode ? 'selected' : ''}>${esc(a.code)} — ${esc(a.city)}</option>`).join('')}</select></div>
|
|
<div><label class="sc-lbl">Sales emp. name</label><input class="sc-in" data-c="${i}" data-f="seName" value="${esc(c.seName || '')}"></div>
|
|
<div><label class="sc-lbl">Sales emp. email</label><input class="sc-in" data-c="${i}" data-f="seEmail" value="${esc(c.seEmail || '')}"></div>
|
|
<div style="flex:0 0 auto;min-width:auto;align-self:flex-end"><button class="sc-btn sc-b-red" data-rm="${i}">Remove</button></div></div>
|
|
<table class="sc-tbl" style="margin-top:6px"><thead><tr><th>Item</th><th class="n">Qty for this consignee</th></tr></thead><tbody>
|
|
${items.map(it => `<tr><td>${esc(it.p.displayCode || it.key)}</td><td class="n"><input class="sc-in" type="number" min="0" style="width:110px;text-align:right" data-ci="${i}" data-item="${esc(it.p.itemCode)}" value="${esc(((c.items || []).find(x => x.itemCode === it.p.itemCode) || {}).qty || '')}"></td></tr>`).join('')}
|
|
</tbody></table></div></div>`).join('')}
|
|
<button class="sc-btn sc-b-ghost" id="d-addc">+ Add consignee</button>
|
|
<div class="sc-muted" style="margin-top:6px">${items.map(it => `${esc(it.p.displayCode)}: allocated ${num(sum(it.p.itemCode))} / ${num(it.qty)}`).join(' · ')}</div>`;
|
|
q('#d-addc', R).onclick = () => { S.consignees.push({ shipToCode: (S.cust.shipTo[0] || {}).code, seName: '', seEmail: '', items: [] }); renderCons(); };
|
|
qa('[data-rm]', box).forEach(b => { b.onclick = () => { S.consignees.splice(+b.dataset.rm, 1); renderCons(); }; });
|
|
qa('[data-f]', box).forEach(el => { el.onchange = () => { S.consignees[+el.dataset.c][el.dataset.f] = el.value; }; });
|
|
qa('[data-ci]', box).forEach(el => { el.onchange = () => {
|
|
const c = S.consignees[+el.dataset.ci]; c.items = (c.items || []).filter(x => x.itemCode !== el.dataset.item);
|
|
if (+el.value > 0) c.items.push({ itemCode: el.dataset.item, qty: +el.value }); renderCons(); }; });
|
|
}
|
|
async function save() {
|
|
if (!S.cust || !S.cart) { toast('Select a customer and product category'); return true; }
|
|
const body = { divisionId: +q('#d-div', R).value, cardCode: S.cust.cardCode, shipToCode: q('#d-ship', R).value, billToCode: q('#d-bill', R).value,
|
|
custOrderNo: q('#d-po', R).value.trim(), orderDate: q('#d-od', R).value, deliveryDate: q('#d-dd', R).value, contactPerson: q('#d-cp', R).value,
|
|
salesEmployee: q('#d-se', R).value, creditDays: q('#d-cd', R) ? q('#d-cd', R).value : null, paymentDetail: q('#d-pd', R) ? q('#d-pd', R).value : null,
|
|
remarks: q('#d-rm', R).value, lines: S.cart.getLines(), isMultiConsignee: q('#d-mc', R).checked, consignees: S.consignees };
|
|
const o = existing ? await api(`/orders/${existing.id}/resubmit`, { method: 'PUT', body }) : await api('/orders', { method: 'POST', body });
|
|
const f = q('#d-file', R).files[0];
|
|
if (f) { try { await SC.upload(f, { entity: 'order', entityId: o.id, docType: 'po_copy', title: 'Customer PO copy' }); } catch (e) { toast('Order saved, but PO copy upload failed: ' + e.message); } }
|
|
toast(`Direct order ${o.refNo} ${existing ? 'resubmitted' : 'created'}`, 'ok');
|
|
loadOrders();
|
|
}
|
|
}
|
|
|
|
// ── Payments & wallet (Accounts) ────────────────────────────────────────────
|
|
function renderPayments() {
|
|
q('#pane').innerHTML = `
|
|
${cap('sales_order:payment_verify', 'view') ? `<div class="card"><div class="card-hd">Payments awaiting verification <span class="count" id="p-cnt">—</span></div><div class="tbl-wrap" id="p-tbl"></div></div>` : ''}
|
|
<div class="card"><div class="card-hd">Customer wallet / ledger</div><div class="card-bd">
|
|
<div class="filters"><div class="ac" style="min-width:320px"><label class="sc-lbl">Customer</label><input class="sc-in" id="w-cust" placeholder="Type code or name…" autocomplete="off"><div class="ac-list" id="w-ac" style="display:none"></div></div>
|
|
<div><label class="sc-lbl">From</label><input class="sc-in" type="date" id="w-from"></div><div><label class="sc-lbl">To</label><input class="sc-in" type="date" id="w-to"></div>
|
|
${cap('sales_order:payment_entry', 'add') ? '<div style="min-width:auto"><button class="sc-btn sc-b-green" id="w-add" disabled>+ On-account payment</button></div>' : ''}</div>
|
|
<div id="w-out" style="margin-top:10px"></div></div></div>`;
|
|
if (q('#p-tbl')) loadPending();
|
|
let cc = null, t = null;
|
|
q('#w-cust').oninput = () => { clearTimeout(t); const v = q('#w-cust').value.trim(); const ac = q('#w-ac'); if (v.length < 2) { ac.style.display = 'none'; return; }
|
|
t = setTimeout(async () => { const list = await api('/customers?q=' + encodeURIComponent(v)).catch(e => { toast(e.message); return []; });
|
|
ac.innerHTML = list.map(c => `<div data-cc="${esc(c.cardCode)}">${esc(c.cardName)} <span class="sc-muted">${esc(c.cardCode)}</span></div>`).join('') || '<div class="sc-muted">No match</div>'; ac.style.display = 'block';
|
|
qa('[data-cc]', ac).forEach(d => { d.onclick = () => { ac.style.display = 'none'; q('#w-cust').value = d.textContent; cc = d.dataset.cc; if (q('#w-add')) q('#w-add').disabled = false; loadLedger(); }; }); }, 300); };
|
|
q('#w-from').onchange = q('#w-to').onchange = () => cc && loadLedger();
|
|
if (q('#w-add')) q('#w-add').onclick = () => cc && onAccountForm(cc, loadLedger);
|
|
async function loadLedger() {
|
|
const out = q('#w-out'); out.innerHTML = '<span class="sc-spin"></span>';
|
|
try {
|
|
const d = await api(`/wallet/${encodeURIComponent(cc)}?from=${q('#w-from').value}&to=${q('#w-to').value}`);
|
|
const L = d.ledger;
|
|
out.innerHTML = `<div class="sc-grid" style="margin-bottom:8px"><div class="sc-kv"><b>Wallet balance</b><span style="font-size:16px;font-weight:700">${money(d.summary.balance)}</span></div>
|
|
<div class="sc-kv"><b>Pending credits</b><span>${money(d.summary.pendingCredits)}</span></div><div class="sc-kv"><b>Pending debits</b><span>${money(d.summary.pendingDebits)}</span></div>
|
|
<div class="sc-kv"><b>Credit limit</b><span>${money(d.summary.creditLimit)} ${cap('sales_order:payment_entry', 'edit') ? '<span class="sc-link" id="w-cl">edit</span>' : ''}</span></div></div>
|
|
<button class="sc-btn sc-b-ghost" id="w-csv">⬇ Download ledger (CSV)</button>
|
|
<table class="sc-tbl" style="margin-top:8px"><thead><tr><th>Date</th><th>Txn</th><th>Type</th><th>Order</th><th>Mode / ref.</th><th>Status</th><th class="n">Credit</th><th class="n">Debit</th><th class="n">Balance</th></tr></thead><tbody>
|
|
<tr><td colspan="8"><i>Opening balance</i></td><td class="n">${money(L.opening)}</td></tr>
|
|
${L.lines.map(x => `<tr><td>${dt(x.createdAt)}</td><td>#${x.id}</td><td>${esc(x.refType)}</td><td>${esc(x.refNo || '')}</td><td>${esc(x.paymentMode)} ${esc(x.paymentRef || x.remarks)}</td>
|
|
<td>${esc(x.status)}</td><td class="n">${x.type === 'credit' ? money(x.amount) : ''}</td><td class="n">${x.type === 'debit' ? money(x.amount) : ''}${x.tds ? `<div class="sc-muted">TDS ${money(x.tds)}</div>` : ''}</td><td class="n">${money(x.runningBalance)}</td></tr>`).join('')}
|
|
<tr class="tot"><td colspan="8">Closing balance (approved only)</td><td class="n">${money(L.closing)}</td></tr></tbody></table>`;
|
|
q('#w-csv').onclick = () => SC.csv(`ledger_${cc}.csv`, ['Date', 'Txn', 'Type', 'Order', 'Mode', 'Reference', 'Status', 'Credit', 'Debit', 'TDS', 'Balance'],
|
|
L.lines.map(x => [dt(x.createdAt), x.id, x.refType, x.refNo || '', x.paymentMode, x.paymentRef || x.remarks, x.status, x.type === 'credit' ? x.amount : '', x.type === 'debit' ? x.amount : '', x.tds || '', x.runningBalance]));
|
|
if (q('#w-cl')) q('#w-cl').onclick = () => { const m = SC.modal({ title: 'Credit limit', body: `<label class="sc-lbl">Credit limit (₹)</label><input class="sc-in" id="cl-v" type="number" min="0" value="${d.summary.creditLimit}">
|
|
<div class="sc-muted" style="margin-top:6px">Shown to the customer and on the order screens. Payment rules don't consume the limit (same as msale).</div>`,
|
|
buttons: [{ label: 'Cancel' }, { label: 'Save', cls: 'sc-b-primary', onClick: async ({ root }) => { await api('/credit-limit/' + encodeURIComponent(cc), { method: 'PUT', body: { creditLimit: q('#cl-v', root).value } }); toast('Saved', 'ok'); loadLedger(); } }] }); };
|
|
} catch (e) { out.innerHTML = `<div class="sc-err">${esc(e.message)}</div>`; }
|
|
}
|
|
}
|
|
async function loadPending() {
|
|
const box = q('#p-tbl'); box.innerHTML = '<div class="empty"><span class="sc-spin"></span></div>';
|
|
try {
|
|
const rows = await api('/payments/pending');
|
|
q('#p-cnt').textContent = rows.length;
|
|
box.innerHTML = rows.length ? `<table class="lst"><thead><tr><th>Txn</th><th>Submitted</th><th>Customer</th><th>Order</th><th class="n">Order value</th><th class="n">Amount paid</th><th>Mode</th><th>Reference</th><th>Pay date</th><th>Detail</th><th></th></tr></thead><tbody>
|
|
${rows.map(r => `<tr><td>#${r.id}</td><td>${dt(r.createdAt)}</td><td>${esc(r.cardName || r.cardCode)}<div class="sc-muted">${esc(r.cardCode)}</div></td>
|
|
<td>${r.orderId ? `<span class="sc-link" data-o="${r.orderId}">${esc(r.orderRef)}</span>` : '<i>on-account</i>'}</td><td class="n">${money(r.orderTotal)}</td><td class="n">${money(r.amount)}</td>
|
|
<td>${esc(r.paymentMode)}</td><td class="mono">${esc(r.paymentRef)}</td><td>${date(r.paymentDate)}</td><td>${esc(r.paymentDetail)}${r.docId ? ` <span class="sc-link" data-doc="${r.docId}">proof</span>` : ''}</td>
|
|
<td>${cap('sales_order:payment_verify', 'approve') ? `<button class="sc-btn sc-b-green" data-ok="${r.id}">Approve</button> <button class="sc-btn sc-b-red" data-no="${r.id}">Reject</button>` : ''}</td></tr>`).join('')}
|
|
</tbody></table>` : '<div class="empty">Nothing waiting for verification.</div>';
|
|
qa('[data-o]', box).forEach(el => { el.onclick = () => openOrder(el.dataset.o); });
|
|
qa('[data-doc]', box).forEach(el => { el.onclick = () => SC.openFile('/docs/' + el.dataset.doc); });
|
|
qa('[data-ok]', box).forEach(b => { b.onclick = () => decide(b.dataset.ok, 'approve'); });
|
|
qa('[data-no]', box).forEach(b => { b.onclick = () => decide(b.dataset.no, 'reject'); });
|
|
} catch (e) { box.innerHTML = `<div class="sc-err">${esc(e.message)}</div>`; }
|
|
}
|
|
function decide(id, decision) {
|
|
SC.modal({ title: decision === 'approve' ? 'Approve payment' : 'Reject payment', body: `<div class="sc-muted" style="margin-bottom:6px">${decision === 'approve' ? 'Confirm the amount is received in the bank. The order moves to "ready for SAP".' : 'The order goes back to the customer to pay again.'}</div>
|
|
<label class="sc-lbl">Remarks ${decision === 'reject' ? '*' : ''} (visible to customer)</label><textarea class="sc-in" id="dc-r"></textarea>`,
|
|
buttons: [{ label: 'Back' }, { label: decision === 'approve' ? 'Approve' : 'Reject', cls: decision === 'approve' ? 'sc-b-green' : 'sc-b-red',
|
|
onClick: async ({ root }) => { await api(`/payments/${id}/decide`, { method: 'POST', body: { decision, remarks: q('#dc-r', root).value } }); toast('Done', 'ok'); loadPending(); } }] });
|
|
}
|
|
function onAccountForm(cc, after) {
|
|
SC.modal({ title: `On-account payment — ${esc(cc)}`, body: `<div class="sc-row">
|
|
<div><label class="sc-lbl">Type</label><select class="sc-in" id="oa-t"><option value="credit">Credit (payment received)</option><option value="debit">Debit (adjustment)</option></select></div>
|
|
<div><label class="sc-lbl">Amount *</label><input class="sc-in" type="number" min="0" step="0.01" id="oa-a"></div>
|
|
<div><label class="sc-lbl">Mode *</label><select class="sc-in" id="oa-m"><option>RTGS</option><option>NEFT</option><option>Cheque</option><option>Online</option><option>Adjustment</option></select></div></div>
|
|
<div class="sc-row" style="margin-top:8px"><div><label class="sc-lbl">Reference no. *</label><input class="sc-in" id="oa-r"></div><div><label class="sc-lbl">Payment date *</label><input class="sc-in" type="date" id="oa-d" value="${SC.today()}"></div></div>
|
|
<div style="margin-top:8px"><label class="sc-lbl">Payment remarks *</label><textarea class="sc-in" id="oa-x"></textarea></div>`,
|
|
buttons: [{ label: 'Cancel' }, { label: 'Save', cls: 'sc-b-green', onClick: async ({ root }) => {
|
|
await api('/payments/on-account', { method: 'POST', body: { cardCode: cc, type: q('#oa-t', root).value, amount: q('#oa-a', root).value, paymentMode: q('#oa-m', root).value,
|
|
paymentRef: q('#oa-r', root).value, paymentDate: q('#oa-d', root).value, paymentDetail: q('#oa-x', root).value } });
|
|
toast('Payment saved', 'ok'); after(); } }] });
|
|
}
|
|
|
|
init().catch(e => { q('#app').innerHTML = `<div class="sc-err">${esc(e.message)}</div>`; });
|
|
</script>
|
|
<script src="/sidebar.js?v=31"></script>
|
|
</body>
|
|
</html>
|