Subversion Repositories SmartDukaan

Rev

Details | Last modification | View Log | RSS feed

Rev Author Line No. Line
37750 ranu 1
## PO Stock Approvals โ€” queue view for approvers.
2
## Route: /po/stock-approvals. Detail modal fetched from /po/stock-approvals/{poId}.
3
## Row-click opens the modal; approve/reject fires from inside via pso:decided event.
4
<style type="text/css">
5
    .psov2 {
6
        --brand: #3b5bdb;
7
        --brand2: #28449e;
8
        --ink: #1a2027;
9
        --soft: #6b7280;
10
        --line: #e5e7eb;
11
        --line2: #f0f2f6;
12
        --bg: #f7f8fb;
13
        --good: #16a34a;
14
        --good-bg: #dcfce7;
15
        --warn: #ea580c;
16
        --warn-bg: #ffedd5;
17
        --bad: #dc2626;
18
        --bad-bg: #fee2e2;
19
        --gray-bg: #f1f5f9;
20
        background: var(--bg);
21
        padding: 20px 22px 60px;
22
        font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
23
        color: var(--ink);
24
        min-height: 100vh
25
    }
26
 
27
    .psov2 .hdr {
28
        display: flex;
29
        justify-content: space-between;
30
        align-items: flex-end;
31
        margin-bottom: 22px
32
    }
33
 
34
    .psov2 .hdr h1 {
35
        font-size: 26px;
36
        font-weight: 800;
37
        margin: 0;
38
        letter-spacing: -.01em
39
    }
40
 
41
    .psov2 .hdr .sub {
42
        color: var(--soft);
43
        font-size: 13.5px;
44
        margin-top: 4px
45
    }
46
 
47
    .psov2 .hdr .live {
48
        display: inline-flex;
49
        align-items: center;
50
        gap: 6px;
51
        font-size: 11px;
52
        color: var(--good);
53
        background: var(--good-bg);
54
        border-radius: 20px;
55
        padding: 4px 10px;
56
        font-weight: 700;
57
        letter-spacing: .05em;
58
        text-transform: uppercase;
59
        margin-left: 8px
60
    }
61
 
62
    .psov2 .hdr .live::before {
63
        content: "";
64
        width: 6px;
65
        height: 6px;
66
        border-radius: 50%;
67
        background: var(--good)
68
    }
69
 
70
    /* --- KPI cards --- */
71
    .psov2 .kpis {
72
        display: grid;
73
        grid-template-columns: repeat(5, 1fr);
74
        gap: 14px;
75
        margin-bottom: 22px
76
    }
77
 
78
    .psov2 .kpi {
79
        background: #fff;
80
        border-radius: 12px;
81
        padding: 16px 18px;
82
        display: flex;
83
        align-items: center;
84
        gap: 14px;
85
        box-shadow: 0 1px 2px rgba(15, 23, 42, .04);
86
        border: 1px solid var(--line)
87
    }
88
 
89
    .psov2 .kpi .icn {
90
        width: 44px;
91
        height: 44px;
92
        border-radius: 10px;
93
        display: flex;
94
        align-items: center;
95
        justify-content: center;
96
        font-size: 20px;
97
        flex-shrink: 0
98
    }
99
 
100
    .psov2 .kpi .txt .num {
101
        font-size: 22px;
102
        font-weight: 800;
103
        line-height: 1.1;
104
        font-variant-numeric: tabular-nums
105
    }
106
 
107
    .psov2 .kpi .txt .lbl {
108
        font-size: 11.5px;
109
        color: var(--soft);
110
        font-weight: 600;
111
        margin-top: 2px
112
    }
113
 
114
    .psov2 .kpi.i-pending .icn {
115
        background: #eef2fb;
116
        color: var(--brand)
117
    }
118
 
119
    .psov2 .kpi.i-stale .icn {
120
        background: var(--warn-bg);
121
        color: var(--warn)
122
    }
123
 
124
    .psov2 .kpi.i-approved .icn {
125
        background: var(--good-bg);
126
        color: var(--good)
127
    }
128
 
129
    .psov2 .kpi.i-rejected .icn {
130
        background: var(--bad-bg);
131
        color: var(--bad)
132
    }
133
 
134
    .psov2 .kpi.i-excess .icn {
135
        background: #f3e8ff;
136
        color: #7c3aed
137
    }
138
 
139
    /* --- Main card --- */
140
    .psov2 .card {
141
        background: #fff;
142
        border-radius: 12px;
143
        box-shadow: 0 1px 2px rgba(15, 23, 42, .04);
144
        border: 1px solid var(--line);
145
        overflow: hidden
146
    }
147
 
148
    .psov2 .card-hd {
149
        display: flex;
150
        justify-content: space-between;
151
        align-items: center;
152
        padding: 16px 22px;
153
        border-bottom: 1px solid var(--line)
154
    }
155
 
156
    .psov2 .card-hd .title h2 {
157
        margin: 0;
158
        font-size: 17px;
159
        font-weight: 700
160
    }
161
 
162
    .psov2 .card-hd .title .sub {
163
        color: var(--soft);
164
        font-size: 12.5px;
165
        margin-top: 2px
166
    }
167
 
168
    /* --- Filter tabs --- */
169
    .psov2 .tabs {
170
        display: inline-flex;
171
        background: var(--gray-bg);
172
        border-radius: 8px;
173
        padding: 3px
174
    }
175
 
176
    .psov2 .tabs .tab {
177
        padding: 6px 14px;
178
        font-size: 12.5px;
179
        font-weight: 650;
180
        color: var(--soft);
181
        background: transparent;
182
        border: none;
183
        border-radius: 6px;
184
        cursor: pointer;
185
        transition: background .1s, color .1s
186
    }
187
 
188
    .psov2 .tabs .tab.on {
189
        background: var(--good);
190
        color: #fff;
191
        box-shadow: 0 1px 2px rgba(22, 163, 74, .18)
192
    }
193
 
194
    .psov2 .tabs .tab.on.pending {
195
        background: var(--brand);
196
        box-shadow: 0 1px 2px rgba(59, 91, 219, .2)
197
    }
198
 
199
    .psov2 .tabs .tab.on.rejected {
200
        background: var(--bad);
201
        box-shadow: 0 1px 2px rgba(220, 38, 38, .2)
202
    }
203
 
204
    /* --- List row --- Uses .psov2-list / .psov2-row (NOT Bootstrap's .row which
205
       applies conflicting flex + negative margins from the parent admin frame). */
206
    .psov2 .psov2-list {
207
        padding: 4px 0
208
    }
209
 
210
    .psov2 .psov2-row {
211
        display: grid;
212
        grid-template-columns: minmax(200px, 2fr) minmax(160px, 1.6fr) minmax(140px, 1.2fr) minmax(180px, 2fr) 110px 40px;
213
        column-gap: 20px;
214
        row-gap: 0;
215
        align-items: center;
216
        padding: 14px 22px;
217
        margin: 0;
218
        border-bottom: 1px solid var(--line2);
219
        cursor: pointer;
220
        transition: background .08s;
221
        background: #fff
222
    }
223
 
224
    /* Hidden state must beat display:grid โ€” used by tab filter. */
225
    .psov2 .psov2-row.hide {
226
        display: none
227
    }
228
 
229
    .psov2 .psov2-row:last-child {
230
        border-bottom: none
231
    }
232
 
233
    .psov2 .psov2-row:hover {
234
        background: #fafbfe
235
    }
236
 
237
    .psov2 .psov2-row > * {
238
        min-width: 0 /* let grid columns actually shrink instead of overflowing */
239
    }
240
 
241
    .psov2 .r-primary .pn {
242
        font-size: 14px;
243
        font-weight: 700;
244
        color: var(--brand);
245
        font-variant-numeric: tabular-nums;
246
        white-space: nowrap;
247
        overflow: hidden;
248
        text-overflow: ellipsis
249
    }
250
 
251
    .psov2 .r-primary .rec {
252
        font-size: 11.5px;
253
        color: var(--soft);
254
        margin-top: 3px;
255
        white-space: nowrap;
256
        overflow: hidden;
257
        text-overflow: ellipsis
258
    }
259
 
260
    .psov2 .r-vendor .v {
261
        font-size: 13px;
262
        color: var(--ink);
263
        font-weight: 600;
264
        white-space: nowrap;
265
        overflow: hidden;
266
        text-overflow: ellipsis
267
    }
268
 
269
    .psov2 .r-vendor .w {
270
        font-size: 11.5px;
271
        color: var(--soft);
272
        margin-top: 3px;
273
        white-space: nowrap;
274
        overflow: hidden;
275
        text-overflow: ellipsis;
276
        font-weight: 600;
277
        letter-spacing: .04em;
278
        text-transform: uppercase
279
    }
280
 
281
    .psov2 .r-flags .fc {
282
        font-size: 13px;
283
        font-weight: 700;
284
        color: var(--ink)
285
    }
286
 
287
    .psov2 .r-flags .fx {
288
        font-size: 12px;
289
        color: var(--bad);
290
        font-weight: 600;
291
        margin-top: 2px
292
    }
293
 
294
    .psov2 .r-reason {
295
        font-size: 12.5px;
296
        color: var(--ink);
297
        line-height: 1.4;
298
        display: -webkit-box;
299
        -webkit-line-clamp: 2;
300
        -webkit-box-orient: vertical;
301
        overflow: hidden
302
    }
303
 
304
    .psov2 .r-reason.empty {
305
        color: var(--soft);
306
        font-style: italic
307
    }
308
 
309
    .psov2 .badge {
310
        display: inline-flex;
311
        align-items: center;
312
        padding: 5px 12px;
313
        border-radius: 20px;
314
        font-size: 11px;
315
        font-weight: 700;
316
        letter-spacing: .05em;
317
        text-transform: uppercase
318
    }
319
 
320
    .psov2 .badge.PENDING {
321
        background: var(--warn-bg);
322
        color: var(--warn)
323
    }
324
 
325
    .psov2 .badge.APPROVED {
326
        background: var(--good-bg);
327
        color: var(--good)
328
    }
329
 
330
    .psov2 .badge.REJECTED {
331
        background: var(--bad-bg);
332
        color: var(--bad)
333
    }
334
 
335
    .psov2 .r-badge {
336
        text-align: center
337
    }
338
 
339
    .psov2 .r-actions {
340
        display: flex;
341
        justify-content: flex-end;
342
        align-items: center
343
    }
344
 
345
    .psov2 .icon-btn {
346
        width: 34px;
347
        height: 34px;
348
        padding: 0;
349
        display: inline-flex;
350
        align-items: center;
351
        justify-content: center;
352
        background: var(--good-bg);
353
        color: var(--good);
354
        border: 1px solid transparent;
355
        border-radius: 8px;
356
        font-size: 14px;
357
        cursor: pointer;
358
        transition: background .1s, transform .05s
359
    }
360
 
361
    .psov2 .icon-btn:active {
362
        transform: scale(.94)
363
    }
364
 
365
    .psov2 .icon-btn:hover {
366
        background: #bbf7d0
367
    }
368
 
369
    .psov2 .icon-btn.psov2-view {
370
        background: var(--good-bg);
371
        color: var(--good)
372
    }
373
 
374
    .psov2 .empty {
375
        text-align: center;
376
        padding: 44px 20px;
377
        color: var(--soft)
378
    }
379
 
380
    .psov2 .empty .ei {
381
        font-size: 34px;
382
        margin-bottom: 10px
383
    }
384
 
385
    .psov2 .empty b {
386
        color: var(--ink);
387
        font-size: 14px
388
    }
389
 
390
    .psov2 .rule-note {
391
        color: var(--soft);
392
        font-size: 12px;
393
        padding: 10px 22px;
394
        border-top: 1px solid var(--line);
395
        background: #fafbfe
396
    }
397
 
398
    .psov2 .rule-note code {
399
        background: #eef2fb;
400
        color: var(--brand);
401
        padding: 1px 5px;
402
        border-radius: 3px;
403
        font-size: 11px
404
    }
405
 
406
    @media (max-width: 1100px) {
407
        .psov2 .kpis {
408
            grid-template-columns: repeat(3, 1fr)
409
        }
410
 
411
        .psov2 .psov2-row {
412
            grid-template-columns: minmax(180px, 2fr) minmax(140px, 1.4fr) 110px 40px;
413
            row-gap: 6px
414
        }
415
 
416
        .psov2 .r-flags, .psov2 .r-reason {
417
            display: none
418
        }
419
    }
420
</style>
421
 
422
<div class="psov2">
423
    <div class="hdr">
424
        <div>
425
            <h1>PO Stock Approvals <span class="live">Live</span></h1>
426
            <div class="sub">Purchase orders where <b>aging stock + PO qty</b> exceeds last-15-day sales.</div>
427
        </div>
428
    </div>
429
 
430
    <!-- KPI tiles -->
431
    <div class="kpis">
432
        <div class="kpi i-pending">
433
            <div class="icn">๐Ÿ“‹</div>
434
            <div class="txt">
435
                <div class="num">$!pendingCount</div>
436
                <div class="lbl">Pending</div>
437
            </div>
438
        </div>
439
        <div class="kpi i-stale">
440
            <div class="icn">โฐ</div>
441
            <div class="txt">
442
                <div class="num">$!staleCount</div>
443
                <div class="lbl">Pending &gt; 24h</div>
444
            </div>
445
        </div>
446
        <div class="kpi i-approved">
447
            <div class="icn">โœ“</div>
448
            <div class="txt">
449
                <div class="num">$!approvedTodayCount</div>
450
                <div class="lbl">Approved today</div>
451
            </div>
452
        </div>
453
        <div class="kpi i-rejected">
454
            <div class="icn">โœ•</div>
455
            <div class="txt">
456
                <div class="num">$!rejectedTodayCount</div>
457
                <div class="lbl">Rejected today</div>
458
            </div>
459
        </div>
460
        <div class="kpi i-excess">
461
            <div class="icn">๐Ÿ“ฆ</div>
462
            <div class="txt">
463
                <div class="num">+$!totalPendingExcess</div>
464
                <div class="lbl">Excess in queue</div>
465
            </div>
466
        </div>
467
    </div>
468
 
469
    <!-- Main list card -->
470
    <div class="card">
471
        <div class="card-hd">
472
            <div class="title">
473
                <h2>Approvals Queue</h2>
474
                <div class="sub">Click any row to review the flagged items and decide.</div>
475
            </div>
476
            <div class="tabs" id="psov2Tabs">
477
                <button type="button" class="tab on pending" data-filter="PENDING">Pending</button>
478
                <button type="button" class="tab" data-filter="APPROVED">Approved</button>
479
                <button type="button" class="tab" data-filter="REJECTED">Rejected</button>
480
                <button type="button" class="tab" data-filter="ALL">All</button>
481
            </div>
482
        </div>
483
 
484
        #if($!error)
485
            <div class="empty">
486
                <div class="ei">๐Ÿ”’</div>
487
                <b>$error</b></div>
488
        #else
489
            <div class="psov2-list" id="psov2Rows">
490
                #foreach($r in $rows)
491
                    <div class="psov2-row" data-status="$!r.status" data-poid="$!r.poId">
492
                        <div class="r-primary">
493
                            <div class="pn">$!r.poNumber</div>
494
                            <div class="rec">$!r.requesterName ยท $!r.createdAt</div>
495
                        </div>
496
                        <div class="r-vendor">
497
                            <div class="v">$!r.vendorName</div>
498
                            <div class="w">$!r.warehouseName</div>
499
                        </div>
500
                        <div class="r-flags">
501
                            <div class="fc">$!r.flaggedCount item#if($r.flaggedCount != 1)s#end flagged</div>
502
                            <div class="fx">+$!r.totalExcess excess</div>
503
                        </div>
504
                        <div class="r-reason#if($!r.requesterReason == '') empty#end" title="$!r.requesterReason">
505
                            #if($!r.requesterReason == '')No reason on file#else$!r.requesterReason#end
506
                        </div>
507
                        <div class="r-badge">
508
                            <span class="badge $!r.status">$!r.status</span>
509
                        </div>
510
                        <div class="r-actions">
511
                            <button type="button" class="icon-btn psov2-view" title="Review this approval">
512
                                <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
513
                                     stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
514
                                    <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
515
                                    <circle cx="12" cy="12" r="3"/>
516
                                </svg>
517
                            </button>
518
                        </div>
519
                    </div>
520
                #end
521
            </div>
522
            <div class="empty" id="psov2EmptyState" style="display:none">
523
                <div class="ei">๐Ÿ“ญ</div>
524
                <b>No approvals match this filter.</b>
525
                <div style="margin-top:4px">Try switching tabs above.</div>
526
            </div>
527
        #end
528
 
529
        <div class="rule-note">
530
            Rule: a PO is held here when <code>agingStock + poQty &gt; last-15-day sales</code>
531
            for any of its items. Requester must provide a reason before submission.
532
        </div>
533
    </div>
534
</div>
535
 
536
<!-- Detail modal (unchanged from v1) -->
537
<div class="modal fade" id="psoDetailModal" tabindex="-1" role="dialog" aria-hidden="true">
538
    <div class="modal-dialog modal-lg" role="document" style="width:90%;max-width:1100px;margin:30px auto">
539
        <div class="modal-content">
540
            <div class="modal-header" style="padding:12px 20px">
541
                <button type="button" class="close" data-dismiss="modal" aria-label="Close">
542
                    <span aria-hidden="true">&times;</span>
543
                </button>
544
                <h4 class="modal-title" style="margin:0">PO Stock Approval</h4>
545
            </div>
546
            <div class="modal-body" id="psoDetailBody"
547
                 style="max-height:calc(100vh - 140px);overflow-y:auto;padding:20px">
548
                <div style="text-align:center;padding:24px;color:#888">Loading&hellip;</div>
549
            </div>
550
        </div>
551
    </div>
552
</div>
553
 
554
<script type="text/javascript">
555
    (function () {
556
        var ctx = "${rc.contextPath}";
557
        var initialFilter = "$!initialFilter";
558
 
559
        // Uses .hide class (which has display:none) instead of inline style so it
560
        // beats display:grid without needing !important.
561
        function applyFilter(f) {
562
            var visible = 0;
563
            $("#psov2Rows .psov2-row").each(function () {
564
                var s = this.getAttribute("data-status");
565
                var show = (f === "ALL") || (s === f);
566
                if (show) {
567
                    this.classList.remove("hide");
568
                    visible++;
569
                } else {
570
                    this.classList.add("hide");
571
                }
572
            });
573
            $("#psov2EmptyState").toggle(visible === 0);
574
            $("#psov2Tabs .tab").removeClass("on pending rejected");
575
            var btn = $("#psov2Tabs .tab[data-filter='" + f + "']");
576
            btn.addClass("on");
577
            if (f === "PENDING") btn.addClass("pending");
578
            if (f === "REJECTED") btn.addClass("rejected");
579
        }
580
 
581
        applyFilter(initialFilter || "PENDING");
582
 
583
        // Off/on so reloads don't stack duplicate handlers.
584
        $("#psov2Tabs .tab").off("click.psov2tab").on("click.psov2tab", function () {
585
            applyFilter(this.getAttribute("data-filter"));
586
        });
587
 
588
        // Row click โ†’ open detail modal. Defensive guard: bail out if the row
589
        // is missing data-poid (would otherwise send /po/stock-approvals/undefined
590
        // and produce a NumberFormatException server-side).
591
        //
592
        // The nuclear off('click', '.psov2-row') purges ANY previously-bound
593
        // click handler on this selector โ€” including handlers from older template
594
        // versions still lingering in memory after a partial reload. Only THEN do
595
        // we register the current guarded version.
596
        $(document).off('click', '.psov2-row');
597
        $(document).off('click.psov2 click.pso').on('click.psov2', '.psov2-row', function (ev) {
598
            var poId = this.getAttribute('data-poid');
599
            if (!poId || poId === 'undefined' || poId === 'null' || isNaN(parseInt(poId, 10))) {
600
                console.warn('psov2: row missing valid data-poid, ignoring click', this);
601
                return;
602
            }
603
            // Coerce to integer explicitly โ€” belt-and-braces against string weirdness.
604
            poId = parseInt(poId, 10);
605
            $('#psoDetailBody').html('<div style="text-align:center;padding:24px;color:#888">Loading&hellip;</div>');
606
            $('#psoDetailModal').modal('show');
607
            $.ajax({
608
                url: ctx + '/po/stock-approvals/' + poId,
609
                type: 'GET',
610
                dataType: 'html',
611
                global: false,
612
                success: function (html) {
613
                    $('#psoDetailBody').html(html);
614
                },
615
                error: function () {
616
                    $('#psoDetailBody').html('<div style="text-align:center;padding:24px;color:#c00">Failed to load detail. Try again.</div>');
617
                }
618
            });
619
        });
620
 
621
        // After decide โ†’ close modal cleanly + reload the queue.
622
        $(document).off('pso:decided').on('pso:decided', function () {
623
            var reloaded = false;
624
            var doReload = function () {
625
                if (reloaded) return;
626
                reloaded = true;
627
                $('.modal-backdrop').remove();
628
                $('body').removeClass('modal-open').css('padding-right', '');
629
                // Full re-render โ€” KPI counts need re-computing after a decision.
630
                loadPoStockApprovals('main-content');
631
            };
632
            var modalEl = $('#psoDetailModal');
633
            modalEl.one('hidden.bs.modal', doReload);
634
            modalEl.modal('hide');
635
            setTimeout(doReload, 800);
636
        });
637
    })();
638
</script>