Blame | Last modification | View Log | RSS feed
## PO Stock Approval — detail fragment rendered inside the queue modal.## No outer wrapper / breadcrumb — modal chrome provides those.## POST /po/stock-approvals/{approvalId}/approve|reject with `reason` form param.<style type="text/css">.pmsad {--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;color: var(--ink);font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif}.pmsad .head {display: flex;justify-content: space-between;align-items: flex-start;padding: 4px 0 18px;border-bottom: 1px solid var(--line);margin-bottom: 18px}.pmsad .head .pn {font-size: 22px;font-weight: 800;color: var(--brand);letter-spacing: -.01em;line-height: 1.2;font-variant-numeric: tabular-nums}.pmsad .head .sub {font-size: 13px;color: var(--soft);margin-top: 4px;font-weight: 600}.pmsad .head .sub .sep {margin: 0 6px;opacity: .5}.pmsad .badge {display: inline-flex;align-items: center;padding: 6px 14px;border-radius: 20px;font-size: 11px;font-weight: 700;letter-spacing: .06em;text-transform: uppercase}.pmsad .badge.PENDING {background: var(--warn-bg);color: var(--warn)}.pmsad .badge.APPROVED {background: var(--good-bg);color: var(--good)}.pmsad .badge.REJECTED {background: var(--bad-bg);color: var(--bad)}/* Info tiles */.pmsad .tiles {display: grid;grid-template-columns: repeat(4, 1fr);gap: 12px;margin-bottom: 20px}.pmsad .tile {background: #fafbfe;border: 1px solid var(--line);border-radius: 10px;padding: 12px 14px}.pmsad .tile .lbl {font-size: 10.5px;font-weight: 700;color: var(--soft);letter-spacing: .05em;text-transform: uppercase;margin-bottom: 6px}.pmsad .tile .val {font-size: 15px;font-weight: 700;color: var(--ink);line-height: 1.2;font-variant-numeric: tabular-nums}.pmsad .tile.warn .val {color: var(--bad)}/* Requester reason card */.pmsad .reason-card {background: #fffbeb;border: 1px solid #fcd34d;border-radius: 10px;padding: 14px 18px;margin-bottom: 20px}.pmsad .reason-card .lbl {font-size: 11px;font-weight: 700;color: #92400e;letter-spacing: .05em;text-transform: uppercase;margin-bottom: 6px}.pmsad .reason-card .val {font-size: 14px;color: var(--ink);line-height: 1.5;font-style: italic}/* Flagged items section */.pmsad .section-hd {font-size: 14px;font-weight: 700;color: var(--ink);margin: 0 0 10px 0;letter-spacing: -.005em}.pmsad .section-hd .count {font-size: 11.5px;color: var(--soft);font-weight: 600;margin-left: 6px}.pmsad .items-tbl {width: 100%;border-collapse: separate;border-spacing: 0;border: 1px solid var(--line);border-radius: 10px;overflow: hidden;margin-bottom: 20px}.pmsad .items-tbl thead th {background: #fafbfe;padding: 10px 14px;font-size: 10.5px;font-weight: 700;color: var(--soft);letter-spacing: .05em;text-transform: uppercase;text-align: left;border-bottom: 1px solid var(--line)}.pmsad .items-tbl thead th.num {text-align: right}.pmsad .items-tbl thead th small {display: block;font-size: 9.5px;font-weight: 500;text-transform: none;letter-spacing: 0;margin-top: 2px;color: #94a3b8}.pmsad .items-tbl tbody td {padding: 12px 14px;font-size: 13.5px;color: var(--ink);border-bottom: 1px solid var(--line2);font-variant-numeric: tabular-nums}.pmsad .items-tbl tbody tr:last-child td {border-bottom: none}.pmsad .items-tbl tbody td.num {text-align: right}.pmsad .items-tbl tbody td.excess {color: var(--bad);font-weight: 700}/* Green when the item has NO aging stock at this vendor+warehouse — the itemis flagged only because 15d sold = 0 (no movement) rather than an actualaging pile. Signals to the approver "this is a fresh SKU, not aging risk". */.pmsad .items-tbl tbody td.excess.excess-fresh {color: var(--good)}.pmsad .items-tbl tbody tr:hover td {background: #fafbfe}.pmsad .items-tbl .item-id {color: var(--soft);font-size: 11.5px;margin-left: 6px}/* Aging-elsewhere sub-row: shown when the PO's own vendor+warehouse has 0 agingbut the item still has aging inventory at other (warehouse, vendor) buckets. */.pmsad .items-tbl tbody tr.pmsad-breakdown td {background: #fff9e6;color: #8a6d3b;font-size: 11.5px;padding: 6px 12px;border-top: 1px dashed #f1e0b0}.pmsad .items-tbl tbody tr.pmsad-breakdown td:first-child {padding-left: 32px}.pmsad .items-tbl tbody tr.pmsad-breakdown .pmsad-breakdown-lbl {color: #b78600;margin-right: 4px}/* Decide form (pending) */.pmsad .decide {background: #f7f8fb;border: 1px solid var(--line);border-radius: 10px;padding: 18px}.pmsad .decide .lbl {font-size: 11px;font-weight: 700;color: var(--soft);letter-spacing: .05em;text-transform: uppercase;margin-bottom: 8px}.pmsad .decide textarea {width: 100%;padding: 10px 12px;font-size: 13.5px;border: 1px solid var(--line);border-radius: 8px;background: #fff;color: var(--ink);resize: vertical;min-height: 80px;outline: none;transition: border-color .1s, box-shadow .1s;font-family: inherit}.pmsad .decide textarea:focus {border-color: var(--brand);box-shadow: 0 0 0 3px rgba(59, 91, 219, .12)}/* 50-char live counter under the textarea */.pmsad .pmsad-counter {font-size: 11.5px;color: var(--soft);margin-top: 6px;display: flex;gap: 8px;align-items: center}.pmsad .pmsad-counter #pmsadReasonCount {font-weight: 700;color: #c62828}.pmsad .pmsad-counter.pmsad-ok #pmsadReasonCount {color: #177a3a}.pmsad .pmsad-hint-short {color: #c62828;font-style: italic}.pmsad .pmsad-counter.pmsad-ok .pmsad-hint-short {display: none}.pmsad .pmsad-counter.pmsad-ok::after {content: "✓ ready";color: #177a3a;font-weight: 600}.pmsad .decide .actions {display: flex;gap: 10px;margin-top: 14px;align-items: center}.pmsad .btn {display: inline-flex;align-items: center;gap: 6px;padding: 8px 20px;border: 1px solid transparent;border-radius: 8px;font-size: 13px;font-weight: 650;cursor: pointer;transition: background .1s, transform .05s}.pmsad .btn:active {transform: translateY(1px)}.pmsad .btn:disabled {opacity: .5;cursor: not-allowed}.pmsad .btn-approve {background: var(--good);color: #fff;border-color: var(--good)}.pmsad .btn-approve:hover:not(:disabled) {background: #15803d;border-color: #15803d}.pmsad .btn-reject {background: #fff;color: var(--bad);border: 1px solid var(--bad)}.pmsad .btn-reject:hover:not(:disabled) {background: var(--bad-bg)}.pmsad .decide .status-txt {margin-left: auto;font-size: 12px;color: var(--soft);font-style: italic}/* Decision box (already decided) */.pmsad .decision {border-radius: 10px;padding: 18px 20px}.pmsad .decision.APPROVED {background: var(--good-bg);border: 1px solid var(--good)}.pmsad .decision.REJECTED {background: var(--bad-bg);border: 1px solid var(--bad)}.pmsad .decision .d-head {display: flex;align-items: center;gap: 10px;margin-bottom: 12px}.pmsad .decision .d-head .ico {width: 32px;height: 32px;border-radius: 50%;display: inline-flex;align-items: center;justify-content: center;font-size: 16px;font-weight: 800}.pmsad .decision.APPROVED .d-head .ico {background: var(--good);color: #fff}.pmsad .decision.REJECTED .d-head .ico {background: var(--bad);color: #fff}.pmsad .decision .d-head .d-title {font-size: 15px;font-weight: 800;color: var(--ink)}.pmsad .decision .d-head .d-title small {font-size: 11.5px;font-weight: 600;color: var(--soft);display: block;margin-top: 2px}.pmsad .decision .d-reason-lbl {font-size: 10.5px;font-weight: 700;color: var(--soft);letter-spacing: .05em;text-transform: uppercase;margin-bottom: 4px}.pmsad .decision .d-reason {font-size: 13.5px;color: var(--ink);line-height: 1.5;font-style: italic}.pmsad .alert-err {background: var(--warn-bg);border: 1px solid #fcd34d;color: #92400e;padding: 12px 16px;border-radius: 10px;font-size: 13px}@media (max-width: 900px) {.pmsad .tiles {grid-template-columns: repeat(2, 1fr)}}</style><div class="pmsad">#if($!error)<div class="alert-err">$error</div>#else<!-- Header --><div class="head"><div><div class="pn">$!po.getPoNumber()</div><div class="sub">#if($!supplier)$supplier.getName()#else-#end<span class="sep">·</span>$!warehouseName</div></div><div><span class="badge $!approval.getStatus()">$!approval.getStatus()</span></div></div><!-- Info tiles --><div class="tiles"><div class="tile"><div class="lbl">PO Date</div><div class="val">#if($!po.getCreatedAt())$po.getCreatedAt().toLocalDate()#else-#end</div></div><div class="tile"><div class="lbl">Total Cost</div><div class="val">₹#if($!po.getTotalCost())$po.getTotalCost()#else-#end</div></div><div class="tile"><div class="lbl">Requester</div><div class="val">$!requesterName</div></div><div class="tile warn"><div class="lbl">Total Excess</div><div class="val">+$!totalExcess units</div></div></div><!-- Requester reason --><div class="reason-card"><div class="lbl">Requester's reason for buying aging stock</div><div class="val">$!approval.getRequesterReason()</div></div><!-- Flagged items --><div class="section-hd">Flagged Items <span class="count">($!flagged.size())</span></div><table class="items-tbl"><thead><tr><th>Item</th><th class="num">Aging Stock<small>vendor + warehouse</small></th><th class="num">15d Sold<small>warehouse</small></th><th class="num">PO Qty</th><th class="num">Excess</th></tr></thead><tbody>#foreach($f in $flagged)## Excess turns GREEN only when the item has NO aging anywhere in the network —## neither at this vendor+warehouse (agingQty > 0) nor elsewhere (agingBreakdown## non-empty). "Aging elsewhere" is still an aging concern, just at a different## depot, so those rows must render RED.#set($hasAgingHere = ($f.get("agingQty") != 0))#set($hasAgingElsewhere = ($f.get("agingBreakdown") && $f.get("agingBreakdown").size() > 0))#set($isFresh = !$hasAgingHere && !$hasAgingElsewhere)<tr><td>$!f.get("modelName")<span class="item-id">#$!f.get("itemId")</span></td><td class="num">$!f.get("agingQty")</td><td class="num">$!f.get("sold15dQty")</td><td class="num">$!f.get("poQty")</td>#if($isFresh)<td class="num excess excess-fresh">+$!f.get("excess")</td>#else<td class="num excess">+$!f.get("excess")</td>#end</tr>#if($f.get("agingQty") == 0 && $f.get("agingBreakdown") && $f.get("agingBreakdown").size() > 0)#foreach($b in $f.get("agingBreakdown"))## Compact label: first segment of warehouse displayName + first word of vendor.## Vendor-owned warehouses have names like "DL-NSSPL/Delhi/BSB Marketing/G";## we only want the region-code head. Vendor "BSB Marketing Private Limited"## collapses to "BSB". Falls back to raw values if either is blank / missing.#set($whFull = $!b.get("warehouseName"))#set($vFull = $!b.get("vendorName"))#set($whShort = $whFull)#if($whFull && $whFull.contains("/"))#set($whShort = $whFull.split("/")[0])#end#set($vShort = $vFull)#if($vFull && $vFull.contains(" "))#set($vShort = $vFull.split(" ")[0])#end<tr class="pmsad-breakdown"><td><span class="pmsad-breakdown-lbl">↳ aging elsewhere:</span>$whShort - ($vShort)</td><td class="num">$!b.get("qty")</td><td colspan="3"></td></tr>#end#end#end</tbody></table><!-- Decide (pending) OR Decision (already decided) -->#if($!isPending && $!canDecide)<div class="decide"><div class="lbl">Decision Reason (mandatory · min 50 characters)</div><textarea id="decisionReason"placeholder="Explain in detail why you're approving or rejecting this PO. Minimum 50 characters."></textarea><div class="pmsad-counter"><span id="pmsadReasonCount">0</span> / 50 characters minimum<span id="pmsadReasonHint" class="pmsad-hint-short">— need more detail</span></div><div class="actions"><button type="button" class="btn btn-approve" id="btnApprove" disableddata-approval-id="$approval.getId()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>Approve</button><button type="button" class="btn btn-reject" id="btnReject" disableddata-approval-id="$approval.getId()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>Reject</button><span class="status-txt" id="decisionStatus"></span></div></div>#else<div class="decision $!approval.getStatus()"><div class="d-head"><div class="ico">#if($!approval.getStatus().toString() == 'APPROVED')✓#else✕#end</div><div class="d-title">$!approval.getStatus() by $!approval.getDecidedBy()<small>#if($!approval.getDecidedAt())$approval.getDecidedAt()#end</small></div></div><div class="d-reason-lbl">Reason</div><div class="d-reason">$!approval.getDecisionReason()</div></div>#end#end</div>#if($!isPending && $!canDecide)<script type="text/javascript">(function () {var ctx = "${rc.contextPath}";var MIN_REASON = 50;// Live 50-char counter — gates the approve/reject buttons.var ta = document.getElementById("decisionReason");var counterBox = ta ? ta.parentNode.querySelector(".pmsad-counter") : null;var countEl = document.getElementById("pmsadReasonCount");var btnA = document.getElementById("btnApprove");var btnR = document.getElementById("btnReject");function refreshCounter() {var len = ta.value.trim().length;if (countEl) countEl.textContent = len;var ok = len >= MIN_REASON;if (counterBox) counterBox.classList.toggle("pmsad-ok", ok);if (btnA) btnA.disabled = !ok;if (btnR) btnR.disabled = !ok;}if (ta) {ta.addEventListener("input", refreshCounter);ta.addEventListener("change", refreshCounter);refreshCounter();}function decide(action, approvalId) {var reason = ta.value.trim();if (reason.length < MIN_REASON) {alert("Reason needs at least " + MIN_REASON + " characters (currently " + reason.length + ").");return;}if (!confirm("Are you sure you want to " + action + " this PO?")) return;btnA.disabled = true;btnR.disabled = true;document.getElementById("decisionStatus").innerText = "Submitting…";var form = new URLSearchParams();form.append("reason", reason);fetch(ctx + "/po/stock-approvals/" + approvalId + "/" + action, {method: "POST",credentials: "same-origin",headers: {"Content-Type": "application/x-www-form-urlencoded"},body: form.toString()}).then(function (r) {return r.text();}).then(function (t) {document.getElementById("decisionStatus").innerText = "";alert("PO " + action + "d successfully.");if (window.jQuery && jQuery('#psoDetailModal').length) {jQuery(document).trigger('pso:decided');} else {window.location = ctx + "/po/stock-approvals";}}).catch(function (e) {document.getElementById("btnApprove").disabled = false;document.getElementById("btnReject").disabled = false;document.getElementById("decisionStatus").innerText = "Failed — try again";alert("Failed: " + (e && e.message ? e.message : "network error"));});}document.getElementById("btnApprove").addEventListener("click", function () {decide("approve", this.getAttribute("data-approval-id"));});document.getElementById("btnReject").addEventListener("click", function () {decide("reject", this.getAttribute("data-approval-id"));});})();</script>#end