1138 lines
60 KiB
HTML
1138 lines
60 KiB
HTML
<!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,maximum-scale=1.0"/>
|
||
<title>Purchase Quotation — 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&family=Bebas+Neue&display=swap" rel="stylesheet"/>
|
||
<style>
|
||
:root{
|
||
--teal:#047e7e;--teal2:#035b5b;--teal3:#06b6b6;
|
||
--blue:#1d4ed8;--amber:#d97706;--green:#059669;--red:#dc2626;
|
||
--text:#111827;--text2:#374151;--text3:#6b7280;--text4:#9ca3af;
|
||
--bg:#f0f2f5;--surface:#fff;--border:#e5e7eb;--border2:#d1d5db;
|
||
--surface2:#f9fafb;--surface3:#f3f4f6;
|
||
--radius:10px;--font:'Space Grotesk',sans-serif;
|
||
--mono:'JetBrains Mono',monospace;
|
||
--shadow:0 4px 20px rgba(0,0,0,.1)
|
||
}
|
||
*{box-sizing:border-box;margin:0;padding:0}
|
||
body{font-family:var(--font);background:var(--bg);color:var(--text);min-height:100vh;-webkit-font-smoothing:antialiased}
|
||
|
||
/* nav is removed by sidebar.js — keep hidden */
|
||
nav{display:none}
|
||
|
||
main{padding:14px 20px 80px}
|
||
|
||
/* ── Tabs ── */
|
||
.tabs{display:flex;gap:4px;margin-bottom:14px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:4px;box-shadow:0 1px 3px rgba(0,0,0,.04)}
|
||
.tab{padding:8px 16px;border-radius:7px;font-size:12px;font-weight:700;color:var(--text3);cursor:pointer;border:none;background:none;font-family:var(--font);transition:all .15s}
|
||
.tab:hover{color:var(--text2);background:var(--surface3)}
|
||
.tab.on{background:linear-gradient(135deg,var(--teal),var(--teal2));color:#fff;box-shadow:0 2px 8px rgba(4,126,126,.3)}
|
||
|
||
/* ── Cards ── */
|
||
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);margin-bottom:12px;box-shadow:0 1px 3px rgba(0,0,0,.05)}
|
||
.card-hd{background:var(--surface3);padding:10px 16px;font-size:11px;font-weight:800;color:var(--text2);display:flex;align-items:center;gap:8px;border-bottom:1px solid var(--border);text-transform:uppercase;letter-spacing:.5px}
|
||
.card-hd .ico{font-size:14px}
|
||
.card-hd .right{margin-left:auto;font-size:10px;font-weight:500;color:var(--text3)}
|
||
.card-bd{padding:16px}
|
||
|
||
/* ── Fields ── */
|
||
.fg{margin-bottom:11px}
|
||
.fl{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.4px;color:var(--text3);margin-bottom:4px;display:block}
|
||
.req{color:var(--teal)}
|
||
.fi{width:100%;padding:8px 11px;border:1.5px solid var(--border);border-radius:8px;font-size:13px;font-family:var(--font);background:var(--surface);color:var(--text);outline:none;height:36px;transition:border-color .15s,box-shadow .15s}
|
||
.fi:focus{border-color:var(--teal);box-shadow:0 0 0 3px rgba(4,126,126,.1)}
|
||
.fi::placeholder{color:var(--text4)}
|
||
.fi[readonly]{background:var(--surface2);color:var(--text3);cursor:default}
|
||
select.fi{cursor:pointer;appearance:none;background:var(--surface) 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='%236b7280'/%3E%3C/svg%3E") no-repeat right 10px center;padding-right:26px}
|
||
textarea.fi{height:auto;min-height:60px;resize:vertical}
|
||
.fi-row{display:grid;grid-template-columns:1fr 1fr;gap:10px}
|
||
.fi-row3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px}
|
||
.fi-row4{display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:10px}
|
||
@media(max-width:600px){.fi-row,.fi-row3,.fi-row4{grid-template-columns:1fr}}
|
||
|
||
/* ── Autocomplete ── */
|
||
.sw{position:relative}
|
||
.sdd{position:absolute;top:100%;left:0;right:0;background:var(--surface);border:1.5px solid var(--teal);border-radius:8px;box-shadow:0 8px 30px rgba(0,0,0,.12);z-index:300;max-height:220px;overflow-y:auto;display:none;margin-top:3px}
|
||
.sdd.open{display:block}
|
||
.sdd-row{padding:8px 12px;cursor:pointer;border-bottom:1px solid var(--border);display:flex;gap:10px;align-items:center;transition:background .08s}
|
||
.sdd-row:last-child{border-bottom:none}
|
||
.sdd-row:hover,.sdd-row.hi{background:#ecfeff}
|
||
.sdd-code{font-family:var(--mono);font-size:11px;color:var(--teal);font-weight:600;min-width:90px;flex-shrink:0}
|
||
.sdd-name{font-size:11px;color:var(--text2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.sdd-msg{padding:12px;text-align:center;color:var(--text3);font-size:11px;font-style:italic}
|
||
.acd-fixed{position:fixed;min-width:240px;background:var(--surface);border:1.5px solid var(--teal);border-radius:8px;box-shadow:0 8px 30px rgba(0,0,0,.12);z-index:99999;max-height:240px;overflow-y:auto;display:none}
|
||
.acd-fixed.open{display:block}
|
||
.aci{padding:8px 12px;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:#ecfeff}
|
||
.ac-name{font-size:11px;color:var(--text2);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.ac-code{font-family:var(--mono);font-size:10px;color:var(--teal);flex-shrink:0}
|
||
.ac-msg{padding:10px 12px;color:var(--text3);font-size:11px;font-style:italic}
|
||
.spin{display:inline-block;width:12px;height:12px;border:2px solid rgba(4,126,126,.2);border-top-color:var(--teal);border-radius:50%;animation:spin .6s linear infinite;flex-shrink:0}
|
||
|
||
/* ── Lines table ── */
|
||
.lines-wrap{overflow-x:auto;border:1px solid var(--border);border-radius:8px}
|
||
table{width:100%;border-collapse:collapse;font-size:11px}
|
||
thead tr{background:var(--surface3)}
|
||
th{padding:7px 8px;text-align:left;font-size:9px;font-weight:700;color:var(--text3);text-transform:uppercase;letter-spacing:.06em;white-space:nowrap;border-bottom:1px solid var(--border)}
|
||
td{padding:0;border-bottom:1px solid var(--border);vertical-align:middle}
|
||
tbody tr:last-child td{border-bottom:none}
|
||
tbody tr:hover td{background:#f0fdf4}
|
||
td input,td select{background:transparent;border:none;padding:6px 7px;font-size:11px;color:var(--text);width:100%;font-family:var(--font);height:30px;outline:none}
|
||
td input:focus,td select:focus{background:#ecfeff;outline:none}
|
||
td input[readonly]{color:var(--text3)}
|
||
td select{appearance:none;cursor:pointer}
|
||
.nc{text-align:right}.nc input{text-align:right;font-family:var(--mono)}
|
||
.li-num{display:block;width:28px;text-align:center;font-family:var(--mono);font-size:10px;color:var(--text3);padding:6px 2px}
|
||
.xbtn{display:block;width:26px;text-align:center;color:var(--text3);cursor:pointer;padding:5px 2px;font-size:14px;line-height:1;transition:color .12s}.xbtn:hover{color:var(--red)}
|
||
.tfoot-row{background:var(--surface3);border-top:2px solid var(--border)}
|
||
.tfoot-row td{padding:7px 10px;font-size:11px;font-weight:700;color:var(--text2);font-family:var(--mono)}
|
||
.tfoot-row .tl{font-size:10px;color:var(--text3);font-weight:600;text-transform:uppercase;letter-spacing:.4px}
|
||
.tfoot-row .tv{color:var(--teal)}
|
||
|
||
/* ── Buttons ── */
|
||
.btn-bar{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
|
||
.btn{padding:8px 18px;border-radius:8px;border:none;font-family:var(--font);font-size:12px;font-weight:700;cursor:pointer;transition:all .15s;display:inline-flex;align-items:center;gap:6px}
|
||
.btn:disabled{opacity:.4;cursor:not-allowed}
|
||
.b-primary{background:linear-gradient(135deg,var(--teal),var(--teal2));color:#fff}.b-primary:hover:not(:disabled){box-shadow:0 4px 14px rgba(4,126,126,.4);transform:translateY(-1px)}
|
||
.b-secondary{background:var(--surface);color:var(--text2);border:1.5px solid var(--border)}.b-secondary:hover{border-color:var(--teal);color:var(--teal)}
|
||
.b-danger{background:#fef2f2;color:var(--red);border:1px solid #fecaca}.b-danger:hover{background:var(--red);color:#fff}
|
||
.b-warn{background:#fffbeb;color:var(--amber);border:1px solid #fde68a}.b-warn:hover{background:#fef3c7}
|
||
.b-green{background:#f0fdf4;color:var(--green);border:1px solid #a7f3d0}.b-green:hover{background:#d1fae5}
|
||
.btn-sm{padding:5px 11px;font-size:10px;border-radius:6px}
|
||
|
||
/* ── Status badges ── */
|
||
.badge{display:inline-block;padding:2px 8px;border-radius:5px;font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;font-family:var(--mono)}
|
||
.s-open{background:#d1fae5;color:#065f46;border:1px solid #a7f3d0}
|
||
.s-closed{background:#f3f4f6;color:var(--text3);border:1px solid var(--border2)}
|
||
.s-draft{background:#fffbeb;color:#92400e;border:1px solid #fde68a}
|
||
|
||
/* ── PQ list cards ── */
|
||
.pq-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:14px}
|
||
.pq-card{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:14px 14px 14px 18px;cursor:pointer;transition:all .18s;position:relative;overflow:hidden;aspect-ratio:1/1;display:flex;flex-direction:column}
|
||
.pq-card::before{content:'';position:absolute;left:0;top:0;bottom:0;width:4px;background:var(--teal)}
|
||
.pq-card:hover{transform:translateY(-2px);box-shadow:0 6px 20px rgba(0,0,0,.10);border-color:var(--teal3)}
|
||
.pq-card-top{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;flex-wrap:wrap}
|
||
.pq-docnum{font-family:var(--mono);font-size:12.5px;color:var(--teal);font-weight:700}
|
||
.pq-vendor{font-size:12.5px;color:var(--text);font-weight:600;margin:8px 0;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
|
||
.pq-meta{font-size:10.5px;color:var(--text3);display:flex;flex-direction:column;gap:3px;margin-top:auto;padding-top:8px;border-top:1px solid var(--border)}
|
||
.pq-meta b{color:var(--text2);font-weight:600}
|
||
.pq-empty{text-align:center;padding:40px;color:var(--text3);font-size:13px}
|
||
|
||
/* ── Search bar ── */
|
||
.search-bar{display:flex;gap:8px;margin-bottom:12px}
|
||
.search-bar .fi{flex:1}
|
||
.search-bar select.fi{flex:0 0 160px}
|
||
|
||
/* ── Toast ── */
|
||
.toast-c{position:fixed;bottom:24px;right:24px;z-index:9999;display:flex;flex-direction:column;gap:8px;pointer-events:none}
|
||
.toast{padding:10px 16px;border-radius:10px;font-size:12px;font-weight:600;box-shadow:0 4px 20px rgba(0,0,0,.15);animation:sIn .22s ease;max-width:320px;pointer-events:auto;display:flex;align-items:center;gap:8px}
|
||
@keyframes sIn{from{transform:translateY(12px);opacity:0}to{transform:translateY(0);opacity:1}}
|
||
.t-ok{background:var(--green);color:#fff}.t-err{background:var(--red);color:#fff}.t-info{background:var(--teal);color:#fff}
|
||
|
||
/* ── Spinner ── */
|
||
.sp{width:13px;height:13px;border:2px solid rgba(255,255,255,.3);border-top-color:#fff;border-radius:50%;animation:spin .6s linear infinite;display:inline-block}
|
||
@keyframes spin{to{transform:rotate(360deg)}}
|
||
.full-sp{display:none;position:fixed;inset:0;background:rgba(0,0,0,.4);z-index:9000;align-items:center;justify-content:center;flex-direction:column;gap:12px;backdrop-filter:blur(3px)}
|
||
.full-sp.show{display:flex}
|
||
.full-sp .sp{width:36px;height:36px;border-width:3px}
|
||
.full-sp p{font-size:12px;color:#fff;font-family:var(--mono)}
|
||
|
||
/* ── Modal ── */
|
||
.modal-bg{position:fixed;inset:0;background:rgba(0,0,0,.35);z-index:500;display:none;align-items:flex-start;justify-content:center;padding:20px;overflow-y:auto;backdrop-filter:blur(3px)}
|
||
.modal-bg.show{display:flex}
|
||
.modal{background:var(--surface);border:1px solid var(--border);border-radius:16px;width:100%;max-width:800px;margin:auto;box-shadow:0 24px 80px rgba(0,0,0,.15);animation:mIn .2s ease}
|
||
@keyframes mIn{from{transform:translateY(20px);opacity:0}to{transform:translateY(0);opacity:1}}
|
||
.modal-hd{padding:14px 20px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;gap:10px}
|
||
.modal-hd-left{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
|
||
.modal-close{width:28px;height:28px;border-radius:7px;border:1.5px solid var(--border);background:var(--surface3);color:var(--text3);cursor:pointer;font-size:16px;line-height:1;display:flex;align-items:center;justify-content:center;transition:all .15s}.modal-close:hover{background:#fee2e2;color:var(--red);border-color:#fca5a5}
|
||
.modal-bd{padding:16px 20px;max-height:72vh;overflow-y:auto}
|
||
.modal-tabs{display:flex;gap:3px;margin-bottom:12px;border-bottom:2px solid var(--border)}
|
||
.modal-tab{padding:7px 14px;font-size:11px;font-weight:700;color:var(--text3);cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-2px;transition:all .12s}
|
||
.modal-tab.on{color:var(--teal);border-bottom-color:var(--teal)}
|
||
.modal-tab-pane{display:none}.modal-tab-pane.on{display:block}
|
||
.dt-grid{display:grid;grid-template-columns:1fr 1fr;gap:0;border:1px solid var(--border);border-radius:8px;overflow:hidden}
|
||
.dt-row{display:flex;flex-direction:column;padding:8px 12px;border-bottom:1px solid var(--border);gap:2px}
|
||
.dt-row:nth-last-child(-n+2){border-bottom:none}
|
||
.dt-row.full{grid-column:1/-1}
|
||
.dt-l{color:var(--text3);font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.4px}
|
||
.dt-v{color:var(--text);font-size:13px;font-weight:600}
|
||
.modal-actions{display:flex;gap:8px;flex-wrap:wrap;padding:12px 20px;border-top:1px solid var(--border);background:var(--surface3);border-radius:0 0 16px 16px}
|
||
|
||
/* ── Lines table in modal ── */
|
||
.line-table{width:100%;border-collapse:collapse;font-size:11px;margin-top:4px;border:1px solid var(--border);border-radius:8px;overflow:hidden}
|
||
.line-table th{background:var(--surface3);padding:7px 8px;text-align:left;font-size:9px;font-weight:700;color:var(--text3);text-transform:uppercase;letter-spacing:.06em;border-bottom:1px solid var(--border)}
|
||
.line-table td{padding:7px 8px;border-bottom:1px solid #f3f4f6;color:var(--text2);vertical-align:top}
|
||
.line-table tbody tr:last-child td{border-bottom:none}
|
||
.line-table tr:hover td{background:#f9fafb}
|
||
.line-table .num{text-align:right;font-family:var(--mono)}
|
||
|
||
/* ── Pagination ── */
|
||
.pg-bar{display:flex;align-items:center;gap:8px;margin-top:12px;font-size:11px;color:var(--text3)}
|
||
.pg-bar button{padding:5px 12px;border-radius:7px;border:1.5px solid var(--border);background:var(--surface);color:var(--text2);font-size:11px;cursor:pointer;font-family:var(--font);font-weight:600;transition:all .15s}.pg-bar button:hover:not(:disabled){border-color:var(--teal);color:var(--teal)}.pg-bar button:disabled{opacity:.35}
|
||
|
||
/* ── co-sel (nav) ── */
|
||
.co-sel{background:var(--surface2);border:1.5px solid var(--border);color:var(--text);border-radius:7px;padding:5px 10px;font-size:12px;font-family:var(--font);cursor:pointer;outline:none}
|
||
.user-chip{font-size:12px;color:var(--text3);font-weight:600}
|
||
.btn-logout{padding:5px 12px;border-radius:7px;font-size:11px;font-weight:700;border:1.5px solid var(--border);color:var(--text3);background:none;cursor:pointer;font-family:var(--font)}.btn-logout:hover{border-color:var(--red);color:var(--red)}
|
||
</style>
|
||
<link rel="stylesheet" href="/theme.css?v=3"/>
|
||
</head>
|
||
<body>
|
||
|
||
<div class="full-sp" id="fullSp"><div class="sp"></div><p id="spMsg">Processing…</p></div>
|
||
<div class="toast-c" id="toastC"></div>
|
||
|
||
<!-- NAV -->
|
||
<nav>
|
||
<div style="display:flex;align-items:center;gap:10px;min-width:0">
|
||
<span class="nav-logo">PQ</span>
|
||
<div style="min-width:0">
|
||
<div class="nav-title">Purchase Quotation</div>
|
||
<div class="nav-sub">Purchase Quotations · SAP B1</div>
|
||
</div>
|
||
</div>
|
||
<div class="nav-right">
|
||
<span class="user-chip" id="userChip"></span>
|
||
<select class="co-sel" id="coSel">
|
||
</select>
|
||
<button class="btn-logout" onclick="logout()">Logout</button>
|
||
</div>
|
||
</nav>
|
||
|
||
<main>
|
||
<div class="tabs" style="justify-content:space-between;align-items:center">
|
||
<div style="display:flex;gap:4px">
|
||
<button class="tab on" id="tab-create" onclick="switchTab('create')">New Quotation</button>
|
||
<button class="tab" id="tab-view" onclick="switchTab('view');loadQuotations()">View / Search</button>
|
||
</div>
|
||
<select class="fi" id="coSel2" onchange="onCo()" style="width:auto;height:32px;padding:4px 10px;font-size:11px;font-family:var(--mono)">
|
||
</select>
|
||
</div>
|
||
|
||
<!-- ══ CREATE TAB ══ -->
|
||
<div id="pane-create">
|
||
|
||
<!-- Header Card -->
|
||
<div class="card">
|
||
<div class="card-hd"><span class="ico"></span>Purchase Quotation Header
|
||
<span class="right">Vendor required · Validity auto-set to +30 days</span>
|
||
</div>
|
||
<div class="card-bd">
|
||
<div class="fi-row4" style="margin-bottom:10px">
|
||
<div class="fg" style="grid-column:span 2">
|
||
<span class="fl">Vendor <span class="req">*</span></span>
|
||
<div class="sw" id="vendorWrap">
|
||
<input class="fi" type="text" id="vendorSearch" placeholder="Search vendor code or name…" autocomplete="off" oninput="vendorAc(this.value)">
|
||
<input type="hidden" id="cardCode">
|
||
<div class="sdd" id="vendorDd"></div>
|
||
</div>
|
||
</div>
|
||
<div class="fg">
|
||
<span class="fl">Document Type <span class="req">*</span></span>
|
||
<select class="fi" id="docType" onchange="onDocTypeChange()">
|
||
<option value="dDocument_Items">Item</option>
|
||
<option value="dDocument_Service">Service</option>
|
||
</select>
|
||
</div>
|
||
<div class="fg">
|
||
<span class="fl">Vendor Ref No.</span>
|
||
<input class="fi" type="text" id="vendorRef" placeholder="e.g. VR-2024-001">
|
||
</div>
|
||
</div>
|
||
<div class="fi-row4" style="margin-bottom:10px">
|
||
<div class="fg">
|
||
<span class="fl">Posting Date <span class="req">*</span></span>
|
||
<input class="fi" type="date" id="docDate">
|
||
</div>
|
||
<div class="fg">
|
||
<span class="fl">Validity Date</span>
|
||
<input class="fi" type="date" id="docDueDate">
|
||
</div>
|
||
<div class="fg">
|
||
<span class="fl">Required Date</span>
|
||
<input class="fi" type="date" id="reqDate">
|
||
</div>
|
||
<div class="fg">
|
||
<span class="fl">Doc No. / Status</span>
|
||
<input class="fi" type="text" id="docNumDisp" value="AUTO · Draft" readonly>
|
||
</div>
|
||
</div>
|
||
<div class="fg">
|
||
<span class="fl">Remarks</span>
|
||
<textarea class="fi" id="comments" rows="2" placeholder="Optional remarks for this quotation…"></textarea>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Lines Card -->
|
||
<div class="card">
|
||
<div class="card-hd"><span class="ico"></span>Document Lines
|
||
<span id="docTypeBadge" style="font-size:10px;padding:2px 8px;border-radius:5px;background:rgba(4,126,126,.15);color:var(--teal);border:1px solid rgba(4,126,126,.3);margin-left:4px">Item</span>
|
||
<span class="right">
|
||
<button class="btn b-secondary btn-sm" onclick="addLine()">+ Add Line</button>
|
||
<button class="btn b-secondary btn-sm" style="margin-left:4px" onclick="clearLines()">Clear</button>
|
||
</span>
|
||
</div>
|
||
<div class="card-bd" style="padding:0">
|
||
<div class="lines-wrap" id="linesWrap">
|
||
<table id="linesTable">
|
||
<thead>
|
||
<tr id="thead-row">
|
||
<th style="width:30px">#</th>
|
||
<th class="col-itemcode" style="min-width:110px">Item Code <span class="req">*</span></th>
|
||
<th style="min-width:150px">Description <span class="req" id="desc-req" style="display:none">*</span></th>
|
||
<th style="width:70px;text-align:right">Qty <span class="req">*</span></th>
|
||
<th style="width:100px;text-align:right">Unit Price <span class="req">*</span></th>
|
||
<th style="width:60px;text-align:right">Disc %</th>
|
||
<th style="min-width:120px">Tax Code</th>
|
||
<th style="width:55px;text-align:right">Tax %</th>
|
||
<th style="width:100px;text-align:right">Total</th>
|
||
<th class="col-warehouse" style="min-width:90px">Warehouse</th>
|
||
<th style="width:26px"></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="tbody"></tbody>
|
||
<tfoot>
|
||
<tr class="tfoot-row">
|
||
<td colspan="7" style="padding:8px 10px"><span class="tl">TOTALS</span></td>
|
||
<td colspan="2" class="nc" style="padding:8px 10px">
|
||
<div style="display:flex;flex-direction:column;gap:2px;align-items:flex-end">
|
||
<span style="font-size:9px;color:var(--text3)">Subtotal: <span class="tv" id="totSub">0.00</span></span>
|
||
<span style="font-size:9px;color:var(--text3)">Discount: <span class="tv" id="totDisc">0.00</span></span>
|
||
<span style="font-size:9px;color:var(--text3)">Tax: <span class="tv" id="totTax">0.00</span></span>
|
||
<span style="font-size:11px;font-weight:700;color:var(--text)">Total: <span style="color:var(--teal)" id="totDoc">0.00</span></span>
|
||
</div>
|
||
</td>
|
||
<td colspan="2"></td>
|
||
</tr>
|
||
</tfoot>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="btn-bar">
|
||
<button class="btn b-primary" id="postBtn" onclick="submitPQ()">
|
||
<span class="sp" id="spPost" style="display:none"></span> ✓ Post PQ
|
||
</button>
|
||
<button class="btn b-secondary" onclick="openCopyFrom()">Copy From (Indent)</button>
|
||
<button class="btn b-secondary" onclick="resetForm()">↺ Reset</button>
|
||
</div>
|
||
</div><!-- /pane-create -->
|
||
|
||
<!-- ══ VIEW TAB ══ -->
|
||
<div id="pane-view" style="display:none">
|
||
<div class="card">
|
||
<div class="card-hd"><span class="ico"></span>Search Purchase Quotations</div>
|
||
<div class="card-bd">
|
||
<div class="search-bar">
|
||
<input class="fi" type="text" id="srchQ" placeholder="Search by doc no., vendor, remarks…" oninput="debounceSearch()">
|
||
<select class="fi" id="srchStatus" onchange="loadQuotations()">
|
||
<option value="">All Status</option>
|
||
<option value="bost_Open">Open</option>
|
||
<option value="bost_Close">Closed</option>
|
||
</select>
|
||
<button class="btn b-primary btn-sm" onclick="loadQuotations()">Search</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div id="listWrap">
|
||
<div class="pq-empty">Click "View / Search" to load purchase quotations.</div>
|
||
</div>
|
||
<div class="pg-bar" id="pgBar" style="display:none">
|
||
<button id="pgPrev" onclick="pgMove(-1)" disabled>← Prev</button>
|
||
<span id="pgInfo">Page 1</span>
|
||
<button id="pgNext" onclick="pgMove(1)">Next →</button>
|
||
</div>
|
||
</div><!-- /pane-view -->
|
||
|
||
</main>
|
||
|
||
<!-- ══ COPY FROM MODAL (PR Chooser) ══ -->
|
||
<div class="modal-bg" id="copyFromModal">
|
||
<div class="modal" style="max-width:900px">
|
||
<div class="modal-hd">
|
||
<div class="modal-hd-left">
|
||
<span style="font-size:18px"></span>
|
||
<span style="font-weight:700;font-size:13px">List of Purchase Requests (Indents)</span>
|
||
</div>
|
||
<button class="modal-close" onclick="closeCopyFrom()">×</button>
|
||
</div>
|
||
<div class="modal-bd" style="padding:12px 16px">
|
||
<div style="display:flex;gap:8px;margin-bottom:10px">
|
||
<input class="fi" type="text" id="cfSearch" placeholder="Search by doc no. or remarks…" oninput="cfDebounce()" style="flex:1">
|
||
<button class="btn b-secondary btn-sm" onclick="loadPRList()">Search</button>
|
||
</div>
|
||
<div style="border:1px solid var(--border);border-radius:8px;overflow:hidden;max-height:420px;overflow-y:auto">
|
||
<table style="width:100%;border-collapse:collapse;font-size:11px">
|
||
<thead style="position:sticky;top:0;z-index:5">
|
||
<tr style="background:var(--surface3)">
|
||
<th style="padding:7px 8px;text-align:left;font-size:9px;font-weight:700;color:var(--text3);text-transform:uppercase;border-bottom:1px solid var(--border);width:32px">#</th>
|
||
<th style="padding:7px 8px;text-align:left;font-size:9px;font-weight:700;color:var(--text3);text-transform:uppercase;border-bottom:1px solid var(--border);width:70px">PRQ No.</th>
|
||
<th style="padding:7px 8px;text-align:left;font-size:9px;font-weight:700;color:var(--text3);text-transform:uppercase;border-bottom:1px solid var(--border);width:90px">Posting Date</th>
|
||
<th style="padding:7px 8px;text-align:left;font-size:9px;font-weight:700;color:var(--text3);text-transform:uppercase;border-bottom:1px solid var(--border);width:90px">Required Date</th>
|
||
<th style="padding:7px 8px;text-align:left;font-size:9px;font-weight:700;color:var(--text3);text-transform:uppercase;border-bottom:1px solid var(--border);width:120px">Vendor</th>
|
||
<th style="padding:7px 8px;text-align:left;font-size:9px;font-weight:700;color:var(--text3);text-transform:uppercase;border-bottom:1px solid var(--border)">Remarks</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="cfBody">
|
||
<tr><td colspan="6" style="padding:24px;text-align:center;color:var(--text3)">Loading…</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;gap:8px;justify-content:flex-end;padding:10px 16px;border-top:1px solid var(--border);background:var(--surface3);border-radius:0 0 12px 12px">
|
||
<button class="btn b-secondary" onclick="closeCopyFrom()">Cancel</button>
|
||
<button class="btn b-primary" id="btnChoose" onclick="choosePR()" disabled>✓ Choose</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Fixed AC portal (outside scroll context) -->
|
||
<div class="acd-fixed" id="acPortal"></div>
|
||
|
||
<!-- Document detail modal -->
|
||
<div class="modal-bg" id="docModal">
|
||
<div class="modal">
|
||
<div class="modal-hd">
|
||
<div class="modal-hd-left">
|
||
<span style="font-weight:700;font-size:13px">Purchase Quotation</span>
|
||
<span class="badge s-draft" id="mStatus">—</span>
|
||
<span style="font-family:var(--mono);font-size:12px;color:var(--teal)" id="mDocNum">#—</span>
|
||
</div>
|
||
<button class="modal-close" onclick="closeModal()">×</button>
|
||
</div>
|
||
<div class="modal-bd">
|
||
<div class="modal-tabs">
|
||
<div class="modal-tab on" onclick="mTab(this,'mt-info')">Details</div>
|
||
<div class="modal-tab" onclick="mTab(this,'mt-lines')">Lines</div>
|
||
</div>
|
||
<div class="modal-tab-pane on" id="mt-info">
|
||
<div class="dt-grid">
|
||
<div>
|
||
<div class="dt-row"><span class="dt-l">Doc Entry</span><span class="dt-v" id="mDocEntry">—</span></div>
|
||
<div class="dt-row"><span class="dt-l">Doc No.</span><span class="dt-v" id="mDocNumD">—</span></div>
|
||
<div class="dt-row"><span class="dt-l">Posting Date</span><span class="dt-v" id="mDocDate">—</span></div>
|
||
<div class="dt-row"><span class="dt-l">Validity Date</span><span class="dt-v" id="mDueDate">—</span></div>
|
||
</div>
|
||
<div>
|
||
<div class="dt-row"><span class="dt-l">Vendor Code</span><span class="dt-v" id="mVendor">—</span></div>
|
||
<div class="dt-row"><span class="dt-l">Vendor Name</span><span class="dt-v" id="mVendorName">—</span></div>
|
||
<div class="dt-row"><span class="dt-l">Vendor Ref.</span><span class="dt-v" id="mRef">—</span></div>
|
||
<div class="dt-row"><span class="dt-l">Total</span><span class="dt-v" id="mTotal" style="color:var(--teal)">—</span></div>
|
||
</div>
|
||
</div>
|
||
<div class="dt-row" style="border-top:1px solid var(--border);margin-top:8px">
|
||
<span class="dt-l">Remarks</span>
|
||
<span class="dt-v" id="mComments" style="max-width:60%;word-break:break-word;text-align:right">—</span>
|
||
</div>
|
||
</div>
|
||
<div class="modal-tab-pane" id="mt-lines">
|
||
<table class="line-table">
|
||
<thead><tr>
|
||
<th>#</th><th>Item Code</th><th>Description</th>
|
||
<th class="num">Qty</th><th class="num">Unit Price</th>
|
||
<th class="num">Disc %</th><th>Tax Code</th><th class="num">Total</th><th>Warehouse</th>
|
||
</tr></thead>
|
||
<tbody id="mLinesBody"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
<div class="modal-actions" id="mActions"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
'use strict';
|
||
const _tok = sessionStorage.getItem('portal_token');
|
||
if (!_tok) window.location.href = '/';
|
||
|
||
let _co = document.getElementById('coSel').value;
|
||
let lineSeq = 0;
|
||
let _taxCodes= [];
|
||
let _whs = [];
|
||
let _portalTarget = null;
|
||
let _curDoc = null;
|
||
let _srchTimer = null;
|
||
let _page = 0;
|
||
const PAGE_SIZE = 30;
|
||
|
||
const ah = () => ({ Authorization: `Bearer ${_tok}` });
|
||
const esc = s => String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
const dt = s => s ? s.slice(0,10) : '—';
|
||
const fmt = n => parseFloat(n||0).toFixed(2);
|
||
|
||
// ── Toast ──────────────────────────────────────────────
|
||
function toast(msg, type='info'){
|
||
const el = document.createElement('div');
|
||
el.className = `toast t-${type}`;
|
||
el.innerHTML = `<span>${{ok:'✓',err:'✕',info:'ℹ'}[type]||'ℹ'}</span><span>${esc(String(msg))}</span>`;
|
||
document.getElementById('toastC').appendChild(el);
|
||
setTimeout(()=>el.remove(), 5000);
|
||
}
|
||
|
||
function spin(show, msg='Processing…'){
|
||
document.getElementById('fullSp').classList.toggle('show', show);
|
||
document.getElementById('spMsg').textContent = msg;
|
||
}
|
||
|
||
// ── Auth / user chip ───────────────────────────────────
|
||
try {
|
||
const u = sessionStorage.getItem('portal_user');
|
||
if (u){ const user=JSON.parse(u); document.getElementById('userChip').textContent=user?.name||user?.username||''; }
|
||
} catch{}
|
||
|
||
function logout(){ sessionStorage.clear();document.cookie='portal_token=;path=/;expires=Thu, 01 Jan 1970 00:00:00 GMT';document.cookie='portal_user=;path=/;expires=Thu, 01 Jan 1970 00:00:00 GMT';; localStorage.removeItem('token');localStorage.removeItem('portal_user'); window.location.href='/'; }
|
||
|
||
async function populateCompanySelects(){
|
||
try{
|
||
const list=await window.fetchCompanyList();
|
||
if(!list.length)return;
|
||
['coSel','coSel2'].forEach(id=>{
|
||
const sel=document.getElementById(id);if(!sel)return;
|
||
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){}
|
||
}
|
||
function onCo(src){
|
||
const v = src === 'nav'
|
||
? document.getElementById('coSel').value
|
||
: document.getElementById('coSel2').value;
|
||
document.getElementById('coSel').value = v;
|
||
document.getElementById('coSel2').value = v;
|
||
_co = v;
|
||
_taxCodes=[]; _whs=[];
|
||
loadTaxCodes(); loadWarehouses();
|
||
// refresh view list if it is currently visible
|
||
if (document.getElementById('pane-view').style.display !== 'none'){ _page=0; loadQuotations(); }
|
||
// refresh Copy From PR list if modal is open
|
||
if (document.getElementById('copyFromModal').classList.contains('show')) loadPRList();
|
||
}
|
||
|
||
function isService(){ return document.getElementById('docType').value === 'dDocument_Service'; }
|
||
|
||
function onDocTypeChange(){
|
||
const svc = isService();
|
||
document.getElementById('docTypeBadge').textContent = svc ? 'Service' : 'Item';
|
||
document.getElementById('docTypeBadge').style.background = svc ? 'rgba(26,111,255,.15)' : 'rgba(4,126,126,.15)';
|
||
document.getElementById('docTypeBadge').style.color = svc ? 'var(--blue)' : 'var(--teal)';
|
||
document.getElementById('docTypeBadge').style.borderColor = svc ? 'rgba(26,111,255,.3)' : 'rgba(4,126,126,.3)';
|
||
document.getElementById('desc-req').style.display = svc ? '' : 'none';
|
||
// show/hide item-code and warehouse columns
|
||
document.querySelectorAll('.col-itemcode').forEach(el => el.style.display = svc ? 'none' : '');
|
||
document.querySelectorAll('.col-warehouse').forEach(el => el.style.display = svc ? 'none' : '');
|
||
// update all existing rows
|
||
document.querySelectorAll('#tbody tr').forEach(tr => {
|
||
const icCell = tr.querySelector('.cell-itemcode'); if (icCell) icCell.style.display = svc ? 'none' : '';
|
||
const whCell = tr.querySelector('.cell-warehouse'); if (whCell) whCell.style.display = svc ? 'none' : '';
|
||
const descInp = tr.querySelector('.desc-inp'); if (descInp) descInp.readOnly = !svc;
|
||
});
|
||
}
|
||
|
||
// ── Tab switching ──────────────────────────────────────
|
||
function switchTab(t){
|
||
['create','view'].forEach(id=>{
|
||
document.getElementById('pane-'+id).style.display = id===t?'':'none';
|
||
document.getElementById('tab-'+id).classList.toggle('on', id===t);
|
||
});
|
||
}
|
||
|
||
// ── Tax Codes ──────────────────────────────────────────
|
||
async function loadTaxCodes(){
|
||
try {
|
||
const res = await fetch(`/api/sap/lookup/tax-codes?company=${encodeURIComponent(_co)}`, { headers: ah() });
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
const d = await res.json();
|
||
if (d.data?.length) _taxCodes = d.data;
|
||
else throw new Error('empty');
|
||
} catch {
|
||
_taxCodes = [
|
||
{Code:'CG+SG@0',Rate:0},{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@0',Rate:0},{Code:'IGST@5',Rate:5},{Code:'IGST@12',Rate:12},
|
||
{Code:'IGST@18',Rate:18},{Code:'IGST@28',Rate:28},
|
||
{Code:'EXEMPT',Rate:0},{Code:'NIL',Rate:0},{Code:'RIGST@5',Rate:5},
|
||
];
|
||
}
|
||
document.querySelectorAll('.taxcode-sel').forEach(s=>fillTaxSel(s));
|
||
}
|
||
|
||
function fillTaxSel(sel, defaultCode){
|
||
const prev = defaultCode || sel.value;
|
||
sel.innerHTML = '<option value="">— None —</option>' + _taxCodes.map(t=>
|
||
`<option value="${esc(t.Code)}" data-rate="${t.Rate||0}">${esc(t.Code)}</option>`
|
||
).join('');
|
||
if (prev){ const o=sel.querySelector(`option[value="${prev.replace(/"/g,'\\"')}"]`); if(o) sel.value=prev; }
|
||
sel.onchange = ()=>{
|
||
const opt = sel.options[sel.selectedIndex];
|
||
const rate = parseFloat(opt?.dataset?.rate||0);
|
||
const tp = sel.closest('tr')?.querySelector('.taxpct-inp');
|
||
if (tp){ tp.value=rate; recalc(); }
|
||
};
|
||
}
|
||
|
||
// ── Warehouses ─────────────────────────────────────────
|
||
async function loadWarehouses(){
|
||
try {
|
||
const r = await fetch(`/api/sap/lookup/warehouses?company=${_co}`, { headers: ah() });
|
||
const d = await r.json();
|
||
_whs = d.data || [];
|
||
document.querySelectorAll('.whs-sel').forEach(fillWhsSel);
|
||
} catch {}
|
||
}
|
||
|
||
function fillWhsSel(sel, defaultCode){
|
||
const prev = defaultCode || sel.value;
|
||
sel.innerHTML = '<option value="">— WH —</option>' + _whs.map(w=>
|
||
`<option value="${esc(w.WarehouseCode||w.code)}">${esc(w.WarehouseCode||w.code)}${(w.WarehouseName||w.name)?' — '+(w.WarehouseName||w.name):''}</option>`
|
||
).join('');
|
||
if (prev) sel.value = prev;
|
||
}
|
||
|
||
// ── Vendor autocomplete (inline dropdown) ─────────────
|
||
let _vendTimer = null;
|
||
function vendorAc(val){
|
||
document.getElementById('cardCode').value = '';
|
||
clearTimeout(_vendTimer);
|
||
const dd = document.getElementById('vendorDd');
|
||
if (val.length < 2){ dd.classList.remove('open'); dd.innerHTML=''; return; }
|
||
_vendTimer = setTimeout(async()=>{
|
||
try {
|
||
const r = await fetch(`/api/sap/lookup/vendors?q=${encodeURIComponent(val)}&company=${_co}`, { headers: ah() });
|
||
const d = await r.json();
|
||
const items = d.data || [];
|
||
dd.innerHTML = items.length
|
||
? items.map(v=>`<div class="sdd-row" onclick="pickVendor('${esc(v.CardCode)}','${esc((v.CardName||'').replace(/'/g,"\\'"))}')"><span class="sdd-code">${esc(v.CardCode)}</span><span class="sdd-name">${esc(v.CardName)}</span></div>`).join('')
|
||
: '<div class="sdd-msg">No vendors found</div>';
|
||
dd.classList.add('open');
|
||
} catch {}
|
||
}, 300);
|
||
}
|
||
function pickVendor(code, name){
|
||
document.getElementById('vendorSearch').value = `${code} — ${name}`;
|
||
document.getElementById('cardCode').value = code;
|
||
document.getElementById('vendorDd').classList.remove('open');
|
||
toast(`Vendor: ${name}`, 'ok');
|
||
}
|
||
document.addEventListener('click', e=>{
|
||
if (!document.getElementById('vendorWrap').contains(e.target))
|
||
document.getElementById('vendorDd').classList.remove('open');
|
||
});
|
||
|
||
// ── Item autocomplete (fixed portal) ──────────────────
|
||
const portal = document.getElementById('acPortal');
|
||
const _itemTimers = {};
|
||
|
||
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';
|
||
}
|
||
|
||
document.addEventListener('mousedown', e=>{
|
||
if (!portal.contains(e.target) && !e.target.closest('.icode-inp')) closePortal();
|
||
});
|
||
window.addEventListener('scroll', ()=>{
|
||
if (portal.classList.contains('open') && _portalTarget) positionPortal(_portalTarget);
|
||
}, true);
|
||
|
||
function closePortal(){ portal.classList.remove('open'); portal.innerHTML=''; _portalTarget=null; }
|
||
|
||
function itemAcForInput(inputEl, lid){
|
||
const val = inputEl.value;
|
||
clearTimeout(_itemTimers[lid]);
|
||
if (val.length < 2){ closePortal(); return; }
|
||
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');
|
||
_portalTarget = inputEl;
|
||
_itemTimers[lid] = setTimeout(async()=>{
|
||
try {
|
||
const res = await fetch(`/api/sap/lookup/items?q=${encodeURIComponent(val)}&company=${_co}`, { headers: ah() });
|
||
const data = await res.json();
|
||
const list = data.data || [];
|
||
portal.innerHTML = list.length
|
||
? list.map(i=>`<div class="aci" onmousedown="e => e.preventDefault()" onclick="selItem(${lid},'${esc(i.ItemCode)}','${esc((i.ItemName||'').replace(/'/g,"\\'"))}')"><span class="ac-name">${esc(i.ItemName)}</span><span class="ac-code">${esc(i.ItemCode)}</span></div>`).join('')
|
||
: '<div class="ac-msg">No items found</div>';
|
||
positionPortal(inputEl);
|
||
portal.classList.add('open');
|
||
} catch(err){ portal.innerHTML=`<div class="ac-msg">Error: ${esc(err.message)}</div>`; }
|
||
}, 300);
|
||
}
|
||
|
||
function selItem(lid, code, name){
|
||
const icode = document.getElementById(`icode-${lid}`);
|
||
const desc = document.getElementById(`desc-${lid}`);
|
||
if (icode) icode.value = code;
|
||
if (desc) desc.value = name;
|
||
closePortal();
|
||
recalc();
|
||
}
|
||
|
||
// ── Add / Remove / Renumber Lines ─────────────────────
|
||
function addLine(d={}){
|
||
lineSeq++;
|
||
const lid = lineSeq;
|
||
const svc = isService();
|
||
const rowNum = document.querySelectorAll('#tbody tr').length+1;
|
||
const tr = document.createElement('tr');
|
||
tr.id = `line-${lid}`;
|
||
tr.innerHTML = `
|
||
<td><span class="li-num">${rowNum}</span></td>
|
||
<td class="cell-itemcode" style="min-width:110px${svc?';display:none':''}">
|
||
<input class="icode-inp" type="text" id="icode-${lid}" placeholder="Item code…" value="${esc(d.itemCode||'')}"
|
||
autocomplete="off"
|
||
oninput="itemAcForInput(this,${lid})"
|
||
onfocus="if(this.value.length>=2)itemAcForInput(this,${lid})">
|
||
</td>
|
||
<td style="min-width:150px">
|
||
<input class="desc-inp" type="text" id="desc-${lid}" placeholder="${svc?'Service description…':'Description'}"
|
||
value="${esc(d.desc||'')}" ${svc?'':'readonly'}>
|
||
</td>
|
||
<td class="nc" style="width:70px">
|
||
<input class="qty-inp" type="number" id="qty-${lid}" value="${d.qty!==undefined?d.qty:1}" min="0" step="0.001" oninput="recalc()">
|
||
</td>
|
||
<td class="nc" style="width:100px">
|
||
<input class="price-inp" type="number" id="price-${lid}" value="${d.price!==undefined?d.price:0}" min="0" step="0.01" oninput="recalc()">
|
||
</td>
|
||
<td class="nc" style="width:60px">
|
||
<input class="disc-inp" type="number" id="disc-${lid}" value="${d.disc!==undefined?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="width:55px">
|
||
<input class="taxpct-inp" type="number" id="tp-${lid}" value="${d.taxPct!==undefined?d.taxPct:0}" min="0" max="100" readonly>
|
||
</td>
|
||
<td class="nc" style="width:100px">
|
||
<input class="total-inp" type="number" id="tot-${lid}" value="0" readonly style="color:var(--teal);font-weight:600">
|
||
</td>
|
||
<td class="cell-warehouse" style="min-width:90px${svc?';display:none':''}">
|
||
<select class="whs-sel" id="whs-${lid}"></select>
|
||
</td>
|
||
<td><span class="xbtn" onclick="removeLine(${lid})">×</span></td>`;
|
||
|
||
document.getElementById('tbody').appendChild(tr);
|
||
fillTaxSel(tr.querySelector('.taxcode-sel'), d.taxCode||'');
|
||
if (!svc) fillWhsSel(tr.querySelector('.whs-sel'), d.warehouse||'');
|
||
renumber();
|
||
recalc();
|
||
}
|
||
|
||
function removeLine(lid){ document.getElementById(`line-${lid}`)?.remove(); renumber(); recalc(); }
|
||
function clearLines(){ document.getElementById('tbody').innerHTML=''; lineSeq=0; recalc(); }
|
||
function renumber(){
|
||
document.querySelectorAll('#tbody tr').forEach((tr,i)=>{
|
||
const s = tr.querySelector('.li-num'); if(s) s.textContent=i+1;
|
||
});
|
||
}
|
||
|
||
// ── Recalculate Totals ─────────────────────────────────
|
||
function recalc(){
|
||
let sub=0, disc=0, tax=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 discPct= parseFloat(tr.querySelector('.disc-inp')?.value ||0);
|
||
const taxPct = parseFloat(tr.querySelector('.taxpct-inp')?.value||0);
|
||
const base = qty * price;
|
||
const dAmt = base * discPct / 100;
|
||
const after = base - dAmt;
|
||
const tAmt = after * taxPct / 100;
|
||
sub += base; disc += dAmt; tax += tAmt;
|
||
const totInp = tr.querySelector('.total-inp');
|
||
if (totInp) totInp.value = (after + tAmt).toFixed(2);
|
||
});
|
||
document.getElementById('totSub').textContent = fmt(sub);
|
||
document.getElementById('totDisc').textContent = fmt(disc);
|
||
document.getElementById('totTax').textContent = fmt(tax);
|
||
document.getElementById('totDoc').textContent = fmt(sub - disc + tax);
|
||
}
|
||
|
||
// ── Build payload ──────────────────────────────────────
|
||
function buildPayload(){
|
||
const cardCode = document.getElementById('cardCode').value.trim();
|
||
const docDate = document.getElementById('docDate').value;
|
||
const docDueDate = document.getElementById('docDueDate').value;
|
||
const vendorRef = document.getElementById('vendorRef').value.trim();
|
||
const comments = document.getElementById('comments').value.trim();
|
||
const docType = document.getElementById('docType').value;
|
||
const svc = isService();
|
||
const lines = [];
|
||
|
||
document.querySelectorAll('#tbody tr').forEach(tr=>{
|
||
const itemCode = tr.querySelector('.icode-inp')?.value?.trim()||'';
|
||
const desc = tr.querySelector('.desc-inp')?.value?.trim()||'';
|
||
// for item type: itemCode required; for service type: desc required
|
||
if (!svc && !itemCode) return;
|
||
if (svc && !desc) return;
|
||
|
||
const line = {
|
||
ItemDescription: desc,
|
||
Quantity: parseFloat(tr.querySelector('.qty-inp')?.value||0),
|
||
UnitPrice: parseFloat(tr.querySelector('.price-inp')?.value||0),
|
||
};
|
||
if (!svc && itemCode) line.ItemCode = itemCode;
|
||
const disc = parseFloat(tr.querySelector('.disc-inp')?.value||0);
|
||
const taxCode = tr.querySelector('.taxcode-sel')?.value||'';
|
||
const whs = !svc ? (tr.querySelector('.whs-sel')?.value||'') : '';
|
||
if (disc > 0) line.DiscountPercent = disc;
|
||
if (taxCode) line.TaxCode = taxCode;
|
||
if (whs) line.WarehouseCode = whs;
|
||
lines.push(line);
|
||
});
|
||
|
||
const reqDate = document.getElementById('reqDate').value;
|
||
const payload = { company: _co, CardCode: cardCode, DocType: docType, DocumentLines: lines };
|
||
if (docDate) payload.DocDate = docDate;
|
||
if (docDueDate) payload.DocDueDate = docDueDate;
|
||
if (reqDate) payload.RequriedDate = reqDate;
|
||
if (vendorRef) payload.NumAtCard = vendorRef;
|
||
if (comments) payload.Comments = comments;
|
||
return payload;
|
||
}
|
||
|
||
// ── Submit ─────────────────────────────────────────────
|
||
async function submitPQ(){
|
||
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 required.', 'err');
|
||
const rows = document.querySelectorAll('#tbody tr');
|
||
if (!rows.length) return toast('Add at least one line.', 'err');
|
||
const svc = isService();
|
||
let hasLine = false;
|
||
rows.forEach(tr=>{
|
||
if (svc) { if (tr.querySelector('.desc-inp')?.value?.trim()) hasLine=true; }
|
||
else { if (tr.querySelector('.icode-inp')?.value?.trim()) hasLine=true; }
|
||
});
|
||
if (!hasLine) return toast(svc ? 'Each service line needs a description.' : 'Each line needs an Item Code.', 'err');
|
||
|
||
spin(true, 'Posting to SAP…');
|
||
document.getElementById('postBtn').disabled = true;
|
||
document.getElementById('spPost').style.display = 'inline-block';
|
||
|
||
try {
|
||
const payload = buildPayload();
|
||
const res = await fetch('/api/sap/purchase-quotation', {
|
||
method:'POST', headers:{...ah(),'Content-Type':'application/json'},
|
||
body: JSON.stringify(payload),
|
||
});
|
||
const data = await res.json();
|
||
const result = data?.data||data;
|
||
if (res.ok && data?.success){
|
||
const docNum = result?.DocNum||result?.DocEntry||'—';
|
||
document.getElementById('docNumDisp').value = `#${docNum} · Open`;
|
||
toast(`Purchase Quotation Posted — Doc# ${docNum}`, 'ok');
|
||
} else {
|
||
const sapMsg = data?.error?.message?.value||data?.message||JSON.stringify(data).slice(0,200);
|
||
toast(`Error: ${sapMsg}`, 'err');
|
||
}
|
||
} catch(err){ toast('Network error: '+err.message, 'err'); }
|
||
finally {
|
||
spin(false);
|
||
document.getElementById('postBtn').disabled = false;
|
||
document.getElementById('spPost').style.display = 'none';
|
||
}
|
||
}
|
||
|
||
// ── Reset ──────────────────────────────────────────────
|
||
function resetForm(){
|
||
if (!confirm('Reset all fields?')) return;
|
||
['vendorSearch','cardCode','vendorRef','comments','reqDate'].forEach(id=>{
|
||
const el=document.getElementById(id); if(el) el.value='';
|
||
});
|
||
const today = new Date().toISOString().slice(0,10);
|
||
document.getElementById('docDate').value = today;
|
||
const validity = new Date(); validity.setDate(validity.getDate()+30);
|
||
document.getElementById('docDueDate').value = validity.toISOString().slice(0,10);
|
||
document.getElementById('docType').value = 'dDocument_Items';
|
||
document.getElementById('docNumDisp').value = 'AUTO · Draft';
|
||
onDocTypeChange();
|
||
clearLines(); addLine();
|
||
toast('Form reset.', 'info');
|
||
}
|
||
|
||
// ── View / Search ──────────────────────────────────────
|
||
function debounceSearch(){ clearTimeout(_srchTimer); _srchTimer=setTimeout(()=>{_page=0;loadQuotations();},400); }
|
||
|
||
async function loadQuotations(){
|
||
const co = document.getElementById('coSel2').value; // always read fresh from DOM
|
||
_co = co;
|
||
const wrap = document.getElementById('listWrap');
|
||
wrap.innerHTML = '<div class="pq-empty">Loading…</div>';
|
||
const q = document.getElementById('srchQ').value.trim();
|
||
const status = document.getElementById('srchStatus').value;
|
||
const skip = _page * PAGE_SIZE;
|
||
let url = `/api/sap/purchase-quotations?company=${encodeURIComponent(co)}&top=${PAGE_SIZE}&skip=${skip}`;
|
||
if (q) url += `&q=${encodeURIComponent(q)}`;
|
||
if (status) url += `&status=${encodeURIComponent(status)}`;
|
||
try {
|
||
const r = await fetch(url, { headers: ah() });
|
||
const d = await r.json();
|
||
if (!r.ok || d.success === false){
|
||
const msg = d.message||`HTTP ${r.status}`;
|
||
wrap.innerHTML=`<div class="pq-empty" style="color:var(--red)">✗ ${esc(msg)}</div>`;
|
||
document.getElementById('pgBar').style.display='none';
|
||
return;
|
||
}
|
||
const rows = d.value||d.data||[];
|
||
if (!rows.length){ wrap.innerHTML='<div class="pq-empty">No purchase quotations found.</div>'; document.getElementById('pgBar').style.display='none'; return; }
|
||
wrap.innerHTML = `<div class="pq-list">${rows.map(pq=>`
|
||
<div class="pq-card" onclick="openDoc(${pq.DocEntry})">
|
||
<div class="pq-card-top">
|
||
<span class="pq-docnum">#${esc(String(pq.DocNum||pq.DocEntry))}</span>
|
||
${statusBadge(pq.DocumentStatus)}
|
||
</div>
|
||
${pq.CardName?`<div class="pq-vendor" title="${esc(pq.CardName)}">${esc(pq.CardName)}${pq.CardCode?' ('+esc(pq.CardCode)+')':''}</div>`:''}
|
||
<div class="pq-meta">
|
||
<span>Date <b>${dt(pq.DocDate)}</b></span>
|
||
<span>Valid Until <b>${dt(pq.DocDueDate)}</b></span>
|
||
${pq.DocTotal!==undefined?`<span>Total <b>INR ${parseFloat(pq.DocTotal).toFixed(2)}</b></span>`:''}
|
||
</div>
|
||
</div>`).join('')}</div>`;
|
||
const pgBar = document.getElementById('pgBar');
|
||
pgBar.style.display = 'flex';
|
||
document.getElementById('pgPrev').disabled = _page===0;
|
||
document.getElementById('pgNext').disabled = rows.length < PAGE_SIZE;
|
||
document.getElementById('pgInfo').textContent = `Page ${_page+1}`;
|
||
} catch(err){ wrap.innerHTML=`<div class="pq-empty" style="color:var(--red)">${esc(err.message)}</div>`; }
|
||
}
|
||
|
||
function pgMove(dir){ _page=Math.max(0,_page+dir); loadQuotations(); }
|
||
|
||
function statusBadge(s){
|
||
if (s==='bost_Open') return `<span class="badge s-open">Open</span>`;
|
||
if (s==='bost_Close') return `<span class="badge s-closed">Closed</span>`;
|
||
return `<span class="badge s-draft">${esc(s||'Draft')}</span>`;
|
||
}
|
||
|
||
// ── Document detail modal ──────────────────────────────
|
||
async function openDoc(entry){
|
||
spin(true,'Loading document…');
|
||
try {
|
||
const r = await fetch(`/api/sap/purchase-quotations/${encodeURIComponent(entry)}?company=${encodeURIComponent(_co)}`, { headers: ah() });
|
||
const d = await r.json();
|
||
if (!r.ok) throw new Error(d?.message||'Not found');
|
||
_curDoc = d.data||d.value||d;
|
||
renderModal(_curDoc);
|
||
document.getElementById('docModal').classList.add('show');
|
||
} catch(err){ toast(err.message,'err'); }
|
||
finally { spin(false); }
|
||
}
|
||
|
||
function renderModal(doc){
|
||
const s = doc.DocumentStatus;
|
||
document.getElementById('mStatus').className = 'badge '+(s==='bost_Open'?'s-open':s==='bost_Close'?'s-closed':'s-draft');
|
||
document.getElementById('mStatus').textContent = s==='bost_Open'?'Open':s==='bost_Close'?'Closed':'Draft';
|
||
document.getElementById('mDocNum').textContent = '#'+(doc.DocNum||doc.DocEntry);
|
||
document.getElementById('mDocEntry').textContent= doc.DocEntry;
|
||
document.getElementById('mDocNumD').textContent = doc.DocNum;
|
||
document.getElementById('mDocDate').textContent = dt(doc.DocDate);
|
||
document.getElementById('mDueDate').textContent = dt(doc.DocDueDate);
|
||
document.getElementById('mVendor').textContent = doc.CardCode||'—';
|
||
document.getElementById('mVendorName').textContent= doc.CardName||'—';
|
||
document.getElementById('mRef').textContent = doc.NumAtCard||'—';
|
||
document.getElementById('mTotal').textContent = doc.DocTotal!==undefined?'INR '+parseFloat(doc.DocTotal).toFixed(2):'—';
|
||
document.getElementById('mComments').textContent= doc.Comments||'—';
|
||
const lines = doc.DocumentLines||[];
|
||
document.getElementById('mLinesBody').innerHTML = lines.length
|
||
? lines.map((l,i)=>`<tr>
|
||
<td>${i+1}</td>
|
||
<td style="font-family:var(--mono);color:var(--teal)">${esc(l.ItemCode||'—')}</td>
|
||
<td>${esc(l.ItemDescription||'—')}</td>
|
||
<td class="num">${l.Quantity||0}</td>
|
||
<td class="num">${parseFloat(l.UnitPrice||0).toFixed(2)}</td>
|
||
<td class="num">${parseFloat(l.DiscountPercent||0).toFixed(1)}%</td>
|
||
<td>${esc(l.TaxCode||'—')}</td>
|
||
<td class="num" style="color:var(--teal)">${parseFloat(l.LineTotal||0).toFixed(2)}</td>
|
||
<td>${esc(l.WarehouseCode||'—')}</td>
|
||
</tr>`).join('')
|
||
: '<tr><td colspan="9" style="text-align:center;padding:16px;color:var(--text3)">No lines</td></tr>';
|
||
|
||
const isOpen = s==='bost_Open';
|
||
const isClosed = s==='bost_Close';
|
||
document.getElementById('mActions').innerHTML = `
|
||
${isOpen ? `<button class="btn b-green btn-sm" onclick="docAction('close')">✓ Close</button>`:''}
|
||
${isOpen ? `<button class="btn b-warn btn-sm" onclick="docAction('cancel')">✕ Cancel</button>`:''}
|
||
${isClosed? `<button class="btn b-secondary btn-sm" onclick="docAction('reopen')">↩ Reopen</button>`:''}`;
|
||
}
|
||
|
||
function mTab(el, paneId){
|
||
document.querySelectorAll('.modal-tab').forEach(t=>t.classList.remove('on'));
|
||
document.querySelectorAll('.modal-tab-pane').forEach(p=>p.classList.remove('on'));
|
||
el.classList.add('on');
|
||
document.getElementById(paneId).classList.add('on');
|
||
}
|
||
|
||
function closeModal(){ document.getElementById('docModal').classList.remove('show'); _curDoc=null; }
|
||
document.getElementById('docModal').addEventListener('click', e=>{
|
||
if (e.target===document.getElementById('docModal')) closeModal();
|
||
});
|
||
|
||
async function docAction(action){
|
||
if (!_curDoc) return;
|
||
const entry = _curDoc.DocEntry;
|
||
const labels = { close:'Close', cancel:'Cancel', reopen:'Reopen' };
|
||
if (action==='cancel' && !confirm(`Cancel PQ #${_curDoc.DocNum}? This cannot be undone.`)) return;
|
||
spin(true, `${labels[action]||action}ing…`);
|
||
try {
|
||
const r = await fetch(`/api/sap/documents/PurchaseQuotations/${entry}/${action}?company=${encodeURIComponent(_co)}`,
|
||
{ method:'POST', headers:{...ah(),'Content-Type':'application/json'}, body:'{}' });
|
||
const d = await r.json();
|
||
if (!r.ok||!d.success) throw new Error(d?.message||'Action failed');
|
||
toast(`PQ #${_curDoc.DocNum} ${labels[action]||action}d successfully`, 'ok');
|
||
closeModal(); loadQuotations();
|
||
} catch(err){ toast(err.message,'err'); }
|
||
finally { spin(false); }
|
||
}
|
||
|
||
// ── Copy From (PR Chooser) ─────────────────────────────
|
||
let _selectedPR = null;
|
||
let _cfTimer = null;
|
||
|
||
function openCopyFrom(){
|
||
_selectedPR = null;
|
||
document.getElementById('btnChoose').disabled = true;
|
||
document.getElementById('cfSearch').value = '';
|
||
// sync _co from the visible selector right now before opening
|
||
_co = document.getElementById('coSel2').value;
|
||
document.getElementById('copyFromModal').classList.add('show');
|
||
loadPRList();
|
||
}
|
||
function closeCopyFrom(){ document.getElementById('copyFromModal').classList.remove('show'); _selectedPR=null; }
|
||
function cfDebounce(){ clearTimeout(_cfTimer); _cfTimer=setTimeout(loadPRList, 400); }
|
||
|
||
async function loadPRList(){
|
||
// always read company fresh from the visible selector — never rely on _co alone
|
||
const co = document.getElementById('coSel2').value;
|
||
if (co !== _co) _co = co; // keep _co in sync if it drifted
|
||
const tbody = document.getElementById('cfBody');
|
||
tbody.innerHTML = `<tr><td colspan="6" style="padding:24px;text-align:center;color:var(--text3)"><span class="sp" style="border-color:rgba(4,126,126,.25);border-top-color:var(--teal);width:16px;height:16px;border-width:2px"></span> Loading from <b>${esc(co)}</b>…</td></tr>`;
|
||
const q = document.getElementById('cfSearch').value.trim();
|
||
let url = `/api/sap/purchase-requests?company=${encodeURIComponent(co)}&$top=100&$skip=0&status=bost_Open`;
|
||
if (q) url += `&q=${encodeURIComponent(q)}`;
|
||
try {
|
||
const r = await fetch(url, { headers: ah() });
|
||
const d = await r.json();
|
||
const rows = (d.data || []).filter(pr => Number.isFinite(pr.DocEntry));
|
||
if (!rows.length){
|
||
tbody.innerHTML = `<tr><td colspan="6" style="padding:24px;text-align:center;color:var(--text3)">No open purchase requests found.</td></tr>`;
|
||
return;
|
||
}
|
||
tbody.innerHTML = rows.map((pr, i) => `
|
||
<tr class="cf-row" data-entry="${pr.DocEntry}" onclick="selectPR(this,${pr.DocEntry})" style="cursor:pointer;transition:background .1s">
|
||
<td style="padding:6px 8px;border-bottom:1px solid rgba(28,46,72,.4);color:var(--text3)">${i+1}</td>
|
||
<td style="padding:6px 8px;border-bottom:1px solid rgba(28,46,72,.4);font-family:var(--mono);color:var(--teal);font-weight:600">#${esc(String(pr.DocNum||pr.DocEntry))}</td>
|
||
<td style="padding:6px 8px;border-bottom:1px solid rgba(28,46,72,.4);font-family:var(--mono);font-size:10px">${dt(pr.DocDate)}</td>
|
||
<td style="padding:6px 8px;border-bottom:1px solid rgba(28,46,72,.4);font-family:var(--mono);font-size:10px">${dt(pr.RequriedDate||pr.DocDueDate)}</td>
|
||
<td style="padding:6px 8px;border-bottom:1px solid rgba(28,46,72,.4);font-size:11px">${esc(pr.CardName||pr.CardCode||'—')}</td>
|
||
<td style="padding:6px 8px;border-bottom:1px solid rgba(28,46,72,.4);color:var(--text2);max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(pr.Comments||'')}</td>
|
||
</tr>`).join('');
|
||
} catch(err){
|
||
tbody.innerHTML = `<tr><td colspan="6" style="padding:24px;text-align:center;color:var(--red)">${esc(err.message)}</td></tr>`;
|
||
}
|
||
}
|
||
|
||
function selectPR(row, entry){
|
||
const id = Number.isFinite(entry) ? entry : parseInt(entry);
|
||
if (!Number.isFinite(id)) return; // guard: don't select rows with invalid DocEntry
|
||
document.querySelectorAll('.cf-row').forEach(r => { r.style.background=''; r.style.borderLeft=''; });
|
||
row.style.background = 'rgba(4,126,126,.14)';
|
||
row.style.borderLeft = '3px solid var(--teal)';
|
||
_selectedPR = id;
|
||
document.getElementById('btnChoose').disabled = false;
|
||
}
|
||
|
||
async function choosePR(){
|
||
if (!_selectedPR || !Number.isFinite(_selectedPR)) return;
|
||
const entryId = _selectedPR; // capture before closeCopyFrom() nulls it
|
||
const co = document.getElementById('coSel2').value;
|
||
spin(true, 'Loading Purchase Request…');
|
||
closeCopyFrom(); // resets _selectedPR → null (safe now)
|
||
try {
|
||
const r = await fetch(`/api/sap/purchase-requests/${entryId}?company=${encodeURIComponent(co)}`, { headers: ah() });
|
||
const d = await r.json();
|
||
if (!r.ok || !d.success) throw new Error(d.message||'Failed to load PR');
|
||
const pr = d.data;
|
||
|
||
// Clear existing lines first
|
||
clearLines();
|
||
|
||
// Populate header fields
|
||
if (pr.Comments) document.getElementById('comments').value = pr.Comments;
|
||
if (pr.RequriedDate||pr.DocDueDate)
|
||
document.getElementById('reqDate').value = (pr.RequriedDate||pr.DocDueDate||'').slice(0,10);
|
||
|
||
// Populate vendor if PR had one
|
||
if (pr.CardCode){
|
||
document.getElementById('cardCode').value = pr.CardCode;
|
||
document.getElementById('vendorSearch').value = pr.CardName
|
||
? `${pr.CardCode} — ${pr.CardName}`
|
||
: pr.CardCode;
|
||
}
|
||
|
||
// Populate lines from PR DocumentLines
|
||
const lines = pr.DocumentLines || [];
|
||
if (lines.length){
|
||
for (const l of lines){
|
||
addLine({
|
||
itemCode: l.ItemCode || '',
|
||
desc: l.ItemDescription || '',
|
||
qty: l.Quantity || 1,
|
||
price: l.UnitPrice || 0,
|
||
taxCode: l.TaxCode || '',
|
||
warehouse: l.WarehouseCode || '',
|
||
});
|
||
}
|
||
} else {
|
||
addLine();
|
||
}
|
||
|
||
toast(`Copied ${lines.length} line(s) from PR #${pr.DocNum||pr.DocEntry}`, 'ok');
|
||
} catch(err){
|
||
toast('Copy failed: ' + err.message, 'err');
|
||
if (!document.querySelectorAll('#tbody tr').length) addLine();
|
||
} finally {
|
||
spin(false);
|
||
}
|
||
}
|
||
|
||
// ── Init ───────────────────────────────────────────────
|
||
(async()=>{
|
||
await populateCompanySelects();
|
||
_co = document.getElementById('coSel').value;
|
||
const today = new Date().toISOString().slice(0,10);
|
||
document.getElementById('docDate').value = today;
|
||
const validity = new Date(); validity.setDate(validity.getDate()+30);
|
||
document.getElementById('docDueDate').value = validity.toISOString().slice(0,10);
|
||
// nav coSel → onCo('nav') so it reads from coSel (not coSel2)
|
||
document.getElementById('coSel').addEventListener('change', () => onCo('nav'));
|
||
// Copy From modal backdrop dismiss
|
||
document.getElementById('copyFromModal').addEventListener('click', e=>{
|
||
if (e.target === document.getElementById('copyFromModal')) closeCopyFrom();
|
||
});
|
||
// double-click row in Copy From to choose immediately
|
||
document.getElementById('cfBody').addEventListener('dblclick', e=>{
|
||
const row = e.target.closest('.cf-row');
|
||
if (!row) return;
|
||
selectPR(row, parseInt(row.dataset.entry));
|
||
if (Number.isFinite(_selectedPR)) choosePR();
|
||
});
|
||
await loadTaxCodes();
|
||
await loadWarehouses();
|
||
addLine();
|
||
})();
|
||
</script>
|
||
<script src="/sidebar.js?v=25"></script>
|
||
</body>
|
||
</html>
|
||
|