Subversion Repositories SmartDukaan

Rev

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 which
       applies 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 &gt; 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 &gt; 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">&times;</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&hellip;</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&hellip;</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>