Blame | Last modification | View Log | RSS feed
## PO Stock Approvals โ queue view for approvers.## Route: /po/stock-approvals. Detail modal fetched from /po/stock-approvals/{poId}.## Row-click opens the modal; approve/reject fires from inside via pso:decided event.<style type="text/css">.psov2 {--brand: #3b5bdb;--brand2: #28449e;--ink: #1a2027;--soft: #6b7280;--line: #e5e7eb;--line2: #f0f2f6;--bg: #f7f8fb;--good: #16a34a;--good-bg: #dcfce7;--warn: #ea580c;--warn-bg: #ffedd5;--bad: #dc2626;--bad-bg: #fee2e2;--gray-bg: #f1f5f9;background: var(--bg);padding: 20px 22px 60px;font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;color: var(--ink);min-height: 100vh}.psov2 .hdr {display: flex;justify-content: space-between;align-items: flex-end;margin-bottom: 22px}.psov2 .hdr h1 {font-size: 26px;font-weight: 800;margin: 0;letter-spacing: -.01em}.psov2 .hdr .sub {color: var(--soft);font-size: 13.5px;margin-top: 4px}.psov2 .hdr .live {display: inline-flex;align-items: center;gap: 6px;font-size: 11px;color: var(--good);background: var(--good-bg);border-radius: 20px;padding: 4px 10px;font-weight: 700;letter-spacing: .05em;text-transform: uppercase;margin-left: 8px}.psov2 .hdr .live::before {content: "";width: 6px;height: 6px;border-radius: 50%;background: var(--good)}/* --- KPI cards --- */.psov2 .kpis {display: grid;grid-template-columns: repeat(5, 1fr);gap: 14px;margin-bottom: 22px}.psov2 .kpi {background: #fff;border-radius: 12px;padding: 16px 18px;display: flex;align-items: center;gap: 14px;box-shadow: 0 1px 2px rgba(15, 23, 42, .04);border: 1px solid var(--line)}.psov2 .kpi .icn {width: 44px;height: 44px;border-radius: 10px;display: flex;align-items: center;justify-content: center;font-size: 20px;flex-shrink: 0}.psov2 .kpi .txt .num {font-size: 22px;font-weight: 800;line-height: 1.1;font-variant-numeric: tabular-nums}.psov2 .kpi .txt .lbl {font-size: 11.5px;color: var(--soft);font-weight: 600;margin-top: 2px}.psov2 .kpi.i-pending .icn {background: #eef2fb;color: var(--brand)}.psov2 .kpi.i-stale .icn {background: var(--warn-bg);color: var(--warn)}.psov2 .kpi.i-approved .icn {background: var(--good-bg);color: var(--good)}.psov2 .kpi.i-rejected .icn {background: var(--bad-bg);color: var(--bad)}.psov2 .kpi.i-excess .icn {background: #f3e8ff;color: #7c3aed}/* --- Main card --- */.psov2 .card {background: #fff;border-radius: 12px;box-shadow: 0 1px 2px rgba(15, 23, 42, .04);border: 1px solid var(--line);overflow: hidden}.psov2 .card-hd {display: flex;justify-content: space-between;align-items: center;padding: 16px 22px;border-bottom: 1px solid var(--line)}.psov2 .card-hd .title h2 {margin: 0;font-size: 17px;font-weight: 700}.psov2 .card-hd .title .sub {color: var(--soft);font-size: 12.5px;margin-top: 2px}/* --- Filter tabs --- */.psov2 .tabs {display: inline-flex;background: var(--gray-bg);border-radius: 8px;padding: 3px}.psov2 .tabs .tab {padding: 6px 14px;font-size: 12.5px;font-weight: 650;color: var(--soft);background: transparent;border: none;border-radius: 6px;cursor: pointer;transition: background .1s, color .1s}.psov2 .tabs .tab.on {background: var(--good);color: #fff;box-shadow: 0 1px 2px rgba(22, 163, 74, .18)}.psov2 .tabs .tab.on.pending {background: var(--brand);box-shadow: 0 1px 2px rgba(59, 91, 219, .2)}.psov2 .tabs .tab.on.rejected {background: var(--bad);box-shadow: 0 1px 2px rgba(220, 38, 38, .2)}/* --- List row --- Uses .psov2-list / .psov2-row (NOT Bootstrap's .row whichapplies conflicting flex + negative margins from the parent admin frame). */.psov2 .psov2-list {padding: 4px 0}.psov2 .psov2-row {display: grid;grid-template-columns: minmax(200px, 2fr) minmax(160px, 1.6fr) minmax(140px, 1.2fr) minmax(180px, 2fr) 110px 40px;column-gap: 20px;row-gap: 0;align-items: center;padding: 14px 22px;margin: 0;border-bottom: 1px solid var(--line2);cursor: pointer;transition: background .08s;background: #fff}/* Hidden state must beat display:grid โ used by tab filter. */.psov2 .psov2-row.hide {display: none}.psov2 .psov2-row:last-child {border-bottom: none}.psov2 .psov2-row:hover {background: #fafbfe}.psov2 .psov2-row > * {min-width: 0 /* let grid columns actually shrink instead of overflowing */}.psov2 .r-primary .pn {font-size: 14px;font-weight: 700;color: var(--brand);font-variant-numeric: tabular-nums;white-space: nowrap;overflow: hidden;text-overflow: ellipsis}.psov2 .r-primary .rec {font-size: 11.5px;color: var(--soft);margin-top: 3px;white-space: nowrap;overflow: hidden;text-overflow: ellipsis}.psov2 .r-vendor .v {font-size: 13px;color: var(--ink);font-weight: 600;white-space: nowrap;overflow: hidden;text-overflow: ellipsis}.psov2 .r-vendor .w {font-size: 11.5px;color: var(--soft);margin-top: 3px;white-space: nowrap;overflow: hidden;text-overflow: ellipsis;font-weight: 600;letter-spacing: .04em;text-transform: uppercase}.psov2 .r-flags .fc {font-size: 13px;font-weight: 700;color: var(--ink)}.psov2 .r-flags .fx {font-size: 12px;color: var(--bad);font-weight: 600;margin-top: 2px}.psov2 .r-reason {font-size: 12.5px;color: var(--ink);line-height: 1.4;display: -webkit-box;-webkit-line-clamp: 2;-webkit-box-orient: vertical;overflow: hidden}.psov2 .r-reason.empty {color: var(--soft);font-style: italic}.psov2 .badge {display: inline-flex;align-items: center;padding: 5px 12px;border-radius: 20px;font-size: 11px;font-weight: 700;letter-spacing: .05em;text-transform: uppercase}.psov2 .badge.PENDING {background: var(--warn-bg);color: var(--warn)}.psov2 .badge.APPROVED {background: var(--good-bg);color: var(--good)}.psov2 .badge.REJECTED {background: var(--bad-bg);color: var(--bad)}.psov2 .r-badge {text-align: center}.psov2 .r-actions {display: flex;justify-content: flex-end;align-items: center}.psov2 .icon-btn {width: 34px;height: 34px;padding: 0;display: inline-flex;align-items: center;justify-content: center;background: var(--good-bg);color: var(--good);border: 1px solid transparent;border-radius: 8px;font-size: 14px;cursor: pointer;transition: background .1s, transform .05s}.psov2 .icon-btn:active {transform: scale(.94)}.psov2 .icon-btn:hover {background: #bbf7d0}.psov2 .icon-btn.psov2-view {background: var(--good-bg);color: var(--good)}.psov2 .empty {text-align: center;padding: 44px 20px;color: var(--soft)}.psov2 .empty .ei {font-size: 34px;margin-bottom: 10px}.psov2 .empty b {color: var(--ink);font-size: 14px}.psov2 .rule-note {color: var(--soft);font-size: 12px;padding: 10px 22px;border-top: 1px solid var(--line);background: #fafbfe}.psov2 .rule-note code {background: #eef2fb;color: var(--brand);padding: 1px 5px;border-radius: 3px;font-size: 11px}@media (max-width: 1100px) {.psov2 .kpis {grid-template-columns: repeat(3, 1fr)}.psov2 .psov2-row {grid-template-columns: minmax(180px, 2fr) minmax(140px, 1.4fr) 110px 40px;row-gap: 6px}.psov2 .r-flags, .psov2 .r-reason {display: none}}</style><div class="psov2"><div class="hdr"><div><h1>PO Stock Approvals <span class="live">Live</span></h1><div class="sub">Purchase orders where <b>aging stock + PO qty</b> exceeds last-15-day sales.</div></div></div><!-- KPI tiles --><div class="kpis"><div class="kpi i-pending"><div class="icn">๐</div><div class="txt"><div class="num">$!pendingCount</div><div class="lbl">Pending</div></div></div><div class="kpi i-stale"><div class="icn">โฐ</div><div class="txt"><div class="num">$!staleCount</div><div class="lbl">Pending > 24h</div></div></div><div class="kpi i-approved"><div class="icn">โ</div><div class="txt"><div class="num">$!approvedTodayCount</div><div class="lbl">Approved today</div></div></div><div class="kpi i-rejected"><div class="icn">โ</div><div class="txt"><div class="num">$!rejectedTodayCount</div><div class="lbl">Rejected today</div></div></div><div class="kpi i-excess"><div class="icn">๐ฆ</div><div class="txt"><div class="num">+$!totalPendingExcess</div><div class="lbl">Excess in queue</div></div></div></div><!-- Main list card --><div class="card"><div class="card-hd"><div class="title"><h2>Approvals Queue</h2><div class="sub">Click any row to review the flagged items and decide.</div></div><div class="tabs" id="psov2Tabs"><button type="button" class="tab on pending" data-filter="PENDING">Pending</button><button type="button" class="tab" data-filter="APPROVED">Approved</button><button type="button" class="tab" data-filter="REJECTED">Rejected</button><button type="button" class="tab" data-filter="ALL">All</button></div></div>#if($!error)<div class="empty"><div class="ei">๐</div><b>$error</b></div>#else<div class="psov2-list" id="psov2Rows">#foreach($r in $rows)<div class="psov2-row" data-status="$!r.status" data-poid="$!r.poId"><div class="r-primary"><div class="pn">$!r.poNumber</div><div class="rec">$!r.requesterName ยท $!r.createdAt</div></div><div class="r-vendor"><div class="v">$!r.vendorName</div><div class="w">$!r.warehouseName</div></div><div class="r-flags"><div class="fc">$!r.flaggedCount item#if($r.flaggedCount != 1)s#end flagged</div><div class="fx">+$!r.totalExcess excess</div></div><div class="r-reason#if($!r.requesterReason == '') empty#end" title="$!r.requesterReason">#if($!r.requesterReason == '')No reason on file#else$!r.requesterReason#end</div><div class="r-badge"><span class="badge $!r.status">$!r.status</span></div><div class="r-actions"><button type="button" class="icon-btn psov2-view" title="Review this approval"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button></div></div>#end</div><div class="empty" id="psov2EmptyState" style="display:none"><div class="ei">๐ญ</div><b>No approvals match this filter.</b><div style="margin-top:4px">Try switching tabs above.</div></div>#end<div class="rule-note">Rule: a PO is held here when <code>agingStock + poQty > last-15-day sales</code>for any of its items. Requester must provide a reason before submission.</div></div></div><!-- Detail modal (unchanged from v1) --><div class="modal fade" id="psoDetailModal" tabindex="-1" role="dialog" aria-hidden="true"><div class="modal-dialog modal-lg" role="document" style="width:90%;max-width:1100px;margin:30px auto"><div class="modal-content"><div class="modal-header" style="padding:12px 20px"><button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">×</span></button><h4 class="modal-title" style="margin:0">PO Stock Approval</h4></div><div class="modal-body" id="psoDetailBody"style="max-height:calc(100vh - 140px);overflow-y:auto;padding:20px"><div style="text-align:center;padding:24px;color:#888">Loading…</div></div></div></div></div><script type="text/javascript">(function () {var ctx = "${rc.contextPath}";var initialFilter = "$!initialFilter";// Uses .hide class (which has display:none) instead of inline style so it// beats display:grid without needing !important.function applyFilter(f) {var visible = 0;$("#psov2Rows .psov2-row").each(function () {var s = this.getAttribute("data-status");var show = (f === "ALL") || (s === f);if (show) {this.classList.remove("hide");visible++;} else {this.classList.add("hide");}});$("#psov2EmptyState").toggle(visible === 0);$("#psov2Tabs .tab").removeClass("on pending rejected");var btn = $("#psov2Tabs .tab[data-filter='" + f + "']");btn.addClass("on");if (f === "PENDING") btn.addClass("pending");if (f === "REJECTED") btn.addClass("rejected");}applyFilter(initialFilter || "PENDING");// Off/on so reloads don't stack duplicate handlers.$("#psov2Tabs .tab").off("click.psov2tab").on("click.psov2tab", function () {applyFilter(this.getAttribute("data-filter"));});// Row click โ open detail modal. Defensive guard: bail out if the row// is missing data-poid (would otherwise send /po/stock-approvals/undefined// and produce a NumberFormatException server-side).//// The nuclear off('click', '.psov2-row') purges ANY previously-bound// click handler on this selector โ including handlers from older template// versions still lingering in memory after a partial reload. Only THEN do// we register the current guarded version.$(document).off('click', '.psov2-row');$(document).off('click.psov2 click.pso').on('click.psov2', '.psov2-row', function (ev) {var poId = this.getAttribute('data-poid');if (!poId || poId === 'undefined' || poId === 'null' || isNaN(parseInt(poId, 10))) {console.warn('psov2: row missing valid data-poid, ignoring click', this);return;}// Coerce to integer explicitly โ belt-and-braces against string weirdness.poId = parseInt(poId, 10);$('#psoDetailBody').html('<div style="text-align:center;padding:24px;color:#888">Loading…</div>');$('#psoDetailModal').modal('show');$.ajax({url: ctx + '/po/stock-approvals/' + poId,type: 'GET',dataType: 'html',global: false,success: function (html) {$('#psoDetailBody').html(html);},error: function () {$('#psoDetailBody').html('<div style="text-align:center;padding:24px;color:#c00">Failed to load detail. Try again.</div>');}});});// After decide โ close modal cleanly + reload the queue.$(document).off('pso:decided').on('pso:decided', function () {var reloaded = false;var doReload = function () {if (reloaded) return;reloaded = true;$('.modal-backdrop').remove();$('body').removeClass('modal-open').css('padding-right', '');// Full re-render โ KPI counts need re-computing after a decision.loadPoStockApprovals('main-content');};var modalEl = $('#psoDetailModal');modalEl.one('hidden.bs.modal', doReload);modalEl.modal('hide');setTimeout(doReload, 800);});})();</script>