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

911 lines
44 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<script src="/auth-guard.js?v=4"></script>
<script src="/company-list.js"></script>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Purchase Order — SAP B1</title>
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@400;600;700;800&family=DM+Mono:wght@300;400;500&family=DM+Sans:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>
:root{
--bg:#f5f3ef;--surface:#fff;--surface2:#f9f8f5;
--border:#e2ddd5;--border2:#ccc7bb;
--accent:#1a1a1a;--accent2:#c8793a;--accent3:#2d6a4f;
--text:#1a1a1a;--text-muted:#7a746c;--text-label:#4a4540;
--danger:#c0392b;--success:#2d6a4f;--warning:#c8793a;
--shadow:0 1px 3px rgba(0,0,0,.06),0 4px 16px rgba(0,0,0,.04);
--shadow-lg:0 8px 40px rgba(0,0,0,.14);--r:8px;
}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);font-family:'DM Sans',sans-serif;color:var(--text);min-height:100vh;font-size:13px;}
/* ── TOP BAR ── */
.topbar{background:var(--accent);color:#fff;display:flex;align-items:center;justify-content:space-between;padding:0 20px;height:50px;position:sticky;top:0;z-index:300;}
.tb-brand{display:flex;align-items:center;gap:10px;}
.tb-mark{width:28px;height:28px;background:var(--accent2);border-radius:5px;display:flex;align-items:center;justify-content:center;font-family:'Syne',sans-serif;font-weight:800;font-size:13px;color:#fff;letter-spacing:-.5px;}
.tb-title{font-family:'Syne',sans-serif;font-weight:700;font-size:13px;}
.tb-sub{font-family:'DM Mono',monospace;font-size:9px;color:rgba(255,255,255,.4);margin-top:1px;}
.tb-actions{display:flex;gap:6px;align-items:center;}
.user-chip{font-family:'DM Mono',monospace;font-size:9px;color:rgba(255,255,255,.4);margin-left:4px;}
/* ── BUTTONS ── */
.btn{display:inline-flex;align-items:center;gap:5px;padding:6px 13px;border-radius:5px;font-size:11px;font-weight:600;cursor:pointer;border:none;transition:all .15s;font-family:'DM Sans',sans-serif;white-space:nowrap;}
.btn-primary{background:var(--accent2);color:#fff;}.btn-primary:hover{background:#b5692d;}
.btn-success{background:var(--accent3);color:#fff;}.btn-success:hover{background:#245a42;}
.btn-ghost{background:transparent;color:rgba(255,255,255,.7);border:1px solid rgba(255,255,255,.2);}.btn-ghost:hover{color:#fff;border-color:rgba(255,255,255,.5);}
.btn-outline{background:transparent;color:var(--text-label);border:1px solid var(--border2);}.btn-outline:hover{border-color:var(--accent2);color:var(--accent2);}
.btn-sm{padding:4px 10px;font-size:10px;}
.btn:disabled{opacity:.5;cursor:not-allowed;}
/* ── BADGES ── */
.badge{padding:3px 8px;border-radius:20px;font-size:9px;font-weight:700;font-family:'DM Mono',monospace;letter-spacing:.07em;text-transform:uppercase;}
.badge-draft{background:rgba(255,255,255,.1);color:rgba(255,255,255,.6);border:1px solid rgba(255,255,255,.15);}
.badge-open{background:#edf7f1;color:var(--success);border:1px solid #a8d5bc;}
/* ── PAGE TABS ── */
.page-tabs{display:flex;gap:0;border-bottom:1px solid var(--border);background:var(--surface);position:sticky;top:50px;z-index:200;padding:0 18px;}
.ptab{padding:10px 18px;font-size:11px;font-weight:600;color:var(--text-muted);cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-1px;transition:all .12s;user-select:none;font-family:'Syne',sans-serif;letter-spacing:.04em;}
.ptab.active{color:var(--accent2);border-bottom-color:var(--accent2);}
.ptab:hover:not(.active){color:var(--text);}
.ptab-pane{display:none;}
.ptab-pane.active{display:block;}
/* ── MAIN LAYOUT ── */
.main{max-width:100%;padding:14px 18px 80px;}
/* ── FORM ELEMENTS ── */
.field{display:flex;flex-direction:column;gap:3px;}
.field label{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.09em;color:var(--text-muted);}
.req{color:var(--accent2);}
input,select,textarea{background:var(--surface);border:1.5px solid var(--border);border-radius:5px;color:var(--text);font-family:'DM Sans',sans-serif;font-size:12px;padding:5px 8px;width:100%;outline:none;transition:border-color .15s;}
input:focus,select:focus,textarea:focus{border-color:var(--accent2);box-shadow:0 0 0 2px rgba(200,121,58,.1);}
input[readonly]{background:var(--surface2);color:var(--text-muted);cursor:default;}
select{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%237a746c'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 7px center;padding-right:22px;cursor:pointer;appearance:none;}
textarea{resize:vertical;min-height:56px;}
/* ── GRIDS ── */
.g2{display:grid;grid-template-columns:1fr 1fr;gap:8px;}
.g3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;}
.g4{display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:8px;}
.s2{grid-column:span 2;}
/* ── HEADER CARD ── */
.hcard{background:var(--surface);border:1px solid var(--border);border-radius:var(--r);box-shadow:var(--shadow);padding:14px 16px;margin-bottom:10px;position:relative;z-index:10;}
.hcard-row{display:grid;grid-template-columns:1fr auto;gap:16px;align-items:start;margin-bottom:10px;}
/* ── META STRIP ── */
.meta-strip{display:grid;grid-template-columns:repeat(3,auto);gap:6px 18px;text-align:right;align-items:start;}
.mi label{display:block;font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.09em;color:var(--text-muted);margin-bottom:2px;}
.mi span.mv{font-family:'DM Mono',monospace;font-size:11px;font-weight:500;color:var(--accent2);}
.mi input[type="date"]{font-family:'DM Mono',monospace;font-size:11px;color:var(--accent2);border:none;background:transparent;padding:0;width:108px;}
.mi input[type="date"]:focus{outline:none;border-bottom:1px solid var(--accent2);box-shadow:none;}
/* ── LINES CARD ── */
.tcard{background:var(--surface);border:1px solid var(--border);border-radius:var(--r);box-shadow:var(--shadow);overflow:visible;}
.tabs{display:flex;border-bottom:1px solid var(--border);background:var(--surface2);}
.tab{padding:8px 16px;font-size:11px;font-weight:600;color:var(--text-muted);cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-1px;white-space:nowrap;transition:all .12s;user-select:none;}
.tab.active{color:var(--accent2);border-bottom-color:var(--accent2);}
.tab-pane{display:none;padding:12px 14px;}
.tab-pane.active{display:block;}
/* ── TABLE ── */
.lines-bar{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;}
.stitle{font-family:'Syne',sans-serif;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.1em;color:var(--text-label);}
.lines-wrap{overflow-x:auto;border:1px solid var(--border);border-radius:5px;}
table{width:100%;border-collapse:collapse;font-size:11px;}
thead tr{background:var(--surface2);}
th{padding:6px 6px;text-align:left;font-size:9px;font-weight:700;color:var(--text-muted);text-transform:uppercase;letter-spacing:.07em;white-space:nowrap;border-bottom:1px solid var(--border);position:sticky;top:0;z-index:5;background:var(--surface2);}
td{padding:0;border-bottom:1px solid var(--border);vertical-align:middle;position:relative;}
tbody tr:last-child td{border-bottom:none;}
tbody tr:hover td{background:#fdfaf6;}
td input,td select{background:transparent;border:none;border-radius:0;padding:5px 6px;font-size:11px;color:var(--text);width:100%;font-family:'DM Sans',sans-serif;height:28px;}
td input:focus,td select:focus{background:rgba(200,121,58,.05);box-shadow:inset 0 0 0 1.5px var(--accent2);outline:none;}
td select{appearance:none;background-image:none;cursor:pointer;}
td input[readonly]{background:var(--surface2);color:var(--text-muted);}
.nc{text-align:right;}
.nc input{text-align:right;font-family:'DM Mono',monospace;}
.li{display:block;width:26px;text-align:center;font-family:'DM Mono',monospace;font-size:10px;color:var(--text-muted);padding:5px 2px;}
.xbtn{display:block;width:24px;text-align:center;color:var(--text-muted);cursor:pointer;padding:4px 2px;font-size:12px;transition:color .12s;}
.xbtn:hover{color:var(--danger);}
/* ── TOTALS ── */
.totals-wrap{display:flex;justify-content:flex-end;padding:10px 12px;border-top:1px solid var(--border);background:var(--surface2);}
.tg{display:grid;grid-template-columns:180px 140px;gap:3px 8px;text-align:right;align-items:center;}
.tl{font-size:11px;color:var(--text-muted);}
.tv{font-family:'DM Mono',monospace;font-size:11px;color:var(--text);}
.tt-row .tl{font-weight:700;font-size:12px;color:var(--text);}
.tt-row .tv{font-size:14px;font-weight:600;color:var(--accent2);}
/* ── AUTOCOMPLETE ── */
.acw{position:relative;width:100%;}
.acd-fixed{position:fixed;min-width:240px;background:var(--surface);border:1.5px solid var(--accent2);border-radius:6px;box-shadow:0 8px 32px rgba(0,0,0,.18);z-index:99999;max-height:240px;overflow-y:auto;display:none;}
.acd-fixed.open{display:block;}
.aci{padding:7px 10px;cursor:pointer;display:flex;justify-content:space-between;align-items:center;gap:8px;border-bottom:1px solid var(--border);transition:background .08s;}
.aci:last-child{border-bottom:none;}
.aci:hover,.aci.hi{background:#fef6ed;}
.ac-name{font-size:11px;color:var(--text);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.ac-code{font-family:'DM Mono',monospace;font-size:10px;color:var(--accent2);flex-shrink:0;}
.ac-msg{padding:8px 10px;color:var(--text-muted);font-size:11px;font-style:italic;}
.spin{display:inline-block;width:11px;height:11px;border:2px solid var(--border);border-top-color:var(--accent2);border-radius:50%;animation:sp .6s linear infinite;flex-shrink:0;}
@keyframes sp{to{transform:rotate(360deg)}}
/* ── OVERLAY & TOAST ── */
.overlay{display:none;position:fixed;inset:0;background:rgba(245,243,239,.85);backdrop-filter:blur(3px);z-index:9000;align-items:center;justify-content:center;flex-direction:column;gap:10px;}
.overlay.show{display:flex;}
.spinner{width:36px;height:36px;border:3px solid var(--border2);border-top-color:var(--accent2);border-radius:50%;animation:sp .7s linear infinite;}
.ta{position:fixed;top:60px;right:16px;z-index:9999;display:flex;flex-direction:column;gap:6px;pointer-events:none;}
.toast{padding:9px 13px;border-radius:6px;font-size:11px;font-weight:500;max-width:360px;box-shadow:var(--shadow-lg);display:flex;align-items:center;gap:7px;animation:tin .18s ease;pointer-events:all;}
@keyframes tin{from{opacity:0;transform:translateX(14px)}to{opacity:1}}
.t-ok{background:#edf7f1;color:var(--success);border:1px solid #a8d5bc;}
.t-err{background:#fdf0ee;color:var(--danger);border:1px solid #e8b0aa;}
.t-info{background:#fef6ed;color:var(--accent2);border:1px solid #e8c9a8;}
/* ── VIEW TAB TABLE ── */
.vtable-wrap{border:1px solid var(--border);border-radius:var(--r);overflow:hidden;margin-top:12px;}
.vtable-wrap table{font-size:12px;}
.vtable-wrap th{font-size:9px;padding:7px 10px;}
.vtable-wrap td{padding:6px 10px;border-bottom:1px solid var(--border);}
.vtable-wrap tbody tr:hover td{background:#fdfaf6;cursor:default;}
.doc-status{padding:2px 7px;border-radius:10px;font-size:9px;font-weight:700;font-family:'DM Mono',monospace;text-transform:uppercase;}
.ds-open{background:#edf7f1;color:var(--success);}
.ds-closed{background:var(--surface2);color:var(--text-muted);}
.ds-draft{background:#fff8ed;color:var(--accent2);}
.view-bar{display:flex;align-items:center;justify-content:space-between;margin-bottom:4px;}
.empty-state{text-align:center;padding:40px 20px;color:var(--text-muted);font-size:11px;}
hr.dv{border:none;border-top:1px solid var(--border);margin:10px 0;}
.resp{display:none;background:#1a1a1a;border-radius:6px;padding:12px;font-family:'DM Mono',monospace;font-size:10px;color:#a8d5bc;white-space:pre-wrap;word-break:break-all;max-height:280px;overflow-y:auto;margin-top:12px;line-height:1.7;}
</style>
<link rel="stylesheet" href="/theme.css?v=3"/>
</head>
<body>
<!-- LOADING OVERLAY -->
<div class="overlay" id="overlay">
<div class="spinner"></div>
<span style="font-size:11px;color:var(--text-muted);font-family:'DM Mono',monospace;">Posting to SAP B1…</span>
</div>
<!-- TOAST AREA -->
<div class="ta" id="ta"></div>
<!-- FIXED AUTOCOMPLETE PORTAL -->
<div class="acd-fixed" id="acPortal"></div>
<!-- TOP BAR -->
<div class="topbar">
<div class="tb-brand">
<div class="tb-mark">PO</div>
<div>
<div class="tb-title">Purchase Order</div>
<div class="tb-sub">Purchase Orders · SAP B1</div>
</div>
</div>
<div class="tb-actions">
<select id="companySel" style="background:rgba(255,255,255,.07);border:1px solid rgba(255,255,255,.15);color:#fff;border-radius:5px;padding:4px 22px 4px 8px;font-size:11px;font-family:'DM Mono',monospace;background-image:url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23ffffff80'/%3E%3C/svg%3E\");background-repeat:no-repeat;background-position:right 6px center;appearance:none;cursor:pointer;" onchange="onCompanyChange()">
</select>
<span class="badge badge-draft" id="statusBadge">● DRAFT</span>
<button class="btn btn-ghost btn-sm" onclick="resetForm()">↺ Reset</button>
<button class="btn btn-primary btn-sm" id="postBtn" onclick="submitPO()">✓ Post PO</button>
<span class="user-chip" id="userChip"></span>
</div>
</div>
<!-- PAGE TABS -->
<div class="page-tabs">
<div class="ptab active" onclick="switchPage(this,'pgCreate')">Create Order</div>
<div class="ptab" onclick="switchPage(this,'pgView')">View Orders</div>
</div>
<!-- ═══════════════════════════════════════════ -->
<!-- CREATE TAB -->
<!-- ═══════════════════════════════════════════ -->
<div class="ptab-pane active" id="pgCreate">
<div class="main">
<!-- HEADER CARD -->
<div class="hcard">
<div class="hcard-row">
<!-- Left: Vendor + fields -->
<div>
<div style="display:grid;grid-template-columns:2fr 1fr 1fr;gap:8px;margin-bottom:8px;">
<!-- Vendor Autocomplete -->
<div class="field">
<label>Vendor <span class="req">*</span></label>
<div class="acw">
<input type="text" id="vendorSearch" placeholder="Search vendor code or name…" autocomplete="off" oninput="vendorAc(this.value)" onfocus="if(this.value.length>1)vendorAc(this.value)">
</div>
<input type="hidden" id="cardCode">
<input type="hidden" id="cardName">
</div>
<div class="field">
<label>Vendor Ref. No.</label>
<input type="text" id="numAtCard" placeholder="e.g. INV-2024-001">
</div>
<div class="field">
<label>Delivery Date <span class="req">*</span></label>
<input type="date" id="docDueDate">
</div>
</div>
<hr class="dv" style="margin:8px 0;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;max-width:480px;">
<div class="field">
<label>Remarks</label>
<textarea id="comments" rows="2" placeholder="Order remarks / notes…"></textarea>
</div>
<div class="field">
<label>Branch</label>
<select id="bplId" onchange="onBranchChange()"><option value="">— Select —</option></select>
</div>
</div>
</div>
<!-- Right: Meta strip -->
<div class="meta-strip">
<div class="mi">
<label>Doc No.</label>
<span class="mv" id="docNumDisp">AUTO</span>
</div>
<div class="mi">
<label>Posting Date</label>
<input type="date" id="docDate">
</div>
<div class="mi">
<label>Document Date</label>
<input type="date" id="taxDate">
</div>
</div>
</div>
</div>
<!-- LINES CARD -->
<div class="tcard">
<div class="tabs">
<div class="tab active" onclick="switchTab(this,'tcContents')">Contents</div>
</div>
<div class="tab-pane active" id="tcContents">
<div class="lines-bar">
<span class="stitle">Document Lines</span>
<div style="display:flex;gap:6px;">
<button class="btn btn-outline btn-sm" onclick="addLine()">+ Add Line</button>
<button class="btn btn-outline btn-sm" onclick="clearLines()">Clear</button>
</div>
</div>
<div class="lines-wrap">
<table id="linesTable">
<thead>
<tr>
<th style="width:28px;">#</th>
<th style="min-width:110px;">Item Code <span style="color:var(--accent2);">*</span></th>
<th style="min-width:160px;">Description</th>
<th style="min-width:60px;" class="nc">Qty <span style="color:var(--accent2);">*</span></th>
<th style="min-width:55px;">Unit</th>
<th style="min-width:100px;" class="nc">Unit Price <span style="color:var(--accent2);">*</span></th>
<th style="min-width:60px;" class="nc">Disc %</th>
<th style="min-width:120px;">Tax Code</th>
<th style="min-width:50px;" class="nc">Tax %</th>
<th style="min-width:100px;" class="nc">Line Total</th>
<th style="min-width:80px;">Warehouse</th>
<th style="width:26px;"></th>
</tr>
</thead>
<tbody id="tbody"></tbody>
</table>
</div>
<!-- TOTALS -->
<div class="totals-wrap">
<div class="tg">
<span class="tl">Before Discount</span>
<span class="tv" id="totPre">0.00</span>
<span class="tl">Discount</span>
<span class="tv" id="totDisc">0.00</span>
<span class="tl">Tax</span>
<span class="tv" id="totTax">0.00</span>
<span class="tl tt-row">Total Payment Due</span>
<span class="tv tt-row" id="totDoc">0.00</span>
</div>
</div>
</div>
</div>
<pre class="resp" id="respPanel"></pre>
</div>
</div>
<!-- ═══════════════════════════════════════════ -->
<!-- VIEW ORDERS TAB -->
<!-- ═══════════════════════════════════════════ -->
<div class="ptab-pane" id="pgView">
<div class="main">
<div class="hcard">
<div class="view-bar">
<div>
<span class="stitle">Purchase Orders</span>
<span style="font-size:10px;color:var(--text-muted);margin-left:8px;font-family:'DM Mono',monospace;" id="viewCount"></span>
</div>
<button class="btn btn-outline btn-sm" onclick="loadOrders()">↻ Refresh</button>
</div>
<div id="viewTableWrap">
<div class="empty-state">Click <strong>Refresh</strong> or switch to this tab to load orders.</div>
</div>
</div>
</div>
</div>
<script>
// ═══════════════════════════════════════════
// AUTH & CONFIG
// ═══════════════════════════════════════════
const API = '';
const PO_ENDPOINT = '/api/sap/purchase-order';
let TOKEN = sessionStorage.getItem('portal_token') || '';
if (!TOKEN) { window.location.href = '/'; }
let lineSeq = 0;
const _timers = {};
let _taxCodes = [];
let _warehouses = [];
let _branches = [];
let _portalTarget = null;
let _ordersLoaded = false;
const ah = () => TOKEN ? { 'Authorization': `Bearer ${TOKEN}` } : {};
const esc = s => String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
const fmt = n => parseFloat(n || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const company = () => document.getElementById('companySel').value;
// ═══════════════════════════════════════════
// TOAST
// ═══════════════════════════════════════════
function toast(msg, type = 'info') {
const el = document.createElement('div');
el.className = `toast t-${type}`;
const icon = { ok: '✓', err: '✕', info: 'ℹ' }[type] || 'ℹ';
el.innerHTML = `<span>${icon}</span><span>${esc(String(msg))}</span>`;
document.getElementById('ta').appendChild(el);
setTimeout(() => el.remove(), 5000);
}
// ═══════════════════════════════════════════
// PAGE / INNER TAB SWITCHING
// ═══════════════════════════════════════════
function switchPage(el, pid) {
document.querySelectorAll('.ptab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.ptab-pane').forEach(p => p.classList.remove('active'));
el.classList.add('active');
document.getElementById(pid).classList.add('active');
if (pid === 'pgView' && !_ordersLoaded) loadOrders();
}
function switchTab(el, pid) {
el.closest('.tcard').querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
el.closest('.tcard').querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active'));
el.classList.add('active');
document.getElementById(pid).classList.add('active');
}
function onCompanyChange() {
loadBranches();
loadWarehouses();
loadTaxCodes();
if (_ordersLoaded) { _ordersLoaded = false; }
}
// ═══════════════════════════════════════════
// INIT
// ═══════════════════════════════════════════
async function populateCompanySelect(){
try{
const list=await window.fetchCompanyList();
if(list.length){
const sel=document.getElementById('companySel');
const cur=sel.value;
sel.innerHTML=list.map(c=>`<option value="${esc(c.value)}">${esc(c.label)}</option>`).join('');
if(list.some(c=>c.value===cur))sel.value=cur;
}
}catch(e){}
}
async function initPage() {
await populateCompanySelect();
const today = new Date().toISOString().slice(0, 10);
document.getElementById('docDate').value = today;
document.getElementById('taxDate').value = today;
document.getElementById('docDueDate').value = today;
// Try to get username
try {
const r = await fetch(`${API}/api/auth/me`, { headers: ah() });
const d = await r.json();
if (d.success) document.getElementById('userChip').textContent = d.user?.name || d.user?.username || '';
} catch(e) {}
await Promise.all([loadBranches(), loadWarehouses(), loadTaxCodes()]);
addLine();
}
// ═══════════════════════════════════════════
// LOOKUPS
// ═══════════════════════════════════════════
async function loadTaxCodes() {
const fallback = [
{ Code: 'CG+SG@5', Rate: 5 },
{ Code: 'CG+SG@12', Rate: 12 },
{ Code: 'CG+SG@18', Rate: 18 },
{ Code: 'CG+SG@28', Rate: 28 },
{ Code: 'IGST@5', Rate: 5 },
{ Code: 'IGST@18', Rate: 18 },
{ Code: 'EXEMPT', Rate: 0 },
];
try {
const res = await fetch(`${API}/api/sap/lookup/tax-codes?company=${company()}`, { headers: ah() });
if (!res.ok) throw new Error('HTTP ' + res.status);
const d = await res.json();
_taxCodes = (d.data && d.data.length) ? d.data : fallback;
} catch(e) {
console.warn('[PO] tax-codes fallback:', e.message);
_taxCodes = fallback;
}
document.querySelectorAll('.taxcode-sel').forEach(s => fillTaxSel(s));
}
async function loadWarehouses() {
try {
const res = await fetch(`${API}/api/sap/lookup/warehouses?company=${company()}`, { headers: ah() });
const d = await res.json();
_warehouses = d.data || [];
document.querySelectorAll('.whs-sel').forEach(s => fillWhsSel(s));
} catch(e) { console.warn('[PO] warehouses:', e.message); }
}
async function loadBranches() {
try {
const res = await fetch(`${API}/api/sap/lookup/branches?company=${company()}`, { headers: ah() });
const d = await res.json();
_branches = d.data || [];
const sel = document.getElementById('bplId');
sel.innerHTML = '<option value="">— Select Branch —</option>' +
_branches.map(b => `<option value="${esc(b.BPLId)}">${esc(b.BPLName)}</option>`).join('');
} catch(e) { console.warn('[PO] branches:', e.message); }
}
function onBranchChange() {}
function fillTaxSel(sel, defaultCode) {
const prev = defaultCode || sel.value;
sel.innerHTML = '<option value="">— Tax —</option>' +
_taxCodes.map(t =>
`<option value="${esc(t.Code)}" data-rate="${t.Rate || 0}">${esc(t.Code)}</option>`
).join('');
if (prev) {
const opt = sel.querySelector(`option[value="${CSS.escape(prev)}"]`);
if (opt) sel.value = prev;
}
sel.onchange = () => {
const opt = sel.options[sel.selectedIndex];
const rate = parseFloat(opt?.dataset?.rate || 0);
const row = sel.closest('tr');
if (row) {
const tp = row.querySelector('.taxpct-inp');
if (tp) { tp.value = rate; recalc(); }
}
};
}
function fillWhsSel(sel, defaultCode) {
const prev = defaultCode || sel.value;
sel.innerHTML = '<option value="">— WH —</option>' +
_warehouses.map(w =>
`<option value="${esc(w.WarehouseCode)}">${esc(w.WarehouseCode)} – ${esc(w.WarehouseName)}</option>`
).join('');
if (prev) {
const opt = sel.querySelector(`option[value="${CSS.escape(prev)}"]`);
if (opt) sel.value = prev;
}
}
// ═══════════════════════════════════════════
// AUTOCOMPLETE ENGINE
// ═══════════════════════════════════════════
const portal = document.getElementById('acPortal');
function positionPortal(inputEl) {
const r = inputEl.getBoundingClientRect();
portal.style.left = r.left + 'px';
portal.style.top = (r.bottom + 2) + 'px';
portal.style.width = Math.max(260, r.width) + 'px';
}
function openPortal(inputEl, items, onSelect) {
_portalTarget = { inputEl, onSelect };
portal.innerHTML = '';
if (!items.length) {
portal.innerHTML = '<div class="ac-msg">No results found</div>';
} else {
items.forEach(item => {
const div = document.createElement('div');
div.className = 'aci';
div.innerHTML = `<span class="ac-name">${esc(item.label)}</span><span class="ac-code">${esc(item.code)}</span>`;
div.addEventListener('mousedown', e => { e.preventDefault(); onSelect(item); closePortal(); });
portal.appendChild(div);
});
}
positionPortal(inputEl);
portal.classList.add('open');
}
function openPortalLoading(inputEl) {
portal.innerHTML = '<div class="aci"><div class="spin"></div><span class="ac-name" style="margin-left:6px;">Searching…</span></div>';
positionPortal(inputEl);
portal.classList.add('open');
}
function openPortalMsg(inputEl, msg) {
portal.innerHTML = `<div class="ac-msg">${esc(msg)}</div>`;
positionPortal(inputEl);
portal.classList.add('open');
}
function closePortal() {
portal.classList.remove('open');
portal.innerHTML = '';
_portalTarget = null;
}
document.addEventListener('mousedown', e => {
if (!portal.contains(e.target) && !e.target.closest('.acw')) closePortal();
});
window.addEventListener('scroll', () => {
if (_portalTarget && portal.classList.contains('open')) positionPortal(_portalTarget.inputEl);
}, true);
// ── Vendor Autocomplete ───────────────────
function vendorAc(q) {
clearTimeout(_timers.vendor);
if (!q || q.length < 2) { closePortal(); return; }
openPortalLoading(document.getElementById('vendorSearch'));
_timers.vendor = setTimeout(async () => {
try {
const res = await fetch(`${API}/api/sap/lookup/vendors?q=${encodeURIComponent(q)}&company=${company()}`, { headers: ah() });
const data = await res.json();
const list = (data.data || []).map(v => ({ label: v.CardName, code: v.CardCode, raw: v }));
if (!list.length) { openPortalMsg(document.getElementById('vendorSearch'), 'No vendors found'); return; }
openPortal(document.getElementById('vendorSearch'), list, item => {
document.getElementById('cardCode').value = item.raw.CardCode;
document.getElementById('cardName').value = item.raw.CardName;
document.getElementById('vendorSearch').value = `${item.raw.CardCode} — ${item.raw.CardName}`;
toast(`Vendor: ${item.raw.CardName}`, 'ok');
});
} catch(err) { openPortalMsg(document.getElementById('vendorSearch'), 'Error: ' + err.message); }
}, 300);
}
// ── Item Autocomplete ─────────────────────
function itemAcForInput(inputEl, onSelect) {
const q = inputEl.value;
clearTimeout(_timers['item_' + inputEl.id]);
if (!q || q.length < 2) { closePortal(); return; }
openPortalLoading(inputEl);
_timers['item_' + inputEl.id] = setTimeout(async () => {
try {
const res = await fetch(`${API}/api/sap/lookup/items?q=${encodeURIComponent(q)}&company=${company()}`, { headers: ah() });
const data = await res.json();
const list = (data.data || []).map(i => ({ label: i.ItemName, code: i.ItemCode, raw: i }));
if (!list.length) { openPortalMsg(inputEl, 'No items found'); return; }
openPortal(inputEl, list, item => onSelect(item.raw));
} catch(err) { openPortalMsg(inputEl, 'Error: ' + err.message); }
}, 300);
}
// ═══════════════════════════════════════════
// LINE MANAGEMENT
// ═══════════════════════════════════════════
function addLine(d = {}) {
lineSeq++;
const lid = lineSeq;
const tr = document.createElement('tr');
tr.id = `line-${lid}`;
const rowNum = document.querySelectorAll('#tbody tr').length + 1;
tr.innerHTML = `
<td><span class="li">${rowNum}</span></td>
<td style="min-width:110px;">
<input class="icode-inp" type="text" id="icode-${lid}" placeholder="Item Code"
value="${esc(d.itemCode || '')}"
oninput="itemAcForInput(this, function(i){selItem(${lid},i)})"
onfocus="if(this.value.length>=2)itemAcForInput(this,function(i){selItem(${lid},i)})">
</td>
<td style="min-width:160px;"><input class="desc-inp" type="text" id="desc-${lid}" placeholder="Description" value="${esc(d.desc || '')}"></td>
<td class="nc" style="min-width:60px;"><input class="qty-inp" type="number" id="qty-${lid}" value="${d.qty || 1}" min="0" step="0.001" oninput="recalc()"></td>
<td style="min-width:55px;"><input class="uom-inp" type="text" id="uom-${lid}" value="${esc(d.uom || '')}" placeholder="UOM" readonly style="text-align:center;font-size:10px;color:var(--text-muted);background:var(--surface2);"></td>
<td class="nc" style="min-width:100px;"><input class="price-inp" type="number" id="price-${lid}" value="${d.price || 0}" min="0" step="0.01" oninput="recalc()"></td>
<td class="nc" style="min-width:60px;"><input class="disc-inp" type="number" id="disc-${lid}" value="${d.disc || 0}" min="0" max="100" step="0.01" oninput="recalc()"></td>
<td style="min-width:120px;"><select class="taxcode-sel" id="tax-${lid}"></select></td>
<td class="nc" style="min-width:50px;"><input class="taxpct-inp" type="number" id="tp-${lid}" value="${d.taxPct || 0}" min="0" max="100" oninput="recalc()" style="color:var(--text-muted);font-size:10px;"></td>
<td class="nc" style="min-width:100px;"><input class="linetot-inp" type="text" id="lt-${lid}" readonly value="0.00" style="color:var(--accent2);font-weight:600;font-family:'DM Mono',monospace;"></td>
<td style="min-width:80px;"><select class="whs-sel" id="whs-${lid}"></select></td>
<td><span class="xbtn" onclick="removeLine(${lid})">✕</span></td>
`;
document.getElementById('tbody').appendChild(tr);
const taxSel = tr.querySelector('.taxcode-sel');
if (taxSel) fillTaxSel(taxSel, d.taxCode);
const whsSel = tr.querySelector('.whs-sel');
if (whsSel) fillWhsSel(whsSel, d.whs);
renumber();
recalc();
}
function selItem(lid, item) {
const icode = document.getElementById(`icode-${lid}`);
const desc = document.getElementById(`desc-${lid}`);
const uom = document.getElementById(`uom-${lid}`);
if (icode) icode.value = item.ItemCode;
if (desc) desc.value = item.ItemName || '';
if (uom) uom.value = item.InventoryUOM || '';
recalc();
toast(`Item: ${item.ItemCode} — ${item.ItemName}`, 'info');
}
function removeLine(lid) {
document.getElementById(`line-${lid}`)?.remove();
renumber();
recalc();
}
function clearLines() {
if (!confirm('Remove all lines?')) return;
document.getElementById('tbody').innerHTML = '';
lineSeq = 0;
recalc();
}
function renumber() {
document.querySelectorAll('#tbody tr').forEach((tr, i) => {
const s = tr.querySelector('.li');
if (s) s.textContent = i + 1;
});
}
// ═══════════════════════════════════════════
// RECALCULATE TOTALS
// ═══════════════════════════════════════════
function recalc() {
let sumPre = 0, sumDisc = 0, sumTax = 0;
document.querySelectorAll('#tbody tr').forEach(tr => {
const qty = parseFloat(tr.querySelector('.qty-inp')?.value || 0);
const price = parseFloat(tr.querySelector('.price-inp')?.value || 0);
const disc = parseFloat(tr.querySelector('.disc-inp')?.value || 0);
const taxPct = parseFloat(tr.querySelector('.taxpct-inp')?.value|| 0);
const gross = qty * price;
const discAmt = gross * disc / 100;
const lineNet = gross - discAmt;
const lineTax = lineNet * taxPct / 100;
const lineTotal= lineNet + lineTax;
const ltInp = tr.querySelector('.linetot-inp');
if (ltInp) ltInp.value = lineTotal.toFixed(2);
sumPre += gross;
sumDisc += discAmt;
sumTax += lineTax;
});
const total = sumPre - sumDisc + sumTax;
document.getElementById('totPre').textContent = fmt(sumPre);
document.getElementById('totDisc').textContent = fmt(sumDisc);
document.getElementById('totTax').textContent = fmt(sumTax);
document.getElementById('totDoc').textContent = fmt(total);
}
// ═══════════════════════════════════════════
// BUILD PAYLOAD
// ═══════════════════════════════════════════
function buildPayload() {
const cardCode = document.getElementById('cardCode').value.trim();
const docDate = document.getElementById('docDate').value;
const dueDate = document.getElementById('docDueDate').value;
const numAtCard = document.getElementById('numAtCard').value.trim();
const comments = document.getElementById('comments').value.trim();
const bplId = parseInt(document.getElementById('bplId').value) || undefined;
const lines = [];
document.querySelectorAll('#tbody tr').forEach((tr, idx) => {
const itemCode = tr.querySelector('.icode-inp')?.value?.trim() || '';
const desc = tr.querySelector('.desc-inp')?.value || '';
const qty = parseFloat(tr.querySelector('.qty-inp')?.value || 0);
const price = parseFloat(tr.querySelector('.price-inp')?.value|| 0);
const disc = parseFloat(tr.querySelector('.disc-inp')?.value || 0);
const taxCode = tr.querySelector('.taxcode-sel')?.value || '';
const whs = tr.querySelector('.whs-sel')?.value || '';
if (!itemCode && !desc && qty === 0 && price === 0) return; // skip empty
const line = {
ItemCode: itemCode,
ItemDescription: desc,
Quantity: qty,
UnitPrice: price,
DiscountPercent: disc,
TaxCode: taxCode || undefined,
};
if (whs) line.WarehouseCode = whs;
lines.push(line);
});
const payload = {
company: company(),
CardCode: cardCode,
DocDate: docDate,
DocDueDate: dueDate,
Comments: comments || undefined,
DocumentLines: lines,
};
if (numAtCard) payload.NumAtCard = numAtCard;
if (bplId) payload.BPL_IDAssignedToInvoice = bplId;
// Clean undefined
Object.keys(payload).forEach(k => { if (payload[k] === undefined) delete payload[k]; });
return payload;
}
// ═══════════════════════════════════════════
// SUBMIT
// ═══════════════════════════════════════════
async function submitPO() {
const cardCode = document.getElementById('cardCode').value.trim();
if (!cardCode) return toast('Select a vendor first.', 'err');
if (!document.getElementById('docDate').value) return toast('Posting Date is required.', 'err');
if (!document.getElementById('docDueDate').value) return toast('Delivery Date is required.', 'err');
const rows = document.querySelectorAll('#tbody tr');
if (!rows.length) return toast('Add at least one line item.', 'err');
let hasItem = false;
let valid = true;
rows.forEach(tr => {
const ic = tr.querySelector('.icode-inp')?.value?.trim();
const qty = parseFloat(tr.querySelector('.qty-inp')?.value || 0);
const pr = parseFloat(tr.querySelector('.price-inp')?.value || 0);
if (ic) hasItem = true;
if (ic && (qty <= 0 || pr < 0)) valid = false;
});
if (!hasItem) return toast('At least one line must have an Item Code.', 'err');
if (!valid) return toast('Check line Qty and Unit Price values.', 'err');
const payload = buildPayload();
document.getElementById('overlay').classList.add('show');
document.getElementById('postBtn').disabled = true;
try {
const res = await fetch(`${API}${PO_ENDPOINT}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...ah() },
body: JSON.stringify(payload),
});
const data = await res.json();
document.getElementById('overlay').classList.remove('show');
document.getElementById('postBtn').disabled = false;
if (res.ok && (data?.DocEntry || data?.DocNum)) {
const docNum = data.DocNum || data.DocEntry;
document.getElementById('statusBadge').textContent = '● OPEN';
document.getElementById('statusBadge').className = 'badge badge-open';
document.getElementById('docNumDisp').textContent = docNum;
toast(`Purchase Order #${docNum} created successfully!`, 'ok');
_ordersLoaded = false;
setTimeout(() => resetForm(true), 800);
} else {
const sapMsg = data?.error?.message?.value || data?.message || JSON.stringify(data).slice(0, 200);
toast(`SAP Error: ${sapMsg}`, 'err');
document.getElementById('respPanel').textContent = JSON.stringify(data, null, 2);
document.getElementById('respPanel').style.display = 'block';
}
} catch(err) {
document.getElementById('overlay').classList.remove('show');
document.getElementById('postBtn').disabled = false;
toast('Network error: ' + err.message, 'err');
}
}
// ═══════════════════════════════════════════
// RESET
// ═══════════════════════════════════════════
function resetForm(silent = false) {
if (!silent && !confirm('Reset the form? All unsaved data will be lost.')) return;
['vendorSearch','cardCode','cardName','numAtCard','comments'].forEach(id => {
const el = document.getElementById(id);
if (el) el.value = '';
});
const today = new Date().toISOString().slice(0, 10);
['docDate','taxDate','docDueDate'].forEach(id => {
const el = document.getElementById(id);
if (el) el.value = today;
});
document.getElementById('bplId').value = '';
document.getElementById('tbody').innerHTML = '';
lineSeq = 0;
document.getElementById('statusBadge').textContent = '● DRAFT';
document.getElementById('statusBadge').className = 'badge badge-draft';
document.getElementById('docNumDisp').textContent = 'AUTO';
document.getElementById('respPanel').style.display = 'none';
closePortal();
addLine();
if (!silent) toast('Form reset.', 'info');
}
// ═══════════════════════════════════════════
// VIEW ORDERS
// ═══════════════════════════════════════════
async function loadOrders() {
const wrap = document.getElementById('viewTableWrap');
wrap.innerHTML = '<div class="empty-state"><div class="spin" style="width:20px;height:20px;margin:0 auto 8px;"></div>Loading orders…</div>';
try {
const url = `${API}/api/sap/documents/PurchaseOrders?company=${encodeURIComponent(company())}&$top=50&$orderby=DocEntry+desc`;
const res = await fetch(url, { headers: ah() });
if (!res.ok) throw new Error('HTTP ' + res.status);
const data = await res.json();
const orders = data.value || data.data || data || [];
_ordersLoaded = true;
document.getElementById('viewCount').textContent = `${orders.length} record(s)`;
if (!orders.length) {
wrap.innerHTML = '<div class="empty-state">No purchase orders found for this company.</div>';
return;
}
const rows = orders.map(o => {
const st = o.DocumentStatus || '';
const stCls = st === 'bost_Open' ? 'ds-open' : st === 'bost_Close' ? 'ds-closed' : 'ds-draft';
const stLbl = st === 'bost_Open' ? 'Open' : st === 'bost_Close' ? 'Closed' : 'Draft';
const dueD = o.DocDueDate ? o.DocDueDate.slice(0, 10) : '—';
const docD = o.DocDate ? o.DocDate.slice(0, 10) : '—';
const total = parseFloat(o.DocTotal || 0).toLocaleString('en-IN', { minimumFractionDigits: 2 });
return `<tr>
<td style="font-family:'DM Mono',monospace;font-size:11px;font-weight:600;color:var(--accent2);">${esc(o.DocNum || '')}</td>
<td style="font-family:'DM Mono',monospace;font-size:11px;">${esc(docD)}</td>
<td><span style="font-size:11px;">${esc(o.CardCode || '')} <span style="color:var(--text-muted);">— ${esc(o.CardName || '')}</span></span></td>
<td style="font-family:'DM Mono',monospace;font-size:11px;">${esc(dueD)}</td>
<td style="font-family:'DM Mono',monospace;font-size:12px;font-weight:600;text-align:right;">₹ ${total}</td>
<td><span class="doc-status ${stCls}">${stLbl}</span></td>
</tr>`;
}).join('');
wrap.innerHTML = `<div class="vtable-wrap"><table>
<thead><tr>
<th>Doc #</th>
<th>Doc Date</th>
<th>Vendor</th>
<th>Due Date</th>
<th style="text-align:right;">Doc Total</th>
<th>Status</th>
</tr></thead>
<tbody>${rows}</tbody>
</table></div>`;
} catch(err) {
wrap.innerHTML = `<div class="empty-state" style="color:var(--danger);">Failed to load orders: ${esc(err.message)}</div>`;
toast('Error loading orders: ' + err.message, 'err');
}
}
// ═══════════════════════════════════════════
// BOOT
// ═══════════════════════════════════════════
initPage();
</script>
<script src="/sidebar.js?v=31"></script>
</body>
</html>