Subversion Repositories SmartDukaan

Rev

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 item
     is flagged only because 15d sold = 0 (no movement) rather than an actual
     aging 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 aging
     but 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">&#8627; 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" disabled
                  data-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" disabled
                  data-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