994 lines
52 KiB
HTML
994 lines
52 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 Request — 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{--ink:#080e18;--ink2:#0d1626;--ink3:#111f35;--purple:#9f5afd;--purple2:#7c3aed;--blue:#1a6fff;--amber:#f5a623;--green:#0dbb8a;--red:#e53e3e;--teal:#047e7e;--border:#1c2e48;--border2:#243650;--text:#c8d6e8;--text2:#7a9ab8;--text3:#3d5470;--surface:#0f1d30;--surface2:#142338;--surface3:#0b1520;--radius:10px;--font:'Space Grotesk',sans-serif;--mono:'JetBrains Mono',monospace;--display:'Bebas Neue',sans-serif;--shadow:0 8px 32px rgba(0,0,0,.5)}
|
||
*{box-sizing:border-box;margin:0;padding:0}
|
||
body{font-family:var(--font);background:var(--ink);color:var(--text);min-height:100vh;-webkit-font-smoothing:antialiased}
|
||
|
||
nav{position:sticky;top:0;z-index:200;background:rgba(8,14,24,.95);backdrop-filter:blur(20px);border-bottom:1px solid var(--border);padding:0 16px;height:50px;display:flex;align-items:center;justify-content:space-between;gap:8px}
|
||
.nav-logo{background:var(--teal);color:#fff;font-family:var(--display);font-size:14px;letter-spacing:2px;padding:3px 9px;border-radius:5px;flex-shrink:0}
|
||
.nav-title{font-size:13px;font-weight:700;color:var(--text);flex:1}
|
||
.nav-sub{font-size:10px;color:var(--text3);font-family:var(--mono)}
|
||
.nav-right{display:flex;align-items:center;gap:8px}
|
||
.co-sel{background:var(--surface2);border:1px solid var(--border2);color:var(--text);border-radius:6px;padding:4px 10px;font-size:11px;font-family:var(--mono);cursor:pointer;outline:none}
|
||
.co-sel option{background:var(--ink2)}
|
||
.btn-logout{padding:4px 10px;border-radius:5px;font-size:10px;font-weight:700;border:1px solid var(--border2);color:var(--text3);background:none;cursor:pointer;font-family:var(--font)}.btn-logout:hover{border-color:var(--red);color:var(--red)}
|
||
.user-chip{font-family:var(--mono);font-size:10px;color:var(--text3)}
|
||
|
||
main{padding:14px 16px 80px;max-width:1100px;margin:0 auto}
|
||
|
||
/* ── Tabs ── */
|
||
.tabs{display:flex;gap:4px;margin-bottom:14px;background:var(--surface3);border-radius:var(--radius);padding:4px}
|
||
.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)}
|
||
.tab.on{background:var(--surface);color:var(--teal);border:1px solid var(--border2);box-shadow:0 2px 8px rgba(0,0,0,.3)}
|
||
|
||
/* ── Cards ── */
|
||
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);margin-bottom:12px;overflow:hidden}
|
||
.card-hd{background:var(--surface3);padding:10px 14px;font-size:11px;font-weight:700;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:400;color:var(--text3)}
|
||
.card-bd{padding:14px}
|
||
|
||
/* ── 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:7px 10px;border:1.5px solid var(--border2);border-radius:7px;font-size:12px;font-family:var(--font);background:var(--surface2);color:var(--text);outline:none;height:34px;transition:border-color .15s}
|
||
.fi:focus{border-color:var(--teal);box-shadow:0 0 0 2px rgba(4,126,126,.15)}
|
||
.fi::placeholder{color:var(--text3)}
|
||
.fi[readonly]{opacity:.6;cursor:default}
|
||
select.fi{cursor:pointer;appearance:none;background:var(--surface2) 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='%233d5470'/%3E%3C/svg%3E") no-repeat right 10px center;padding-right:26px}
|
||
select.fi option{background:var(--surface2)}
|
||
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}}
|
||
|
||
/* ── Search dropdown ── */
|
||
.sw{position:relative}
|
||
.sdd{position:absolute;top:100%;left:0;right:0;background:var(--surface2);border:1.5px solid var(--teal);border-radius:7px;box-shadow:0 12px 40px rgba(0,0,0,.7);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{background:rgba(4,126,126,.12)}
|
||
.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}
|
||
|
||
/* ── Line items 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:rgba(4,126,126,.04)}
|
||
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:rgba(4,126,126,.08);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;transition:color .12s;line-height:1}.xbtn:hover{color:var(--red)}
|
||
.tfoot-row{background:var(--surface3);border-top:1px solid var(--border)}
|
||
.tfoot-row td{padding:6px 8px;font-size:10px;font-weight:700;color:var(--text3);font-family:var(--mono)}
|
||
|
||
/* ── 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:var(--teal);color:#fff}.b-primary:hover:not(:disabled){background:#035b5b;box-shadow:0 4px 14px rgba(4,126,126,.4)}
|
||
.b-secondary{background:var(--surface2);color:var(--text2);border:1px solid var(--border2)}.b-secondary:hover{border-color:var(--teal);color:var(--teal)}
|
||
.b-danger{background:rgba(229,62,62,.1);color:var(--red);border:1px solid rgba(229,62,62,.25)}.b-danger:hover{background:var(--red);color:#fff}
|
||
.b-warn{background:rgba(245,166,35,.1);color:var(--amber);border:1px solid rgba(245,166,35,.25)}.b-warn:hover{background:rgba(245,166,35,.2)}
|
||
.b-green{background:rgba(13,187,138,.1);color:var(--green);border:1px solid rgba(13,187,138,.25)}.b-green:hover{background:rgba(13,187,138,.2)}
|
||
.b-blue{background:rgba(26,111,255,.1);color:var(--blue);border:1px solid rgba(26,111,255,.25)}.b-blue:hover{background:rgba(26,111,255,.2)}
|
||
.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:rgba(13,187,138,.12);color:var(--green);border:1px solid rgba(13,187,138,.25)}
|
||
.s-closed{background:rgba(61,84,112,.2);color:var(--text3);border:1px solid var(--border2)}
|
||
.s-cancelled{background:rgba(229,62,62,.12);color:var(--red);border:1px solid rgba(229,62,62,.25)}
|
||
.s-draft{background:rgba(245,166,35,.12);color:var(--amber);border:1px solid rgba(245,166,35,.25)}
|
||
|
||
/* ── PR List cards — square-tile grid, same pattern as work-order.html ── */
|
||
.pr-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:14px}
|
||
.pr-card{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:14px;cursor:pointer;transition:all .15s;aspect-ratio:1/1;display:flex;flex-direction:column;overflow:hidden}
|
||
.pr-card:hover{border-color:var(--teal);box-shadow:0 6px 20px rgba(0,0,0,.35);transform:translateY(-2px)}
|
||
.pr-card-top{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;flex-wrap:wrap}
|
||
.pr-docnum{font-family:var(--mono);font-size:12.5px;color:var(--teal);font-weight:700}
|
||
.pr-icon{width:34px;height:34px;border-radius:9px;background:rgba(6,182,212,.12);color:var(--teal);display:flex;align-items:center;justify-content:center;margin:8px 0;font-size:16px}
|
||
.pr-comment{font-size:11px;color:var(--text2);margin-bottom:6px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
|
||
.pr-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)}
|
||
.pr-meta b{color:var(--text2)}
|
||
.pr-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;top:58px;right:12px;z-index:9999;display:flex;flex-direction:column;gap:6px;pointer-events:none}
|
||
.toast{padding:9px 14px;border-radius:8px;font-size:12px;font-weight:600;box-shadow:var(--shadow);animation:sIn .2s ease;max-width:320px;pointer-events:auto;display:flex;align-items:center;gap:8px}
|
||
@keyframes sIn{from{transform:translateX(110%);opacity:0}to{transform:translateX(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,.5);z-index:8000;align-items:center;justify-content:center;flex-direction:column;gap:12px}
|
||
.full-sp.show{display:flex}
|
||
.full-sp .sp{width:36px;height:36px;border-width:3px}
|
||
.full-sp p{font-size:12px;color:rgba(255,255,255,.6);font-family:var(--mono)}
|
||
|
||
/* ── Document detail modal ── */
|
||
.modal-bg{position:fixed;inset:0;background:rgba(0,0,0,.75);z-index:500;display:none;align-items:flex-start;justify-content:center;padding:14px;overflow-y:auto}
|
||
.modal-bg.show{display:flex}
|
||
.modal{background:var(--ink2);border:1px solid var(--border2);border-radius:12px;width:100%;max-width:780px;margin:auto;box-shadow:0 24px 80px rgba(0,0,0,.7);animation:mIn .18s ease}
|
||
@keyframes mIn{from{transform:translateY(16px);opacity:0}to{transform:translateY(0);opacity:1}}
|
||
.modal-hd{padding:12px 16px;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:6px;border:1px solid var(--border);background:var(--surface);color:var(--text2);cursor:pointer;font-size:16px;line-height:1;display:flex;align-items:center;justify-content:center}.modal-close:hover{background:var(--red);color:#fff;border-color:var(--red)}
|
||
.modal-bd{padding:14px;max-height:75vh;overflow-y:auto}
|
||
.modal-tabs{display:flex;gap:3px;margin-bottom:12px;border-bottom:1px solid var(--border);padding-bottom:0}
|
||
.modal-tab{padding:7px 14px;font-size:11px;font-weight:600;color:var(--text3);cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-1px;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}
|
||
.dt-row{display:flex;justify-content:space-between;align-items:center;padding:7px 10px;border-bottom:1px solid rgba(28,46,72,.5);font-size:12px}
|
||
.dt-row:last-child{border-bottom:none}
|
||
.dt-l{color:var(--text3);font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.3px}
|
||
.dt-v{color:var(--text);font-family:var(--mono);font-size:11px;text-align:right}
|
||
.modal-actions{display:flex;gap:8px;flex-wrap:wrap;padding:12px 16px;border-top:1px solid var(--border);background:var(--surface3);border-radius:0 0 12px 12px}
|
||
|
||
/* ── Line detail table inside modal ── */
|
||
.line-table{width:100%;border-collapse:collapse;font-size:11px;margin-top:8px}
|
||
.line-table th{background:var(--surface3);padding:6px 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 rgba(28,46,72,.4);color:var(--text2);vertical-align:top}
|
||
.line-table tbody tr:last-child td{border-bottom:none}
|
||
.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:4px 10px;border-radius:5px;border:1px solid var(--border2);background:var(--surface2);color:var(--text2);font-size:10px;cursor:pointer;font-family:var(--font)}.pg-bar button:hover:not(:disabled){border-color:var(--teal);color:var(--teal)}.pg-bar button:disabled{opacity:.35}
|
||
</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">PR</span>
|
||
<div style="min-width:0">
|
||
<div class="nav-title">Purchase Request</div>
|
||
<div class="nav-sub">Purchase Requests · SAP B1</div>
|
||
</div>
|
||
</div>
|
||
<div class="nav-right">
|
||
<span class="user-chip" id="userChip"></span>
|
||
<select class="co-sel" id="coSel" onchange="onCo()">
|
||
</select>
|
||
<button class="btn-logout" onclick="logout()">Logout</button>
|
||
</div>
|
||
</nav>
|
||
|
||
<main>
|
||
<div class="tabs" style="justify-content:space-between">
|
||
<div style="display:flex;gap:4px">
|
||
<button class="tab on" id="tab-create" onclick="switchTab('create')">New Request</button>
|
||
<button class="tab" id="tab-view" onclick="switchTab('view');loadList()">View / Search</button>
|
||
</div>
|
||
<select class="fi" id="coSel" 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 -->
|
||
<div class="card">
|
||
<div class="card-hd"><span class="ico"></span>Purchase Request Header
|
||
<span class="right">All dates required · Vendor optional</span>
|
||
</div>
|
||
<div class="card-bd">
|
||
<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">Required By <span class="req">*</span></span>
|
||
<input class="fi" type="date" id="docDueDate">
|
||
</div>
|
||
<div class="fg">
|
||
<span class="fl">Vendor Ref. No.</span>
|
||
<input class="fi" type="text" id="numAtCard" placeholder="e.g. PR-2025-001">
|
||
</div>
|
||
<div class="fg">
|
||
<span class="fl">Status</span>
|
||
<input class="fi" type="text" value="Draft" readonly>
|
||
</div>
|
||
</div>
|
||
<div class="fi-row4" style="margin-bottom:10px">
|
||
<div class="fg sw" id="vendorWrap">
|
||
<span class="fl">Vendor (Optional)</span>
|
||
<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 class="fg">
|
||
<span class="fl">Department <span class="req">*</span></span>
|
||
<select class="fi" id="uDepart">
|
||
<option value="">— Select Department —</option>
|
||
</select>
|
||
</div>
|
||
<div class="fg">
|
||
<span class="fl">STS Required</span>
|
||
<select class="fi" id="uStsReqd">
|
||
<option value="No">No</option>
|
||
<option value="Yes">Yes</option>
|
||
</select>
|
||
</div>
|
||
<div class="fg">
|
||
<span class="fl">Remarks / Purpose <span class="req">*</span></span>
|
||
<textarea class="fi" id="comments" rows="2" placeholder="Enter purpose or remarks for this purchase request…"></textarea>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Lines -->
|
||
<div class="card">
|
||
<div class="card-hd"><span class="ico"></span>Document Lines
|
||
<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">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th style="width:30px">#</th>
|
||
<th style="min-width:120px">Item Code <span class="req">*</span></th>
|
||
<th style="min-width:160px">Description</th>
|
||
<th style="width:90px">Required Date</th>
|
||
<th style="width:70px;text-align:right">Qty <span class="req">*</span></th>
|
||
<th style="width:50px">Unit</th>
|
||
<th style="width:100px;text-align:right">Unit Price</th>
|
||
<th style="min-width:110px">Warehouse</th>
|
||
<th style="width:28px"></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="linesBody"></tbody>
|
||
<tfoot>
|
||
<tr class="tfoot-row">
|
||
<td colspan="4" style="padding:7px 8px;font-size:10px;color:var(--text3)">TOTALS</td>
|
||
<td class="nc" style="padding:7px 8px"><span id="totQty">0</span></td>
|
||
<td></td>
|
||
<td class="nc" style="padding:7px 8px"><span id="totAmt">0.00</span></td>
|
||
<td colspan="2"></td>
|
||
</tr>
|
||
</tfoot>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="btn-bar">
|
||
<button class="btn b-primary" id="btnPost" onclick="submitPR()">
|
||
<span class="sp" id="spPost" style="display:none"></span> ✓ Submit for Approval
|
||
</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 Requests</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="loadList()">
|
||
<option value="">All Status</option>
|
||
<option value="bost_Open">Open</option>
|
||
<option value="bost_Close">Closed</option>
|
||
<option value="drafts">Pending SAP Approval</option>
|
||
</select>
|
||
<button class="btn b-primary btn-sm" onclick="loadList()">Search</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="listWrap">
|
||
<div class="pr-empty">Click "View / Search" to load purchase requests.</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>
|
||
|
||
<!-- ══ APPROVAL REMARKS DIALOG ══ -->
|
||
<div class="modal-bg" id="approvalDialog" style="z-index:600">
|
||
<div class="modal" style="max-width:480px">
|
||
<div class="modal-hd">
|
||
<div class="modal-hd-left">
|
||
<span style="font-size:16px"></span>
|
||
<span style="font-weight:700;font-size:13px">Request for Document Generation</span>
|
||
</div>
|
||
</div>
|
||
<div class="modal-bd" style="padding:16px">
|
||
<p style="font-size:12px;color:var(--text2);margin-bottom:14px;line-height:1.6">
|
||
Generating this document requires the authorisation of other users.<br>
|
||
Enter remarks relevant for the authoriser and choose <strong>OK</strong>.
|
||
</p>
|
||
<table style="width:100%;border-collapse:collapse;font-size:12px;margin-bottom:14px">
|
||
<thead>
|
||
<tr style="background:var(--surface3)">
|
||
<th style="padding:7px 10px;text-align:left;color:var(--text3);font-weight:700;width:30px">#</th>
|
||
<th style="padding:7px 10px;text-align:left;color:var(--text3);font-weight:700">Approval Template</th>
|
||
<th style="padding:7px 10px;text-align:left;color:var(--text3);font-weight:700">Remarks</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr style="border-bottom:1px solid var(--border)">
|
||
<td style="padding:8px 10px;color:var(--text2)">1</td>
|
||
<td style="padding:8px 10px;color:var(--text)">Purchase Indent</td>
|
||
<td style="padding:6px 10px">
|
||
<input class="fi" type="text" id="approvalRemarks" placeholder="Enter remarks for approver…" style="height:30px;font-size:12px">
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
<div style="display:flex;gap:8px;justify-content:flex-end">
|
||
<button class="btn b-secondary" onclick="closeApprovalDialog()">Cancel</button>
|
||
<button class="btn b-primary" id="btnApprovalOk" onclick="confirmApproval()">OK</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</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 Request</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>
|
||
<!-- Details pane -->
|
||
<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">Required By</span><span class="dt-v" id="mDueDate">—</span></div>
|
||
</div>
|
||
<div>
|
||
<div class="dt-row"><span class="dt-l">Vendor</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">Status</span><span class="dt-v" id="mStatusD">—</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>
|
||
<!-- Lines pane -->
|
||
<div class="modal-tab-pane" id="mt-lines">
|
||
<table class="line-table">
|
||
<thead><tr>
|
||
<th>#</th><th>Item Code</th><th>Description</th>
|
||
<th>Required Date</th><th class="num">Qty</th><th>Unit</th><th class="num">Unit Price</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 _seq = 0;
|
||
let _whs = [];
|
||
let _curDoc = null;
|
||
let _srchTimer = null;
|
||
let _page = 0;
|
||
// Matches SAP Service Layer's own MaxPageSize (confirmed live: it silently
|
||
// caps a page at 20 rows regardless of a higher $top) — with PAGE_SIZE=30
|
||
// this was requesting 30, always getting 20 back, and "rows.length <
|
||
// PAGE_SIZE" (20 < 30) then wrongly read as "no more results", disabling
|
||
// Next even when more pages genuinely existed.
|
||
const PAGE_SIZE = 20;
|
||
|
||
const ah = () => ({ Authorization: `Bearer ${_tok}` });
|
||
const esc = s => String(s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
||
const dt = s => s ? s.slice(0,10) : '—';
|
||
|
||
// ── 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 — read from sessionStorage (same as sidebar, no server round-trip) ──
|
||
let _user = null;
|
||
try {
|
||
const u = sessionStorage.getItem('portal_user');
|
||
if (u) {
|
||
_user = JSON.parse(u);
|
||
document.getElementById('userChip').textContent = _user?.name || _user?.username || '';
|
||
}
|
||
} catch(_e) {}
|
||
// Close/Cancel/Create Cancellation/Delete are destructive-ish SAP-document
|
||
// actions — restricted to admin/system_admin only; everyone else can view
|
||
// and create Purchase Requests but not act on an existing one this way.
|
||
const CAN_MANAGE_PR_DOC = _user && (_user.role === 'admin' || _user.role === 'system_admin');
|
||
|
||
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='/'; }
|
||
function onCo(){ _co = document.getElementById('coSel').value; loadWarehouses(); loadDepartments(); }
|
||
async function populateCompanySelects(){
|
||
try{
|
||
const list=await window.fetchCompanyList();
|
||
if(!list.length)return;
|
||
document.querySelectorAll('#coSel').forEach(sel=>{
|
||
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){}
|
||
}
|
||
|
||
// ── 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);
|
||
});
|
||
}
|
||
|
||
// ── 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(fillWhSel);
|
||
} catch {}
|
||
}
|
||
|
||
function fillWhSel(sel){
|
||
const cur = sel.value;
|
||
sel.innerHTML = '<option value="">— WH —</option>' +
|
||
_whs.map(w => `<option value="${esc(w.code)}"${w.code===cur?' selected':''}>${esc(w.code)} — ${esc(w.name)}</option>`).join('');
|
||
if (cur) sel.value = cur;
|
||
}
|
||
|
||
// ── Departments — live from SAP (OUDP), restricted per-user via Admin →
|
||
// Users → "PR Departments" (see routes/sap.js's /lookup/departments and
|
||
// /auth/profile's allowedDepartments; empty = no restriction, every
|
||
// department shown). Replaces what used to be a hardcoded, stale list.
|
||
async function loadDepartments(){
|
||
let allowed = [];
|
||
try {
|
||
const p = await fetch('/api/auth/profile', { headers: ah() });
|
||
const pd = await p.json();
|
||
if (Array.isArray(pd?.user?.allowedDepartments)) allowed = pd.user.allowedDepartments.map(String);
|
||
} catch {}
|
||
try {
|
||
const r = await fetch(`/api/sap/lookup/departments?company=${_co}`, { headers: ah() });
|
||
const d = await r.json();
|
||
let depts = d.data || [];
|
||
if (allowed.length) depts = depts.filter(dp => allowed.includes(dp.name));
|
||
const sel = document.getElementById('uDepart');
|
||
if (!sel) return;
|
||
const cur = sel.value;
|
||
sel.innerHTML = '<option value="">— Select Department —</option>' +
|
||
depts.map(dp => `<option value="${esc(dp.name)}"${dp.name===cur?' selected':''}>${esc(dp.name)}</option>`).join('');
|
||
if (cur) sel.value = cur;
|
||
} catch {}
|
||
}
|
||
|
||
// ── Vendor autocomplete ────────────────────────────────────────
|
||
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 || [];
|
||
// Built as real DOM elements with a bound click handler, not an
|
||
// inline onclick="..." string — a vendor name containing a quote
|
||
// character (e.g. an apostrophe) would otherwise terminate that
|
||
// string/attribute early and silently break the click entirely, same
|
||
// bug class as pickItem() below.
|
||
dd.innerHTML = '';
|
||
if (items.length) {
|
||
items.forEach(v => {
|
||
const row = document.createElement('div');
|
||
row.className = 'sdd-row';
|
||
row.innerHTML = `<span class="sdd-code">${esc(v.CardCode)}</span><span class="sdd-name">${esc(v.CardName)}</span>`;
|
||
row.onclick = () => pickVendor(v.CardCode, v.CardName);
|
||
dd.appendChild(row);
|
||
});
|
||
} else {
|
||
dd.innerHTML = '<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');
|
||
}
|
||
document.addEventListener('click', e => {
|
||
if (!document.getElementById('vendorWrap').contains(e.target))
|
||
document.getElementById('vendorDd').classList.remove('open');
|
||
});
|
||
|
||
// ── Item autocomplete per line ─────────────────────────────────
|
||
const _itemTimers = {};
|
||
function itemAc(inp, lineId){
|
||
const val = inp.value;
|
||
clearTimeout(_itemTimers[lineId]);
|
||
const row = document.getElementById('lr-'+lineId);
|
||
const dd = row?.querySelector('.item-dd');
|
||
if (!dd) return;
|
||
if (val.length < 2){ dd.classList.remove('open'); dd.innerHTML=''; return; }
|
||
_itemTimers[lineId] = setTimeout(async () => {
|
||
try {
|
||
const r = await fetch(`/api/sap/lookup/items?q=${encodeURIComponent(val)}&company=${_co}&activeOnly=1`, { headers: ah() });
|
||
const d = await r.json();
|
||
const items = d.data || [];
|
||
// Built as real DOM elements with a bound click handler, not an
|
||
// inline onclick="..." string — an item description containing a
|
||
// quote character (e.g. an inch mark, "NYLON RUBBER BAND 1"") would
|
||
// otherwise terminate that HTML attribute early, silently corrupting
|
||
// the click handler so picking the item did nothing at all.
|
||
dd.innerHTML = '';
|
||
if (items.length) {
|
||
items.forEach(i => {
|
||
const row = document.createElement('div');
|
||
row.className = 'sdd-row';
|
||
row.innerHTML = `<span class="sdd-code">${esc(i.ItemCode)}</span><span class="sdd-name">${esc(i.ItemName)}</span>`;
|
||
row.onclick = () => pickItem(lineId, i.ItemCode, i.ItemName || '', i.UoM || '', i.Warehouse || '');
|
||
dd.appendChild(row);
|
||
});
|
||
} else {
|
||
dd.innerHTML = '<div class="sdd-msg">No items found</div>';
|
||
}
|
||
dd.classList.add('open');
|
||
const rect = inp.getBoundingClientRect();
|
||
dd.style.top = (rect.bottom + window.scrollY + 2) + 'px';
|
||
dd.style.left = rect.left + 'px';
|
||
dd.style.width = Math.max(rect.width, 260) + 'px';
|
||
} catch {}
|
||
}, 300);
|
||
}
|
||
function pickItem(lineId, code, name, uom, warehouse){
|
||
const row = document.getElementById('lr-'+lineId);
|
||
if (!row) return;
|
||
row.querySelector('.ic-inp').value = code;
|
||
row.querySelector('.desc-inp').value = name;
|
||
row.querySelector('.unit-inp').value = uom;
|
||
if (warehouse) {
|
||
const sel = row.querySelector('.whs-sel');
|
||
if (sel) sel.value = warehouse;
|
||
}
|
||
row.querySelector('.item-dd')?.classList.remove('open');
|
||
recalc();
|
||
}
|
||
|
||
// ── Line items ─────────────────────────────────────────────────
|
||
function addLine(){
|
||
const id = ++_seq;
|
||
const today = new Date().toISOString().slice(0,10);
|
||
const dueDate = document.getElementById('docDueDate').value || today;
|
||
const tr = document.createElement('tr');
|
||
tr.id = 'lr-'+id;
|
||
tr.innerHTML = `
|
||
<td><span class="li-num">${id}</span></td>
|
||
<td style="position:relative;min-width:120px">
|
||
<input class="ic-inp" type="text" placeholder="Item code…" autocomplete="off" oninput="itemAc(this,${id})" style="min-width:110px">
|
||
<div class="sdd item-dd" style="position:fixed;z-index:9999"></div>
|
||
</td>
|
||
<td><input class="desc-inp" type="text" placeholder="Description" readonly style="min-width:140px"></td>
|
||
<td><input class="rd-inp" type="date" value="${dueDate}" oninput="this.dataset.manual='1'" style="min-width:110px;font-size:10px;font-family:var(--mono)"></td>
|
||
<td class="nc"><input class="qty-inp" type="number" value="1" min="0" step="any" oninput="recalc()" style="width:65px"></td>
|
||
<td><input class="unit-inp" type="text" readonly style="width:45px;font-size:10px;color:var(--text3)"></td>
|
||
<td class="nc"><input class="price-inp" type="number" value="0" min="0" step="any" oninput="recalc()" style="width:90px"></td>
|
||
<td><select class="whs-sel fi" style="border:none;background:transparent;height:30px;padding:4px 6px;font-size:11px"></select></td>
|
||
<td><span class="xbtn" onclick="removeLine(${id})">×</span></td>`;
|
||
document.getElementById('linesBody').appendChild(tr);
|
||
// close dropdowns when clicking outside this row
|
||
document.addEventListener('click', function closeItemDd(e){
|
||
if (!tr.contains(e.target)){ tr.querySelector('.item-dd')?.classList.remove('open'); }
|
||
});
|
||
fillWhSel(tr.querySelector('.whs-sel'));
|
||
recalc();
|
||
}
|
||
|
||
// Header "Required By" flows down to every line date the user hasn't set by hand
|
||
function syncLineDates(){
|
||
const due = document.getElementById('docDueDate').value;
|
||
if (!due) return;
|
||
document.querySelectorAll('#linesBody .rd-inp').forEach(inp => {
|
||
if (!inp.dataset.manual) inp.value = due;
|
||
});
|
||
}
|
||
|
||
function removeLine(id){ document.getElementById('lr-'+id)?.remove(); recalc(); }
|
||
function clearLines(){ document.getElementById('linesBody').innerHTML=''; _seq=0; recalc(); }
|
||
|
||
function recalc(){
|
||
let qty=0, amt=0;
|
||
document.querySelectorAll('#linesBody tr').forEach(row => {
|
||
qty += parseFloat(row.querySelector('.qty-inp')?.value||0);
|
||
amt += parseFloat(row.querySelector('.qty-inp')?.value||0) * parseFloat(row.querySelector('.price-inp')?.value||0);
|
||
});
|
||
document.getElementById('totQty').textContent = qty;
|
||
document.getElementById('totAmt').textContent = amt.toFixed(2);
|
||
}
|
||
|
||
// ── Reset ──────────────────────────────────────────────────────
|
||
function resetForm(){
|
||
const today = new Date().toISOString().slice(0,10);
|
||
document.getElementById('docDate').value = today;
|
||
document.getElementById('docDueDate').value = today;
|
||
document.getElementById('numAtCard').value = '';
|
||
document.getElementById('vendorSearch').value = '';
|
||
document.getElementById('cardCode').value = '';
|
||
document.getElementById('comments').value = '';
|
||
document.getElementById('uDepart').value = '';
|
||
document.getElementById('uStsReqd').value = 'No';
|
||
clearLines();
|
||
}
|
||
|
||
// ── Submit PR — validate then show approval dialog ─────────────
|
||
let _pendingPayload = null;
|
||
|
||
function submitPR(){
|
||
const lines = [];
|
||
let valid = true;
|
||
document.querySelectorAll('#linesBody tr').forEach(row => {
|
||
const ic = row.querySelector('.ic-inp')?.value.trim();
|
||
const qty = parseFloat(row.querySelector('.qty-inp')?.value || 0);
|
||
if (!ic || qty <= 0){ valid=false; return; }
|
||
const line = {
|
||
ItemCode: ic,
|
||
ItemDescription: row.querySelector('.desc-inp')?.value || '',
|
||
Quantity: qty,
|
||
UnitPrice: parseFloat(row.querySelector('.price-inp')?.value||0),
|
||
};
|
||
const rd = row.querySelector('.rd-inp')?.value;
|
||
const whs = row.querySelector('.whs-sel')?.value;
|
||
if (rd) line.RequiredDate = rd;
|
||
if (whs) line.WarehouseCode = whs;
|
||
lines.push(line);
|
||
});
|
||
if (!valid){ toast('Each line needs an item code and qty > 0','err'); return; }
|
||
if (!lines.length){ toast('Add at least one line item','err'); return; }
|
||
|
||
const docDate = document.getElementById('docDate').value;
|
||
const docDueDate = document.getElementById('docDueDate').value;
|
||
const cardCode = document.getElementById('cardCode').value.trim();
|
||
const numAtCard = document.getElementById('numAtCard').value.trim();
|
||
const comments = document.getElementById('comments').value.trim();
|
||
const depart = document.getElementById('uDepart').value;
|
||
const stsReqd = document.getElementById('uStsReqd').value;
|
||
|
||
if (!comments){ toast('Remarks / Purpose is required','err'); return; }
|
||
if (!depart){ toast('Select a Department','err'); return; }
|
||
|
||
_pendingPayload = { company: _co, DocumentLines: lines };
|
||
if (docDate) _pendingPayload.DocDate = docDate;
|
||
if (docDueDate) { _pendingPayload.DocDueDate = docDueDate; _pendingPayload.RequriedDate = docDueDate; }
|
||
if (cardCode) _pendingPayload.CardCode = cardCode;
|
||
if (numAtCard) _pendingPayload.NumAtCard = numAtCard;
|
||
if (comments) _pendingPayload.Comments = comments;
|
||
if (docDueDate) _pendingPayload.U_ReqDqte = docDueDate;
|
||
if (depart) { _pendingPayload.U_Depart = depart; _pendingPayload.U_Department = depart; }
|
||
_pendingPayload.U_STS_REQD = stsReqd;
|
||
|
||
// Show approval remarks dialog (like SAP desktop "Request for Document Generation")
|
||
document.getElementById('approvalRemarks').value = '';
|
||
document.getElementById('approvalDialog').classList.add('show');
|
||
setTimeout(() => document.getElementById('approvalRemarks').focus(), 100);
|
||
}
|
||
|
||
function closeApprovalDialog(){
|
||
document.getElementById('approvalDialog').classList.remove('show');
|
||
_pendingPayload = null;
|
||
}
|
||
|
||
async function confirmApproval(){
|
||
if (!_pendingPayload) return;
|
||
const remarks = document.getElementById('approvalRemarks').value.trim();
|
||
if (remarks) {
|
||
_pendingPayload.Comments = (_pendingPayload.Comments ? _pendingPayload.Comments + '\n' : '') + 'Approval Remarks: ' + remarks;
|
||
}
|
||
|
||
document.getElementById('btnApprovalOk').disabled = true;
|
||
document.getElementById('approvalDialog').classList.remove('show');
|
||
document.getElementById('btnPost').disabled = true;
|
||
document.getElementById('spPost').style.display = 'inline-block';
|
||
try {
|
||
const r = await fetch('/api/sap/purchase-request', {
|
||
method:'POST', headers:{...ah(),'Content-Type':'application/json'},
|
||
body: JSON.stringify(_pendingPayload),
|
||
});
|
||
const d = await r.json();
|
||
if (!r.ok || !d.success) throw new Error(d.message||'SAP error');
|
||
toast(`Purchase Request #${d.data?.DocEntry || ''} submitted. If it needed SAP approval it won't show under Open/Closed yet — check "Pending SAP Approval" in the status filter.`, 'ok');
|
||
resetForm();
|
||
_pendingPayload = null;
|
||
} catch(err){ toast(err.message,'err'); }
|
||
finally {
|
||
document.getElementById('btnPost').disabled = false;
|
||
document.getElementById('spPost').style.display = 'none';
|
||
document.getElementById('btnApprovalOk').disabled = false;
|
||
}
|
||
}
|
||
|
||
// ── List / Search ──────────────────────────────────────────────
|
||
function debounceSearch(){
|
||
clearTimeout(_srchTimer);
|
||
_srchTimer = setTimeout(() => { _page=0; loadList(); }, 400);
|
||
}
|
||
|
||
// A Purchase Request that triggers one of SAP's own Approval Procedures
|
||
// isn't created as a real PurchaseRequests document at all — DI API creates
|
||
// it as a SAP Draft instead, sitting there until an SAP approver acts on it
|
||
// (see "SAP Approval" for that). It has no DocumentStatus (Open/Closed), so
|
||
// it never showed up here at all — the requester submitted it and then had
|
||
// no way to even confirm it existed. "Pending SAP Approval" queries the
|
||
// dedicated GET /purchase-request/drafts endpoint instead of the normal
|
||
// PurchaseRequests list.
|
||
async function loadList(){
|
||
const wrap = document.getElementById('listWrap');
|
||
wrap.innerHTML = '<div class="pr-empty">Loading…</div>';
|
||
const q = document.getElementById('srchQ').value.trim();
|
||
const status = document.getElementById('srchStatus').value;
|
||
if (status === 'drafts'){ await loadDraftList(q); return; }
|
||
const skip = _page * PAGE_SIZE;
|
||
let url = `/api/sap/purchase-requests?company=${_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();
|
||
const rows = d.data || [];
|
||
const total = typeof d.total === 'number' ? d.total : null;
|
||
if (!rows.length){ wrap.innerHTML='<div class="pr-empty">No purchase requests found.</div>'; document.getElementById('pgBar').style.display='none'; return; }
|
||
wrap.innerHTML = `<div class="pr-list">${rows.map(pr => `
|
||
<div class="pr-card" onclick="openDoc(${pr.DocEntry})">
|
||
<div class="pr-card-top">
|
||
<span class="pr-docnum">#${esc(String(pr.DocNum||pr.DocEntry))}</span>
|
||
${statusBadge(pr.DocumentStatus)}
|
||
</div>
|
||
<div class="pr-icon">📝</div>
|
||
${pr.Comments ? `<div class="pr-comment" title="${esc(pr.Comments)}">${esc(pr.Comments)}</div>` : ''}
|
||
<div class="pr-meta">
|
||
<span>Date <b>${dt(pr.DocDate)}</b></span>
|
||
<span>Required <b>${dt(pr.DocDueDate)}</b></span>
|
||
${pr.CardCode ? `<span>Vendor <b>${esc(pr.CardCode)}</b></span>` : ''}
|
||
</div>
|
||
</div>`).join('')}</div>`;
|
||
// Pagination
|
||
const pgBar = document.getElementById('pgBar');
|
||
pgBar.style.display = 'flex';
|
||
document.getElementById('pgPrev').disabled = _page === 0;
|
||
document.getElementById('pgNext').disabled = total!=null ? (_page+1)*PAGE_SIZE >= total : rows.length < PAGE_SIZE;
|
||
document.getElementById('pgInfo').textContent = total!=null
|
||
? `Page ${_page+1} of ${Math.max(1,Math.ceil(total/PAGE_SIZE))} · ${total} request${total===1?'':'s'}`
|
||
: `Page ${_page+1}`;
|
||
} catch(err){ wrap.innerHTML=`<div class="pr-empty" style="color:var(--red)">${esc(err.message)}</div>`; }
|
||
}
|
||
|
||
async function loadDraftList(q){
|
||
const wrap = document.getElementById('listWrap');
|
||
document.getElementById('pgBar').style.display = 'none';
|
||
try {
|
||
const r = await fetch(`/api/sap/purchase-request/drafts?company=${_co}`, { headers: ah() });
|
||
const d = await r.json();
|
||
let rows = d.data || [];
|
||
if (q){
|
||
const needle = q.toUpperCase();
|
||
rows = rows.filter(pr => [pr.CardCode, pr.CardName, pr.Comments].some(v => (v||'').toUpperCase().includes(needle)));
|
||
}
|
||
if (!rows.length){ wrap.innerHTML = '<div class="pr-empty">No purchase requests pending SAP approval.</div>'; return; }
|
||
wrap.innerHTML = `<div class="pr-list">${rows.map(pr => `
|
||
<div class="pr-card" style="cursor:default">
|
||
<div class="pr-card-top">
|
||
<span class="pr-docnum">Draft #${esc(String(pr.DocEntry))}</span>
|
||
<span class="badge s-draft">⏳ Pending SAP Approval</span>
|
||
</div>
|
||
<div class="pr-icon">📝</div>
|
||
${pr.Comments ? `<div class="pr-comment" title="${esc(pr.Comments)}">${esc(pr.Comments)}</div>` : ''}
|
||
<div class="pr-meta">
|
||
<span>Date <b>${dt(pr.DocDate)}</b></span>
|
||
<span>Required <b>${dt(pr.RequriedDate||pr.DocDueDate)}</b></span>
|
||
${pr.CardCode ? `<span>Vendor <b>${esc(pr.CardCode)}</b></span>` : ''}
|
||
${(pr.U_Depart||pr.U_Department) ? `<span>Dept <b>${esc(pr.U_Depart||pr.U_Department)}</b></span>` : ''}
|
||
</div>
|
||
</div>`).join('')}</div>`;
|
||
} catch(err){ wrap.innerHTML = `<div class="pr-empty" style="color:var(--red)">${esc(err.message)}</div>`; }
|
||
}
|
||
|
||
function pgMove(dir){ _page = Math.max(0, _page+dir); loadList(); }
|
||
|
||
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>`;
|
||
if (s==='bost_Cancelled') return `<span class="badge s-cancelled">Cancelled</span>`;
|
||
return `<span class="badge s-draft">${esc(s||'Draft')}</span>`;
|
||
}
|
||
|
||
// ── Document detail ────────────────────────────────────────────
|
||
async function openDoc(id){
|
||
spin(true,'Loading document…');
|
||
try {
|
||
const r = await fetch(`/api/sap/purchase-requests/${id}?company=${_co}`, { headers: ah() });
|
||
const d = await r.json();
|
||
if (!r.ok || !d.success) throw new Error(d.message||'Not found');
|
||
_curDoc = d.data;
|
||
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('mStatusD').textContent = s || '—';
|
||
document.getElementById('mComments').textContent = doc.Comments || '—';
|
||
|
||
// Lines
|
||
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 style="font-family:var(--mono);font-size:10px">${dt(l.RequiredDate)}</td>
|
||
<td class="num">${l.Quantity||0}</td>
|
||
<td>${esc(l.UoMCode||l.unitOfMeasure||'—')}</td>
|
||
<td class="num">${parseFloat(l.UnitPrice||0).toFixed(2)}</td>
|
||
<td>${esc(l.WarehouseCode||'—')}</td>
|
||
</tr>`).join('')
|
||
: '<tr><td colspan="8" style="text-align:center;padding:16px;color:var(--text3)">No lines</td></tr>';
|
||
|
||
// Actions
|
||
const isOpen = s === 'bost_Open';
|
||
const isClosed = s === 'bost_Close';
|
||
const actions = document.getElementById('mActions');
|
||
actions.innerHTML = `
|
||
${isOpen && CAN_MANAGE_PR_DOC ? `<button class="btn b-green btn-sm" onclick="docAction('close')">✓ Close</button>` : ''}
|
||
${isOpen && CAN_MANAGE_PR_DOC ? `<button class="btn b-warn btn-sm" onclick="docAction('cancel')">✕ Cancel</button>` : ''}
|
||
${isClosed ? `<button class="btn b-blue btn-sm" onclick="docAction('reopen')">↩ Reopen</button>` : ''}
|
||
${isOpen && CAN_MANAGE_PR_DOC ? `<button class="btn b-secondary btn-sm" onclick="docAction('create-cancellation')">Create Cancellation</button>` : ''}
|
||
${isOpen && CAN_MANAGE_PR_DOC ? `<button class="btn b-danger btn-sm" onclick="docAction('delete')">Delete</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();
|
||
});
|
||
|
||
// ── Document actions ───────────────────────────────────────────
|
||
async function docAction(action){
|
||
if (!_curDoc) return;
|
||
const id = _curDoc.DocEntry;
|
||
const labels = { close:'Close', cancel:'Cancel', reopen:'Reopen', delete:'Delete', 'create-cancellation':'Create Cancellation' };
|
||
const label = labels[action] || action;
|
||
|
||
if (['delete','cancel'].includes(action)){
|
||
if (!confirm(`Are you sure you want to ${label} Purchase Request #${_curDoc.DocNum}?`)) return;
|
||
}
|
||
|
||
spin(true, `${label}ing…`);
|
||
try {
|
||
let r, d;
|
||
if (action === 'delete'){
|
||
r = await fetch(`/api/sap/purchase-requests/${id}?company=${_co}`, { method:'DELETE', headers: ah() });
|
||
d = await r.json();
|
||
} else {
|
||
r = await fetch(`/api/sap/purchase-requests/${id}/${action}?company=${_co}`, { method:'POST', headers: ah() });
|
||
d = await r.json();
|
||
}
|
||
if (!r.ok || !d.success) throw new Error(d.message||'Action failed');
|
||
toast(`Purchase Request ${label}d successfully`, 'ok');
|
||
closeModal();
|
||
loadList();
|
||
} catch(err){ toast(err.message,'err'); }
|
||
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;
|
||
document.getElementById('docDueDate').value = today;
|
||
document.getElementById('docDueDate').addEventListener('change', syncLineDates);
|
||
await loadWarehouses();
|
||
await loadDepartments();
|
||
addLine();
|
||
})();
|
||
</script>
|
||
<script src="/sidebar.js?v=31"></script>
|
||
</body>
|
||
</html>
|
||
|