Subversion Repositories SmartDukaan

Rev

Details | Last modification | View Log | RSS feed

Rev Author Line No. Line
37763 ranu 1
## PO Stock Approval — detail fragment rendered inside the queue modal.
2
## No outer wrapper / breadcrumb — modal chrome provides those.
3
## POST /po/stock-approvals/{approvalId}/approve|reject with `reason` form param.
4
<style type="text/css">
5
  .pmsad {
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
    color: var(--ink);
20
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif
21
  }
22
 
23
  .pmsad .head {
24
    display: flex;
25
    justify-content: space-between;
26
    align-items: flex-start;
27
    padding: 4px 0 18px;
28
    border-bottom: 1px solid var(--line);
29
    margin-bottom: 18px
30
  }
31
 
32
  .pmsad .head .pn {
33
    font-size: 22px;
34
    font-weight: 800;
35
    color: var(--brand);
36
    letter-spacing: -.01em;
37
    line-height: 1.2;
38
    font-variant-numeric: tabular-nums
39
  }
40
 
41
  .pmsad .head .sub {
42
    font-size: 13px;
43
    color: var(--soft);
44
    margin-top: 4px;
45
    font-weight: 600
46
  }
47
 
48
  .pmsad .head .sub .sep {
49
    margin: 0 6px;
50
    opacity: .5
51
  }
52
 
53
  .pmsad .badge {
54
    display: inline-flex;
55
    align-items: center;
56
    padding: 6px 14px;
57
    border-radius: 20px;
58
    font-size: 11px;
59
    font-weight: 700;
60
    letter-spacing: .06em;
61
    text-transform: uppercase
62
  }
63
 
64
  .pmsad .badge.PENDING {
65
    background: var(--warn-bg);
66
    color: var(--warn)
67
  }
68
 
69
  .pmsad .badge.APPROVED {
70
    background: var(--good-bg);
71
    color: var(--good)
72
  }
73
 
74
  .pmsad .badge.REJECTED {
75
    background: var(--bad-bg);
76
    color: var(--bad)
77
  }
78
 
79
  /* Info tiles */
80
  .pmsad .tiles {
81
    display: grid;
82
    grid-template-columns: repeat(4, 1fr);
83
    gap: 12px;
84
    margin-bottom: 20px
85
  }
86
 
87
  .pmsad .tile {
88
    background: #fafbfe;
89
    border: 1px solid var(--line);
90
    border-radius: 10px;
91
    padding: 12px 14px
92
  }
93
 
94
  .pmsad .tile .lbl {
95
    font-size: 10.5px;
96
    font-weight: 700;
97
    color: var(--soft);
98
    letter-spacing: .05em;
99
    text-transform: uppercase;
100
    margin-bottom: 6px
101
  }
102
 
103
  .pmsad .tile .val {
104
    font-size: 15px;
105
    font-weight: 700;
106
    color: var(--ink);
107
    line-height: 1.2;
108
    font-variant-numeric: tabular-nums
109
  }
110
 
111
  .pmsad .tile.warn .val {
112
    color: var(--bad)
113
  }
114
 
115
  /* Requester reason card */
116
  .pmsad .reason-card {
117
    background: #fffbeb;
118
    border: 1px solid #fcd34d;
119
    border-radius: 10px;
120
    padding: 14px 18px;
121
    margin-bottom: 20px
122
  }
123
 
124
  .pmsad .reason-card .lbl {
125
    font-size: 11px;
126
    font-weight: 700;
127
    color: #92400e;
128
    letter-spacing: .05em;
129
    text-transform: uppercase;
130
    margin-bottom: 6px
131
  }
132
 
133
  .pmsad .reason-card .val {
134
    font-size: 14px;
135
    color: var(--ink);
136
    line-height: 1.5;
137
    font-style: italic
138
  }
139
 
140
  /* Flagged items section */
141
  .pmsad .section-hd {
142
    font-size: 14px;
143
    font-weight: 700;
144
    color: var(--ink);
145
    margin: 0 0 10px 0;
146
    letter-spacing: -.005em
147
  }
148
 
149
  .pmsad .section-hd .count {
150
    font-size: 11.5px;
151
    color: var(--soft);
152
    font-weight: 600;
153
    margin-left: 6px
154
  }
155
 
156
  .pmsad .items-tbl {
157
    width: 100%;
158
    border-collapse: separate;
159
    border-spacing: 0;
160
    border: 1px solid var(--line);
161
    border-radius: 10px;
162
    overflow: hidden;
163
    margin-bottom: 20px
164
  }
165
 
166
  .pmsad .items-tbl thead th {
167
    background: #fafbfe;
168
    padding: 10px 14px;
169
    font-size: 10.5px;
170
    font-weight: 700;
171
    color: var(--soft);
172
    letter-spacing: .05em;
173
    text-transform: uppercase;
174
    text-align: left;
175
    border-bottom: 1px solid var(--line)
176
  }
177
 
178
  .pmsad .items-tbl thead th.num {
179
    text-align: right
180
  }
181
 
182
  .pmsad .items-tbl thead th small {
183
    display: block;
184
    font-size: 9.5px;
185
    font-weight: 500;
186
    text-transform: none;
187
    letter-spacing: 0;
188
    margin-top: 2px;
189
    color: #94a3b8
190
  }
191
 
192
  .pmsad .items-tbl tbody td {
193
    padding: 12px 14px;
194
    font-size: 13.5px;
195
    color: var(--ink);
196
    border-bottom: 1px solid var(--line2);
197
    font-variant-numeric: tabular-nums
198
  }
199
 
200
  .pmsad .items-tbl tbody tr:last-child td {
201
    border-bottom: none
202
  }
203
 
204
  .pmsad .items-tbl tbody td.num {
205
    text-align: right
206
  }
207
 
208
  .pmsad .items-tbl tbody td.excess {
209
    color: var(--bad);
210
    font-weight: 700
211
  }
212
 
213
  /* Green when the item has NO aging stock at this vendor+warehouse — the item
214
     is flagged only because 15d sold = 0 (no movement) rather than an actual
215
     aging pile. Signals to the approver "this is a fresh SKU, not aging risk". */
216
  .pmsad .items-tbl tbody td.excess.excess-fresh {
217
    color: var(--good)
218
  }
219
 
220
  .pmsad .items-tbl tbody tr:hover td {
221
    background: #fafbfe
222
  }
223
 
224
  .pmsad .items-tbl .item-id {
225
    color: var(--soft);
226
    font-size: 11.5px;
227
    margin-left: 6px
228
  }
229
 
230
  /* Aging-elsewhere sub-row: shown when the PO's own vendor+warehouse has 0 aging
231
     but the item still has aging inventory at other (warehouse, vendor) buckets. */
232
  .pmsad .items-tbl tbody tr.pmsad-breakdown td {
233
    background: #fff9e6;
234
    color: #8a6d3b;
235
    font-size: 11.5px;
236
    padding: 6px 12px;
237
    border-top: 1px dashed #f1e0b0
238
  }
239
 
240
  .pmsad .items-tbl tbody tr.pmsad-breakdown td:first-child {
241
    padding-left: 32px
242
  }
243
 
244
  .pmsad .items-tbl tbody tr.pmsad-breakdown .pmsad-breakdown-lbl {
245
    color: #b78600;
246
    margin-right: 4px
247
  }
248
 
249
  /* Decide form (pending) */
250
  .pmsad .decide {
251
    background: #f7f8fb;
252
    border: 1px solid var(--line);
253
    border-radius: 10px;
254
    padding: 18px
255
  }
256
 
257
  .pmsad .decide .lbl {
258
    font-size: 11px;
259
    font-weight: 700;
260
    color: var(--soft);
261
    letter-spacing: .05em;
262
    text-transform: uppercase;
263
    margin-bottom: 8px
264
  }
265
 
266
  .pmsad .decide textarea {
267
    width: 100%;
268
    padding: 10px 12px;
269
    font-size: 13.5px;
270
    border: 1px solid var(--line);
271
    border-radius: 8px;
272
    background: #fff;
273
    color: var(--ink);
274
    resize: vertical;
275
    min-height: 80px;
276
    outline: none;
277
    transition: border-color .1s, box-shadow .1s;
278
    font-family: inherit
279
  }
280
 
281
  .pmsad .decide textarea:focus {
282
    border-color: var(--brand);
283
    box-shadow: 0 0 0 3px rgba(59, 91, 219, .12)
284
  }
285
 
286
  /* 50-char live counter under the textarea */
287
  .pmsad .pmsad-counter {
288
    font-size: 11.5px;
289
    color: var(--soft);
290
    margin-top: 6px;
291
    display: flex;
292
    gap: 8px;
293
    align-items: center
294
  }
295
 
296
  .pmsad .pmsad-counter #pmsadReasonCount {
297
    font-weight: 700;
298
    color: #c62828
299
  }
300
 
301
  .pmsad .pmsad-counter.pmsad-ok #pmsadReasonCount {
302
    color: #177a3a
303
  }
304
 
305
  .pmsad .pmsad-hint-short {
306
    color: #c62828;
307
    font-style: italic
308
  }
309
 
310
  .pmsad .pmsad-counter.pmsad-ok .pmsad-hint-short {
311
    display: none
312
  }
313
 
314
  .pmsad .pmsad-counter.pmsad-ok::after {
315
    content: "✓ ready";
316
    color: #177a3a;
317
    font-weight: 600
318
  }
319
 
320
  .pmsad .decide .actions {
321
    display: flex;
322
    gap: 10px;
323
    margin-top: 14px;
324
    align-items: center
325
  }
326
 
327
  .pmsad .btn {
328
    display: inline-flex;
329
    align-items: center;
330
    gap: 6px;
331
    padding: 8px 20px;
332
    border: 1px solid transparent;
333
    border-radius: 8px;
334
    font-size: 13px;
335
    font-weight: 650;
336
    cursor: pointer;
337
    transition: background .1s, transform .05s
338
  }
339
 
340
  .pmsad .btn:active {
341
    transform: translateY(1px)
342
  }
343
 
344
  .pmsad .btn:disabled {
345
    opacity: .5;
346
    cursor: not-allowed
347
  }
348
 
349
  .pmsad .btn-approve {
350
    background: var(--good);
351
    color: #fff;
352
    border-color: var(--good)
353
  }
354
 
355
  .pmsad .btn-approve:hover:not(:disabled) {
356
    background: #15803d;
357
    border-color: #15803d
358
  }
359
 
360
  .pmsad .btn-reject {
361
    background: #fff;
362
    color: var(--bad);
363
    border: 1px solid var(--bad)
364
  }
365
 
366
  .pmsad .btn-reject:hover:not(:disabled) {
367
    background: var(--bad-bg)
368
  }
369
 
370
  .pmsad .decide .status-txt {
371
    margin-left: auto;
372
    font-size: 12px;
373
    color: var(--soft);
374
    font-style: italic
375
  }
376
 
377
  /* Decision box (already decided) */
378
  .pmsad .decision {
379
    border-radius: 10px;
380
    padding: 18px 20px
381
  }
382
 
383
  .pmsad .decision.APPROVED {
384
    background: var(--good-bg);
385
    border: 1px solid var(--good)
386
  }
387
 
388
  .pmsad .decision.REJECTED {
389
    background: var(--bad-bg);
390
    border: 1px solid var(--bad)
391
  }
392
 
393
  .pmsad .decision .d-head {
394
    display: flex;
395
    align-items: center;
396
    gap: 10px;
397
    margin-bottom: 12px
398
  }
399
 
400
  .pmsad .decision .d-head .ico {
401
    width: 32px;
402
    height: 32px;
403
    border-radius: 50%;
404
    display: inline-flex;
405
    align-items: center;
406
    justify-content: center;
407
    font-size: 16px;
408
    font-weight: 800
409
  }
410
 
411
  .pmsad .decision.APPROVED .d-head .ico {
412
    background: var(--good);
413
    color: #fff
414
  }
415
 
416
  .pmsad .decision.REJECTED .d-head .ico {
417
    background: var(--bad);
418
    color: #fff
419
  }
420
 
421
  .pmsad .decision .d-head .d-title {
422
    font-size: 15px;
423
    font-weight: 800;
424
    color: var(--ink)
425
  }
426
 
427
  .pmsad .decision .d-head .d-title small {
428
    font-size: 11.5px;
429
    font-weight: 600;
430
    color: var(--soft);
431
    display: block;
432
    margin-top: 2px
433
  }
434
 
435
  .pmsad .decision .d-reason-lbl {
436
    font-size: 10.5px;
437
    font-weight: 700;
438
    color: var(--soft);
439
    letter-spacing: .05em;
440
    text-transform: uppercase;
441
    margin-bottom: 4px
442
  }
443
 
444
  .pmsad .decision .d-reason {
445
    font-size: 13.5px;
446
    color: var(--ink);
447
    line-height: 1.5;
448
    font-style: italic
449
  }
450
 
451
  .pmsad .alert-err {
452
    background: var(--warn-bg);
453
    border: 1px solid #fcd34d;
454
    color: #92400e;
455
    padding: 12px 16px;
456
    border-radius: 10px;
457
    font-size: 13px
458
  }
459
 
460
  @media (max-width: 900px) {
461
    .pmsad .tiles {
462
      grid-template-columns: repeat(2, 1fr)
463
    }
464
  }
465
</style>
466
 
467
<div class="pmsad">
468
  #if($!error)
469
    <div class="alert-err">$error</div>
470
  #else
471
    <!-- Header -->
472
    <div class="head">
473
      <div>
474
        <div class="pn">$!po.getPoNumber()</div>
475
        <div class="sub">
476
          #if($!supplier)$supplier.getName()#else-#end
477
          <span class="sep">·</span>
478
          $!warehouseName
479
        </div>
480
      </div>
481
      <div>
482
        <span class="badge $!approval.getStatus()">$!approval.getStatus()</span>
483
      </div>
484
    </div>
485
 
486
    <!-- Info tiles -->
487
    <div class="tiles">
488
      <div class="tile">
489
        <div class="lbl">PO Date</div>
490
        <div class="val">#if($!po.getCreatedAt())$po.getCreatedAt().toLocalDate()#else-#end</div>
491
      </div>
492
      <div class="tile">
493
        <div class="lbl">Total Cost</div>
494
        <div class="val">₹#if($!po.getTotalCost())$po.getTotalCost()#else-#end</div>
495
      </div>
496
      <div class="tile">
497
        <div class="lbl">Requester</div>
498
        <div class="val">$!requesterName</div>
499
      </div>
500
      <div class="tile warn">
501
        <div class="lbl">Total Excess</div>
502
        <div class="val">+$!totalExcess units</div>
503
      </div>
504
    </div>
505
 
506
    <!-- Requester reason -->
507
    <div class="reason-card">
508
      <div class="lbl">Requester's reason for buying aging stock</div>
509
      <div class="val">$!approval.getRequesterReason()</div>
510
    </div>
511
 
512
    <!-- Flagged items -->
513
    <div class="section-hd">
514
      Flagged Items <span class="count">($!flagged.size())</span>
515
    </div>
516
    <table class="items-tbl">
517
      <thead>
518
      <tr>
519
        <th>Item</th>
520
        <th class="num">Aging Stock<small>vendor + warehouse</small></th>
521
        <th class="num">15d Sold<small>warehouse</small></th>
522
        <th class="num">PO Qty</th>
523
        <th class="num">Excess</th>
524
      </tr>
525
      </thead>
526
      <tbody>
527
        #foreach($f in $flagged)
528
          ## Excess turns GREEN only when the item has NO aging anywhere in the network —
529
          ## neither at this vendor+warehouse (agingQty > 0) nor elsewhere (agingBreakdown
530
          ## non-empty). "Aging elsewhere" is still an aging concern, just at a different
531
          ## depot, so those rows must render RED.
532
          #set($hasAgingHere = ($f.get("agingQty") != 0))
533
          #set($hasAgingElsewhere = ($f.get("agingBreakdown") && $f.get("agingBreakdown").size() > 0))
534
          #set($isFresh = !$hasAgingHere && !$hasAgingElsewhere)
535
        <tr>
536
          <td>$!f.get("modelName")<span class="item-id">#$!f.get("itemId")</span></td>
537
          <td class="num">$!f.get("agingQty")</td>
538
          <td class="num">$!f.get("sold15dQty")</td>
539
          <td class="num">$!f.get("poQty")</td>
540
          #if($isFresh)
541
            <td class="num excess excess-fresh">+$!f.get("excess")</td>
542
          #else
543
            <td class="num excess">+$!f.get("excess")</td>
544
          #end
545
        </tr>
546
          #if($f.get("agingQty") == 0 && $f.get("agingBreakdown") && $f.get("agingBreakdown").size() > 0)
547
            #foreach($b in $f.get("agingBreakdown"))
548
              ## Compact label: first segment of warehouse displayName + first word of vendor.
549
              ## Vendor-owned warehouses have names like "DL-NSSPL/Delhi/BSB Marketing/G";
550
              ## we only want the region-code head. Vendor "BSB Marketing Private Limited"
551
              ## collapses to "BSB". Falls back to raw values if either is blank / missing.
552
              #set($whFull = $!b.get("warehouseName"))
553
              #set($vFull  = $!b.get("vendorName"))
554
              #set($whShort = $whFull)
555
              #if($whFull && $whFull.contains("/"))
556
                #set($whShort = $whFull.split("/")[0])
557
              #end
558
              #set($vShort = $vFull)
559
              #if($vFull && $vFull.contains(" "))
560
                #set($vShort = $vFull.split(" ")[0])
561
              #end
562
            <tr class="pmsad-breakdown">
563
              <td>
564
                <span class="pmsad-breakdown-lbl">&#8627; aging elsewhere:</span>
565
                $whShort - ($vShort)
566
              </td>
567
              <td class="num">$!b.get("qty")</td>
568
              <td colspan="3"></td>
569
            </tr>
570
            #end
571
          #end
572
        #end
573
      </tbody>
574
    </table>
575
 
576
    <!-- Decide (pending) OR Decision (already decided) -->
577
    #if($!isPending && $!canDecide)
578
      <div class="decide">
579
        <div class="lbl">Decision Reason (mandatory · min 50 characters)</div>
580
        <textarea id="decisionReason"
581
                  placeholder="Explain in detail why you're approving or rejecting this PO. Minimum 50 characters."></textarea>
582
        <div class="pmsad-counter">
583
          <span id="pmsadReasonCount">0</span> / 50 characters minimum
584
          <span id="pmsadReasonHint" class="pmsad-hint-short">— need more detail</span>
585
        </div>
586
        <div class="actions">
587
          <button type="button" class="btn btn-approve" id="btnApprove" disabled
588
                  data-approval-id="$approval.getId()">
589
            <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"
590
                 stroke-linecap="round" stroke-linejoin="round">
591
              <polyline points="20 6 9 17 4 12"/>
592
            </svg>
593
            Approve
594
          </button>
595
          <button type="button" class="btn btn-reject" id="btnReject" disabled
596
                  data-approval-id="$approval.getId()">
597
            <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"
598
                 stroke-linecap="round" stroke-linejoin="round">
599
              <line x1="18" y1="6" x2="6" y2="18"/>
600
              <line x1="6" y1="6" x2="18" y2="18"/>
601
            </svg>
602
            Reject
603
          </button>
604
          <span class="status-txt" id="decisionStatus"></span>
605
        </div>
606
      </div>
607
    #else
608
      <div class="decision $!approval.getStatus()">
609
        <div class="d-head">
610
          <div class="ico">
611
            #if($!approval.getStatus().toString() == 'APPROVED')✓#else✕#end
612
          </div>
613
          <div class="d-title">
614
            $!approval.getStatus() by $!approval.getDecidedBy()
615
            <small>#if($!approval.getDecidedAt())$approval.getDecidedAt()#end</small>
616
          </div>
617
        </div>
618
        <div class="d-reason-lbl">Reason</div>
619
        <div class="d-reason">$!approval.getDecisionReason()</div>
620
      </div>
621
    #end
622
  #end
623
</div>
624
 
625
#if($!isPending && $!canDecide)
626
<script type="text/javascript">
627
  (function () {
628
    var ctx = "${rc.contextPath}";
629
    var MIN_REASON = 50;
630
 
631
    // Live 50-char counter — gates the approve/reject buttons.
632
    var ta = document.getElementById("decisionReason");
633
    var counterBox = ta ? ta.parentNode.querySelector(".pmsad-counter") : null;
634
    var countEl = document.getElementById("pmsadReasonCount");
635
    var btnA = document.getElementById("btnApprove");
636
    var btnR = document.getElementById("btnReject");
637
 
638
    function refreshCounter() {
639
      var len = ta.value.trim().length;
640
      if (countEl) countEl.textContent = len;
641
      var ok = len >= MIN_REASON;
642
      if (counterBox) counterBox.classList.toggle("pmsad-ok", ok);
643
      if (btnA) btnA.disabled = !ok;
644
      if (btnR) btnR.disabled = !ok;
645
    }
646
 
647
    if (ta) {
648
      ta.addEventListener("input", refreshCounter);
649
      ta.addEventListener("change", refreshCounter);
650
      refreshCounter();
651
    }
652
 
653
    function decide(action, approvalId) {
654
      var reason = ta.value.trim();
655
      if (reason.length < MIN_REASON) {
656
        alert("Reason needs at least " + MIN_REASON + " characters (currently " + reason.length + ").");
657
        return;
658
      }
659
      if (!confirm("Are you sure you want to " + action + " this PO?")) return;
660
 
661
      btnA.disabled = true;
662
      btnR.disabled = true;
663
      document.getElementById("decisionStatus").innerText = "Submitting…";
664
 
665
      var form = new URLSearchParams();
666
      form.append("reason", reason);
667
 
668
      fetch(ctx + "/po/stock-approvals/" + approvalId + "/" + action, {
669
        method: "POST",
670
        credentials: "same-origin",
671
        headers: {"Content-Type": "application/x-www-form-urlencoded"},
672
        body: form.toString()
673
      }).then(function (r) {
674
        return r.text();
675
      })
676
              .then(function (t) {
677
                document.getElementById("decisionStatus").innerText = "";
678
                alert("PO " + action + "d successfully.");
679
                if (window.jQuery && jQuery('#psoDetailModal').length) {
680
                  jQuery(document).trigger('pso:decided');
681
                } else {
682
                  window.location = ctx + "/po/stock-approvals";
683
                }
684
              })
685
              .catch(function (e) {
686
                document.getElementById("btnApprove").disabled = false;
687
                document.getElementById("btnReject").disabled = false;
688
                document.getElementById("decisionStatus").innerText = "Failed — try again";
689
                alert("Failed: " + (e && e.message ? e.message : "network error"));
690
              });
691
    }
692
 
693
    document.getElementById("btnApprove").addEventListener("click", function () {
694
      decide("approve", this.getAttribute("data-approval-id"));
695
    });
696
    document.getElementById("btnReject").addEventListener("click", function () {
697
      decide("reject", this.getAttribute("data-approval-id"));
698
    });
699
  })();
700
</script>
701
#end