Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fca550f4c4 | ||
|
|
3e5cc26e49 | ||
|
|
c3713f8a90 | ||
|
|
f804d356d7 | ||
|
|
1daa8e60a0 | ||
|
|
55f5c1cf32 | ||
|
|
6507e68203 | ||
|
|
4d205672df | ||
|
|
ab8ed7e67e | ||
|
|
7ee230e58a | ||
|
|
7f3c97a205 |
@@ -0,0 +1,229 @@
|
|||||||
|
<!--
|
||||||
|
Copyright since 2025 Mifos Initiative
|
||||||
|
|
||||||
|
This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<div class="cv-tab-content cta-tab">
|
||||||
|
<!-- ── TITLE BAR ── -->
|
||||||
|
<div class="cta-action-bar">
|
||||||
|
<div class="cta-action-bar__left">
|
||||||
|
<mat-icon class="cta-action-bar__icon">swap_horiz</mat-icon>
|
||||||
|
<div>
|
||||||
|
<div class="cta-action-bar__title">Cash Transfers</div>
|
||||||
|
<div class="cta-action-bar__sub">
|
||||||
|
Approve or reject an allocation of cash from the vault to a cashier. The cash leaves the vault only when it is
|
||||||
|
approved.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="cta-counts">
|
||||||
|
<span class="cta-count"
|
||||||
|
><strong>{{ all.length }}</strong> waiting</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── FILTER BAR ── -->
|
||||||
|
<div class="cta-filter-bar">
|
||||||
|
<div class="cta-search-box">
|
||||||
|
<mat-icon>search</mat-icon>
|
||||||
|
<input type="text" placeholder="Search by cashier, vault, user or note" [(ngModel)]="searchTerm" />
|
||||||
|
@if (searchTerm) {
|
||||||
|
<button type="button" class="cta-search-box__clear" (click)="searchTerm = ''">
|
||||||
|
<mat-icon>close</mat-icon>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<button type="button" class="cta-btn cta-btn--refresh" (click)="load()" [disabled]="loading">
|
||||||
|
<mat-icon>refresh</mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── TABLE ── -->
|
||||||
|
@if (loading && !loaded) {
|
||||||
|
<div class="cta-empty"><p class="cta-empty__title">Loading…</p></div>
|
||||||
|
} @else if (rows.length === 0) {
|
||||||
|
<div class="cta-empty">
|
||||||
|
<mat-icon class="cta-empty__icon">inbox</mat-icon>
|
||||||
|
<p class="cta-empty__title">No cash transfer waiting</p>
|
||||||
|
<p class="cta-empty__text">There is no cash allocation waiting for approval.</p>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="cta-table-wrap">
|
||||||
|
<table class="cta-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>From</th>
|
||||||
|
<th>To (Cashier)</th>
|
||||||
|
<th class="num">Amount</th>
|
||||||
|
<th>Note</th>
|
||||||
|
<th>Raised By</th>
|
||||||
|
<th>Raised At</th>
|
||||||
|
<th class="cta-th-action">Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (r of rows; track r.id) {
|
||||||
|
<tr class="cta-row">
|
||||||
|
<td class="cta-dt">{{ r.transfer_date || '—' }}</td>
|
||||||
|
<td>{{ r.from_party || '—' }}</td>
|
||||||
|
<td class="cta-strong">{{ r.to_party || '—' }}</td>
|
||||||
|
<td class="num cta-amount">₹{{ r.total_amount | number: '1.2-2' }}</td>
|
||||||
|
<td class="cta-reason" [title]="r.note || ''">{{ r.note || '—' }}</td>
|
||||||
|
<td>{{ r.maker_name || '—' }}</td>
|
||||||
|
<td class="cta-dt">{{ formatDateTime(r.maker_at) }}</td>
|
||||||
|
<td class="cta-action-cell">
|
||||||
|
<button type="button" class="cta-btn cta-btn--approve" (click)="review(r)">
|
||||||
|
<mat-icon>rate_review</mat-icon> Review
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ─── Popup: review a request, every note and coin it asks for ───────────── -->
|
||||||
|
<ng-template #reviewDialog>
|
||||||
|
@if (reviewing) {
|
||||||
|
<div class="cta-dlg">
|
||||||
|
<header class="cta-dlg__head">
|
||||||
|
<mat-icon class="cta-dlg__ico cta-dlg__ico--ok">swap_horiz</mat-icon>
|
||||||
|
<div class="cta-dlg__titles">
|
||||||
|
<div class="cta-dlg__title">Review cash transfer</div>
|
||||||
|
<div class="cta-dlg__sub">
|
||||||
|
Raised by {{ reviewing.maker_name || '—' }} · {{ formatDateTime(reviewing.maker_at) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="cta-dlg__x" aria-label="Close" (click)="closeReview()">
|
||||||
|
<mat-icon>close</mat-icon>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="cta-dlg__body">
|
||||||
|
<div class="cta-total">
|
||||||
|
<span>Amount to release</span>
|
||||||
|
<strong>₹{{ reviewing.total_amount | number: '1.2-2' }}</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cta-grid">
|
||||||
|
<div class="cta-cell">
|
||||||
|
<div class="cta-label">Transfer date</div>
|
||||||
|
<div class="cta-value">{{ reviewing.transfer_date || '—' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="cta-cell">
|
||||||
|
<div class="cta-label">From</div>
|
||||||
|
<div class="cta-value">{{ reviewing.from_party || '—' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="cta-cell">
|
||||||
|
<div class="cta-label">To (cashier)</div>
|
||||||
|
<div class="cta-value">{{ reviewing.to_party || '—' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="cta-cell">
|
||||||
|
<div class="cta-label">Notes and coins</div>
|
||||||
|
<div class="cta-value">
|
||||||
|
{{ piecesOf(reviewing) }} pieces · {{ linesOf(reviewing).length }} denominations
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if (reviewing.note) {
|
||||||
|
<div class="cta-cell cta-cell--wide">
|
||||||
|
<div class="cta-label">Note</div>
|
||||||
|
<div class="cta-value">{{ reviewing.note }}</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cta-label">Denomination breakdown</div>
|
||||||
|
<table class="cta-mini">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Denomination</th>
|
||||||
|
<th class="num">Count</th>
|
||||||
|
<th class="num">Amount</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (l of linesOf(reviewing); track l.value) {
|
||||||
|
<tr>
|
||||||
|
<td>₹{{ l.value }}</td>
|
||||||
|
<td class="num">{{ l.count }}</td>
|
||||||
|
<td class="num">₹{{ l.amount | number: '1.2-2' }}</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr>
|
||||||
|
<td>Total</td>
|
||||||
|
<td class="num">{{ piecesOf(reviewing) }}</td>
|
||||||
|
<td class="num">₹{{ reviewing.total_amount | number: '1.2-2' }}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="cta-dlg__foot">
|
||||||
|
<button type="button" class="cta-btn cta-btn--ghost" (click)="closeReview()">Close</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cta-btn cta-btn--reject"
|
||||||
|
[disabled]="busyId === reviewing.id"
|
||||||
|
(click)="rejectFromReview(reviewing)"
|
||||||
|
>
|
||||||
|
<mat-icon>close</mat-icon> Reject
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cta-btn cta-btn--approve"
|
||||||
|
[disabled]="busyId === reviewing.id"
|
||||||
|
(click)="approve(reviewing)"
|
||||||
|
>
|
||||||
|
<mat-icon>check</mat-icon> Approve
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
|
<!-- ─── Popup: reject with a reason ───────────────────────────────────────── -->
|
||||||
|
<ng-template #rejectDialog>
|
||||||
|
@if (rejecting) {
|
||||||
|
<div class="cta-dlg">
|
||||||
|
<header class="cta-dlg__head">
|
||||||
|
<mat-icon class="cta-dlg__ico">block</mat-icon>
|
||||||
|
<div class="cta-dlg__titles">
|
||||||
|
<div class="cta-dlg__title">Reject cash transfer</div>
|
||||||
|
<div class="cta-dlg__sub">₹{{ rejecting.total_amount | number: '1.2-2' }} to {{ rejecting.to_party }}</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="cta-dlg__x" aria-label="Close" (click)="cancelReject()">
|
||||||
|
<mat-icon>close</mat-icon>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div class="cta-dlg__body">
|
||||||
|
<label class="cta-label" for="cta-reason">Reason (required)</label>
|
||||||
|
<textarea
|
||||||
|
id="cta-reason"
|
||||||
|
rows="3"
|
||||||
|
placeholder="Why is this being rejected?"
|
||||||
|
[(ngModel)]="rejectReason"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
<footer class="cta-dlg__foot">
|
||||||
|
<button type="button" class="cta-btn cta-btn--ghost" (click)="cancelReject()">Cancel</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cta-btn cta-btn--reject-solid"
|
||||||
|
[disabled]="!rejectReason.trim() || busyId === rejecting.id"
|
||||||
|
(click)="confirmReject()"
|
||||||
|
>
|
||||||
|
Confirm reject
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</ng-template>
|
||||||
@@ -0,0 +1,573 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Laid out like the other approval tabs (Withdrawal Requests): title bar, filter bar, one table.
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-tab {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-action-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
|
||||||
|
&__left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
font-size: 22px;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
color: #0f4f3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0d1b2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__sub {
|
||||||
|
max-width: 640px;
|
||||||
|
margin-top: 1px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-counts {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-count {
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #475569;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: #0f4f3c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-filter-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 6px 16px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-fb-sep {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 1px;
|
||||||
|
height: 22px;
|
||||||
|
margin: 0 4px;
|
||||||
|
background: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-search-box {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
height: 30px;
|
||||||
|
min-width: 240px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 17px;
|
||||||
|
width: 17px;
|
||||||
|
height: 17px;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-within {
|
||||||
|
border-color: #0f4f3c;
|
||||||
|
box-shadow: 0 0 0 3px rgb(15 79 60 / 8%);
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
flex: 1;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #0d1b2a;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__clear {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: none;
|
||||||
|
color: #94a3b8;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 15px;
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #dc2626;
|
||||||
|
background: #fef2f2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-filter-select {
|
||||||
|
height: 30px;
|
||||||
|
min-width: 140px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: #fff;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #0d1b2a;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #0f4f3c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Buttons ─────────────────────────────────────────────────────────────────
|
||||||
|
.cta-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
height: 26px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 7px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--refresh {
|
||||||
|
height: 30px;
|
||||||
|
width: 30px;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
border-color: #e2e8f0;
|
||||||
|
background: #fff;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--approve {
|
||||||
|
background: #0f4f3c;
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: #0a3d2e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--reject {
|
||||||
|
border-color: #fecaca;
|
||||||
|
background: #fff;
|
||||||
|
color: #dc2626;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: #fef2f2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--reject-solid {
|
||||||
|
background: #dc2626;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--ghost {
|
||||||
|
border-color: #e2e8f0;
|
||||||
|
background: #fff;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Table ───────────────────────────────────────────────────────────────────
|
||||||
|
.cta-table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 11px;
|
||||||
|
|
||||||
|
th {
|
||||||
|
padding: 7px 10px;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
background: #f8fafc;
|
||||||
|
font-size: 9.5px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-align: left;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #94a3b8;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-bottom: 1px solid #eef2f7;
|
||||||
|
vertical-align: middle;
|
||||||
|
color: #0d1b2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.num {
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-th-action {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-row:hover td {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-mono {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-voucher {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f4f3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-strong {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-sub {
|
||||||
|
margin-top: 1px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-none {
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-amount {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-reason {
|
||||||
|
max-width: 220px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-dt {
|
||||||
|
white-space: nowrap;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-kind {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
|
||||||
|
&--unpass {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--correction {
|
||||||
|
background: #dbeafe;
|
||||||
|
color: #1e40af;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-action-cell {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Empty ───────────────────────────────────────────────────────────────────
|
||||||
|
.cta-empty {
|
||||||
|
padding: 28px 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: #94a3b8;
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto 6px;
|
||||||
|
font-size: 30px;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__text {
|
||||||
|
margin: 3px 0 0;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Reject popup ────────────────────────────────────────────────────────────
|
||||||
|
.cta-dlg {
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
&__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__ico {
|
||||||
|
flex: 0 0 32px;
|
||||||
|
height: 32px;
|
||||||
|
width: 32px;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #dc2626;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 32px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__titles {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__x {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 28px;
|
||||||
|
width: 28px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: transparent;
|
||||||
|
color: #64748b;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
height: 18px;
|
||||||
|
width: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__body {
|
||||||
|
padding: 12px 14px;
|
||||||
|
max-height: 64vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 8px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
resize: vertical;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: 0;
|
||||||
|
border-color: #0f4f3c;
|
||||||
|
box-shadow: 0 0 0 3px rgb(15 79 60 / 8%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__foot {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-top: 1px solid #e2e8f0;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-dlg__ico--ok {
|
||||||
|
background: #f0fdf4;
|
||||||
|
color: #1f7a4d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-total {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #ecfdf5;
|
||||||
|
border: 1px solid #a7f3d0;
|
||||||
|
color: #065f46;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 800;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-cell {
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #eef2f7;
|
||||||
|
|
||||||
|
&--wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-value {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #0f172a;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-mini {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 4px;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
th {
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
background: #f8fafc;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-align: left;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-bottom: 1px solid #eef2f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.num {
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
tfoot td {
|
||||||
|
border-top: 2px solid #e2e8f0;
|
||||||
|
border-bottom: 0;
|
||||||
|
font-weight: 800;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, OnInit, TemplateRef, ViewChild, inject } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
import { catchError } from 'rxjs/operators';
|
||||||
|
import { CashierService } from '../cashier.service';
|
||||||
|
|
||||||
|
/** A cash transfer request waiting for a checker, as the server keeps it (snake_case columns). */
|
||||||
|
interface CashTransferRequest {
|
||||||
|
id: number;
|
||||||
|
transfer_date: string | null;
|
||||||
|
from_party: string | null;
|
||||||
|
to_party: string | null;
|
||||||
|
note: string | null;
|
||||||
|
total_amount: number;
|
||||||
|
maker_name: string | null;
|
||||||
|
maker_at: string | null;
|
||||||
|
denominations: { denominationValue: number; count: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checker side of Cash Transfer: approves or rejects an allocation of cash from the vault to a cashier. The cash
|
||||||
|
* leaves the vault only when it is approved, by someone other than the person who raised it.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-cash-transfer-approvals',
|
||||||
|
templateUrl: './cash-transfer-approvals.component.html',
|
||||||
|
styleUrls: ['./cash-transfer-approvals.component.scss'],
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
MatDialogModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatSnackBarModule
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class CashTransferApprovalsComponent implements OnInit {
|
||||||
|
private service = inject(CashierService);
|
||||||
|
private snackBar = inject(MatSnackBar);
|
||||||
|
private dialog = inject(MatDialog);
|
||||||
|
|
||||||
|
@ViewChild('rejectDialog') rejectDialog!: TemplateRef<unknown>;
|
||||||
|
@ViewChild('reviewDialog') reviewDialog!: TemplateRef<unknown>;
|
||||||
|
private dialogRef: MatDialogRef<unknown> | null = null;
|
||||||
|
private reviewRef: MatDialogRef<unknown> | null = null;
|
||||||
|
|
||||||
|
/** The request open in the review popup, with every note and coin it asks for. */
|
||||||
|
reviewing: CashTransferRequest | null = null;
|
||||||
|
|
||||||
|
all: CashTransferRequest[] = [];
|
||||||
|
loading = false;
|
||||||
|
loaded = false;
|
||||||
|
busyId: number | null = null;
|
||||||
|
searchTerm = '';
|
||||||
|
|
||||||
|
rejecting: CashTransferRequest | null = null;
|
||||||
|
rejectReason = '';
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
load(): void {
|
||||||
|
this.loading = true;
|
||||||
|
this.service
|
||||||
|
.getCashTransferRequests('PENDING')
|
||||||
|
.pipe(catchError(() => of([] as CashTransferRequest[])))
|
||||||
|
.subscribe((rows) => {
|
||||||
|
this.all = rows as CashTransferRequest[];
|
||||||
|
this.loading = false;
|
||||||
|
this.loaded = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get rows(): CashTransferRequest[] {
|
||||||
|
const term = this.searchTerm.trim().toLowerCase();
|
||||||
|
if (!term) return this.all;
|
||||||
|
return this.all.filter((r) => [
|
||||||
|
r.from_party,
|
||||||
|
r.to_party,
|
||||||
|
r.maker_name,
|
||||||
|
r.note
|
||||||
|
].some((v) => (v ?? '').toLowerCase().includes(term)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One line per denomination asked for, largest first, with what each line adds up to. */
|
||||||
|
linesOf(r: CashTransferRequest): { value: number; count: number; amount: number }[] {
|
||||||
|
return [...(r.denominations ?? [])]
|
||||||
|
.filter((d) => d.count > 0)
|
||||||
|
.sort((a, b) => b.denominationValue - a.denominationValue)
|
||||||
|
.map((d) => ({ value: d.denominationValue, count: d.count, amount: d.denominationValue * d.count }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How many notes and coins the request asks for in all. */
|
||||||
|
piecesOf(r: CashTransferRequest): number {
|
||||||
|
return this.linesOf(r).reduce((sum, l) => sum + l.count, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
review(r: CashTransferRequest): void {
|
||||||
|
this.reviewing = r;
|
||||||
|
this.reviewRef?.close();
|
||||||
|
this.reviewRef = this.dialog.open(this.reviewDialog, { width: '560px', maxWidth: '95vw', autoFocus: false });
|
||||||
|
this.reviewRef.afterClosed().subscribe(() => (this.reviewing = null));
|
||||||
|
}
|
||||||
|
|
||||||
|
closeReview(): void {
|
||||||
|
this.reviewRef?.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rejecting from the review popup: the review closes, the reason popup opens for the same request. */
|
||||||
|
rejectFromReview(r: CashTransferRequest): void {
|
||||||
|
this.reviewRef?.close();
|
||||||
|
this.startReject(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDateTime(value: string | null): string {
|
||||||
|
if (!value) return '—';
|
||||||
|
const d = new Date(value);
|
||||||
|
return isNaN(d.getTime())
|
||||||
|
? value
|
||||||
|
: d.toLocaleString('en-IN', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
approve(r: CashTransferRequest): void {
|
||||||
|
if (this.busyId) return;
|
||||||
|
this.busyId = r.id;
|
||||||
|
this.service.approveCashTransferRequest(r.id).subscribe({
|
||||||
|
next: (res: any) => {
|
||||||
|
this.busyId = null;
|
||||||
|
this.reviewRef?.close();
|
||||||
|
this.snackBar.open(
|
||||||
|
res?.glWarning ? `Approved (GL warning: ${res.glWarning})` : 'Approved — cash allocated to the cashier',
|
||||||
|
'Close',
|
||||||
|
{ duration: 6000 }
|
||||||
|
);
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.busyId = null;
|
||||||
|
this.fail(err, 'Could not approve');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
startReject(r: CashTransferRequest): void {
|
||||||
|
this.rejecting = r;
|
||||||
|
this.rejectReason = '';
|
||||||
|
this.dialogRef = this.dialog.open(this.rejectDialog, { width: '440px', maxWidth: '95vw', autoFocus: false });
|
||||||
|
this.dialogRef.afterClosed().subscribe(() => (this.rejecting = null));
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelReject(): void {
|
||||||
|
this.dialogRef?.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmReject(): void {
|
||||||
|
if (!this.rejecting || !this.rejectReason.trim() || this.busyId) return;
|
||||||
|
const id = this.rejecting.id;
|
||||||
|
this.busyId = id;
|
||||||
|
this.service.rejectCashTransferRequest(id, this.rejectReason.trim()).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.busyId = null;
|
||||||
|
this.dialogRef?.close();
|
||||||
|
this.snackBar.open('Rejected', 'Close', { duration: 4000 });
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.busyId = null;
|
||||||
|
this.fail(err, 'Could not reject');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private fail(err: any, fallback: string): void {
|
||||||
|
const msg = err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.defaultUserMessage ?? fallback;
|
||||||
|
this.snackBar.open(msg, 'Close', { duration: 8000 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,15 @@
|
|||||||
<h1 class="pg-title">Cash Transfer</h1>
|
<h1 class="pg-title">Cash Transfer</h1>
|
||||||
<p class="pg-sub">Move cash denomination-wise from one party to another (e.g. Vault → Cashier for opening).</p>
|
<p class="pg-sub">Move cash denomination-wise from one party to another (e.g. Vault → Cashier for opening).</p>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="pg-btn pg-btn--ghost" (click)="clear()"><mat-icon>undo</mat-icon> Clear</button>
|
<div class="ct-head-actions">
|
||||||
|
<button type="button" class="pg-btn pg-btn--ghost" (click)="openRequests()">
|
||||||
|
<mat-icon>pending_actions</mat-icon> My Requests
|
||||||
|
@if (pendingCount) {
|
||||||
|
<span class="ct-badge">{{ pendingCount }}</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="pg-btn pg-btn--ghost" (click)="clear()"><mat-icon>undo</mat-icon> Clear</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="pg-toolbar pg-toolbar--mat">
|
<div class="pg-toolbar pg-toolbar--mat">
|
||||||
@@ -155,7 +163,56 @@
|
|||||||
<div class="pg-total-bar">
|
<div class="pg-total-bar">
|
||||||
Total to transfer: <strong>₹{{ total | number: '1.2-2' }}</strong>
|
Total to transfer: <strong>₹{{ total | number: '1.2-2' }}</strong>
|
||||||
<button type="button" class="pg-btn pg-btn--primary" [disabled]="!valid || saving" (click)="submit()">
|
<button type="button" class="pg-btn pg-btn--primary" [disabled]="!valid || saving" (click)="submit()">
|
||||||
<mat-icon>swap_horiz</mat-icon> Record Transfer
|
<mat-icon>send</mat-icon> Send for approval
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Popup: this person's own cash transfer requests, and where each stands. The cash moves only once a manager approves. -->
|
||||||
|
<ng-template #requestsDialog>
|
||||||
|
<div class="ct-dlg">
|
||||||
|
<header class="ct-dlg__head">
|
||||||
|
<mat-icon class="ct-dlg__ico">pending_actions</mat-icon>
|
||||||
|
<div class="ct-dlg__titles">
|
||||||
|
<div class="ct-dlg__title">My transfer requests</div>
|
||||||
|
<div class="ct-dlg__sub">Cash leaves the vault only after a manager approves</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="ct-dlg__x" aria-label="Close" (click)="closeRequests()">
|
||||||
|
<mat-icon>close</mat-icon>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div class="ct-dlg__body">
|
||||||
|
@if (myRequests.length) {
|
||||||
|
<table class="pg-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>To</th>
|
||||||
|
<th class="num">Amount</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Approved by</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (r of myRequests; track r.id) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ r.transfer_date || '—' }}</td>
|
||||||
|
<td>{{ r.to_party || '—' }}</td>
|
||||||
|
<td class="num">₹{{ r.total_amount | number: '1.2-2' }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="ct-status" [class]="'ct-status ct-status--' + r.status">{{ r.status }}</span>
|
||||||
|
@if (r.rejection_reason) {
|
||||||
|
<div class="ct-requests__note">{{ r.rejection_reason }}</div>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>{{ r.checker_name || '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
} @else {
|
||||||
|
<div class="pg-empty">You have not raised any cash transfer request yet.</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
|||||||
@@ -49,3 +49,131 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #991b1b;
|
color: #991b1b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ct-requests {
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0d1b2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__note {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-status {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #e2e8f0;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
|
||||||
|
&--PENDING {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--APPROVED {
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--REJECTED {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-head-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-badge {
|
||||||
|
min-width: 18px;
|
||||||
|
padding: 0 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f59e0b;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct-dlg {
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
&__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__ico {
|
||||||
|
flex: 0 0 32px;
|
||||||
|
height: 32px;
|
||||||
|
width: 32px;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #f0fdf4;
|
||||||
|
color: #1f7a4d;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 32px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__titles {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__x {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 28px;
|
||||||
|
width: 28px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: transparent;
|
||||||
|
color: #64748b;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
height: 18px;
|
||||||
|
width: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__body {
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,9 +6,10 @@
|
|||||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Component, OnInit, inject } from '@angular/core';
|
import { Component, OnInit, TemplateRef, ViewChild, inject } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
@@ -39,6 +40,7 @@ interface Denom {
|
|||||||
CommonModule,
|
CommonModule,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
MatIconModule,
|
MatIconModule,
|
||||||
|
MatDialogModule,
|
||||||
MatSnackBarModule,
|
MatSnackBarModule,
|
||||||
MatFormFieldModule,
|
MatFormFieldModule,
|
||||||
MatSelectModule,
|
MatSelectModule,
|
||||||
@@ -48,6 +50,10 @@ interface Denom {
|
|||||||
export class CashTransferComponent implements OnInit {
|
export class CashTransferComponent implements OnInit {
|
||||||
private service = inject(CashierService);
|
private service = inject(CashierService);
|
||||||
private snackBar = inject(MatSnackBar);
|
private snackBar = inject(MatSnackBar);
|
||||||
|
private dialog = inject(MatDialog);
|
||||||
|
|
||||||
|
@ViewChild('requestsDialog') requestsDialog!: TemplateRef<unknown>;
|
||||||
|
private requestsRef: MatDialogRef<unknown> | null = null;
|
||||||
|
|
||||||
notes: Denom[] = [];
|
notes: Denom[] = [];
|
||||||
coins: Denom[] = [];
|
coins: Denom[] = [];
|
||||||
@@ -82,7 +88,34 @@ export class CashTransferComponent implements OnInit {
|
|||||||
vaultHasBreakdown = true;
|
vaultHasBreakdown = true;
|
||||||
loadingVault = false;
|
loadingVault = false;
|
||||||
|
|
||||||
|
/** The caller's own transfer requests, newest first, so a request can be followed to its approval. */
|
||||||
|
myRequests: any[] = [];
|
||||||
|
|
||||||
|
loadMyRequests(): void {
|
||||||
|
this.service
|
||||||
|
.getCashTransferRequests(undefined, true)
|
||||||
|
.pipe(catchError(() => of([] as any[])))
|
||||||
|
.subscribe((rows) => (this.myRequests = rows.slice(0, 20)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How many of this person's requests are still waiting for a manager. */
|
||||||
|
get pendingCount(): number {
|
||||||
|
return this.myRequests.filter((r) => r.status === 'PENDING').length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opens the list of this person's requests, freshly read, so a request can be followed to its approval. */
|
||||||
|
openRequests(): void {
|
||||||
|
this.loadMyRequests();
|
||||||
|
this.requestsRef?.close();
|
||||||
|
this.requestsRef = this.dialog.open(this.requestsDialog, { width: '720px', maxWidth: '95vw', autoFocus: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
closeRequests(): void {
|
||||||
|
this.requestsRef?.close();
|
||||||
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
|
this.loadMyRequests();
|
||||||
const d = businessToday();
|
const d = businessToday();
|
||||||
this.transferDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
this.transferDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
this.service.getActiveDenominations().subscribe({
|
this.service.getActiveDenominations().subscribe({
|
||||||
@@ -245,16 +278,11 @@ export class CashTransferComponent implements OnInit {
|
|||||||
.filter((d) => this.countFor(d) > 0)
|
.filter((d) => this.countFor(d) > 0)
|
||||||
.map((d) => ({ denominationId: d.id, denominationValue: d.value, count: this.countFor(d) }))
|
.map((d) => ({ denominationId: d.id, denominationValue: d.value, count: this.countFor(d) }))
|
||||||
};
|
};
|
||||||
this.service.createCashTransfer(payload).subscribe({
|
this.service.createCashTransferRequest(payload).subscribe({
|
||||||
next: (res: any) => {
|
next: () => {
|
||||||
if (res?.glWarning) {
|
// Nothing has moved yet: a manager approves the allocation first, and only then does the cash leave the vault.
|
||||||
this.snackBar.open(`Transfer saved (GL warning: ${res.glWarning})`, 'Close', {
|
this.snackBar.open('Cash transfer sent for manager approval', 'Close', { duration: 5000 });
|
||||||
duration: 8000,
|
this.loadMyRequests();
|
||||||
panelClass: ['snack-warn']
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
this.snackBar.open('Cash transfer recorded', 'Close', { duration: 3000 });
|
|
||||||
}
|
|
||||||
this.clear();
|
this.clear();
|
||||||
this.note = '';
|
this.note = '';
|
||||||
this.fromParty = '';
|
this.fromParty = '';
|
||||||
|
|||||||
@@ -83,6 +83,13 @@ const routes: Routes = [
|
|||||||
),
|
),
|
||||||
data: { title: 'My Share Requests', breadcrumb: 'My Share Requests' }
|
data: { title: 'My Share Requests', breadcrumb: 'My Share Requests' }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// The same page as the cashier's, reached from the clerk dashboard: a clerk unpasses vouchers here.
|
||||||
|
path: 'voucher-correction',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./voucher-correction/voucher-correction.component').then((m) => m.VoucherCorrectionComponent),
|
||||||
|
data: { title: 'Voucher Correction', breadcrumb: 'Voucher Correction' }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'closure-requests',
|
path: 'closure-requests',
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
@@ -291,6 +298,12 @@ const routes: Routes = [
|
|||||||
component: ReconciliationComponent,
|
component: ReconciliationComponent,
|
||||||
data: { title: 'Reconciliation', breadcrumb: 'Reconciliation' }
|
data: { title: 'Reconciliation', breadcrumb: 'Reconciliation' }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'voucher-correction',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./voucher-correction/voucher-correction.component').then((m) => m.VoucherCorrectionComponent),
|
||||||
|
data: { title: 'Voucher Correction', breadcrumb: 'Voucher Correction' }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'cash-history',
|
path: 'cash-history',
|
||||||
component: CashHistoryComponent,
|
component: CashHistoryComponent,
|
||||||
|
|||||||
@@ -47,8 +47,21 @@ export class CashierService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Cash Transfer ──
|
// ── Cash Transfer ──
|
||||||
createCashTransfer(data: any): Observable<any> {
|
/** Cash leaves the vault only when a different person approves, so the screen raises a request, not a transfer. */
|
||||||
return this.http.post('/cashtransfer', data);
|
createCashTransferRequest(data: any): Observable<any> {
|
||||||
|
return this.http.post('/cashtransfer/requests', data);
|
||||||
|
}
|
||||||
|
/** Requests by status (comma separated); mine narrows them to the ones the caller raised. */
|
||||||
|
getCashTransferRequests(status?: string, mine = false): Observable<any[]> {
|
||||||
|
let params = new HttpParams().set('mine', String(mine));
|
||||||
|
if (status) params = params.set('status', status);
|
||||||
|
return this.http.get<any[]>('/cashtransfer/requests', { params });
|
||||||
|
}
|
||||||
|
approveCashTransferRequest(id: number): Observable<any> {
|
||||||
|
return this.http.post(`/cashtransfer/requests/${id}/approve`, {});
|
||||||
|
}
|
||||||
|
rejectCashTransferRequest(id: number, reason: string): Observable<any> {
|
||||||
|
return this.http.post(`/cashtransfer/requests/${id}/reject`, { reason });
|
||||||
}
|
}
|
||||||
getCashTransfers(offset = 0, limit = 20): Observable<any> {
|
getCashTransfers(offset = 0, limit = 20): Observable<any> {
|
||||||
const params = new HttpParams().set('offset', offset).set('limit', limit);
|
const params = new HttpParams().set('offset', offset).set('limit', limit);
|
||||||
|
|||||||
@@ -202,6 +202,7 @@
|
|||||||
<th class="num">Amount</th>
|
<th class="num">Amount</th>
|
||||||
<th>Payment</th>
|
<th>Payment</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
|
<th>Correction</th>
|
||||||
<th>By</th>
|
<th>By</th>
|
||||||
<th>Approved By</th>
|
<th>Approved By</th>
|
||||||
<th>Time</th>
|
<th>Time</th>
|
||||||
@@ -231,6 +232,18 @@
|
|||||||
<td>
|
<td>
|
||||||
<span [class]="statusClass(r.status)">{{ r.status }}</span>
|
<span [class]="statusClass(r.status)">{{ r.status }}</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
<!-- Where the voucher stands after a correction: unpassed, revoked, closed or corrected. -->
|
||||||
|
<mifosx-voucher-status
|
||||||
|
[status]="r.voucherStatus"
|
||||||
|
[revokedBy]="r.revokedByVoucherNo"
|
||||||
|
[replaces]="r.replacesVoucherNo"
|
||||||
|
[note]="r.correctionNote"
|
||||||
|
></mifosx-voucher-status>
|
||||||
|
@if (!r.voucherStatus || r.voucherStatus === 'ACTIVE') {
|
||||||
|
<span class="db-muted">—</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
<td>{{ r.handledBy ?? '—' }}</td>
|
<td>{{ r.handledBy ?? '—' }}</td>
|
||||||
<td>
|
<td>
|
||||||
{{ r.approvedBy ?? '—' }}
|
{{ r.approvedBy ?? '—' }}
|
||||||
@@ -256,7 +269,7 @@
|
|||||||
—
|
—
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td colspan="5"></td>
|
<td colspan="6"></td>
|
||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { Router } from '@angular/router';
|
|||||||
import { catchError, of } from 'rxjs';
|
import { catchError, of } from 'rxjs';
|
||||||
import { CashierService } from '../cashier.service';
|
import { CashierService } from '../cashier.service';
|
||||||
import { businessToday } from 'app/core/utils/business-today';
|
import { businessToday } from 'app/core/utils/business-today';
|
||||||
|
import { VoucherStatusComponent, VoucherStatus } from '../voucher-status/voucher-status.component';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Which part of the day's work a row came from. Set by the server, one per source register.
|
* Which part of the day's work a row came from. Set by the server, one per source register.
|
||||||
@@ -26,6 +27,11 @@ type DayBookSection = 'CASHIER' | 'CLERK' | 'SYSTEM';
|
|||||||
|
|
||||||
interface DayBookRow {
|
interface DayBookRow {
|
||||||
voucherNo: string | null;
|
voucherNo: string | null;
|
||||||
|
/** Where the voucher stands after a correction: active, revoked, under correction, or corrected. */
|
||||||
|
voucherStatus?: VoucherStatus;
|
||||||
|
revokedByVoucherNo?: string | null;
|
||||||
|
replacesVoucherNo?: string | null;
|
||||||
|
correctionNote?: string | null;
|
||||||
section: DayBookSection;
|
section: DayBookSection;
|
||||||
side: 'CR' | 'DR';
|
side: 'CR' | 'DR';
|
||||||
kind: string;
|
kind: string;
|
||||||
@@ -58,7 +64,8 @@ interface DayBookRow {
|
|||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
MatIconModule
|
MatIconModule,
|
||||||
|
VoucherStatusComponent
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class DayBookComponent implements OnInit {
|
export class DayBookComponent implements OnInit {
|
||||||
|
|||||||
@@ -146,6 +146,7 @@
|
|||||||
<th class="num">Amount</th>
|
<th class="num">Amount</th>
|
||||||
<th>My part</th>
|
<th>My part</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
|
<th>Correction</th>
|
||||||
<th>Time</th>
|
<th>Time</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -203,6 +204,18 @@
|
|||||||
{{ statusLabel(r.verificationStatus) }}
|
{{ statusLabel(r.verificationStatus) }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
<!-- Where the voucher stands after a correction: unpassed, revoked, closed or corrected. -->
|
||||||
|
<mifosx-voucher-status
|
||||||
|
[status]="r.voucherStatus"
|
||||||
|
[revokedBy]="r.revokedByVoucherNo"
|
||||||
|
[replaces]="r.replacesVoucherNo"
|
||||||
|
[note]="r.correctionNote"
|
||||||
|
></mifosx-voucher-status>
|
||||||
|
@if (!r.voucherStatus || r.voucherStatus === 'ACTIVE') {
|
||||||
|
<span class="mw-acct">—</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
<td class="mw-time">{{ r.createdAt | date: 'HH:mm' }}</td>
|
<td class="mw-time">{{ r.createdAt | date: 'HH:mm' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { catchError, finalize, of } from 'rxjs';
|
|||||||
import { CashierService } from '../cashier.service';
|
import { CashierService } from '../cashier.service';
|
||||||
import { VoucherDetailDialogComponent } from './voucher-detail-dialog.component';
|
import { VoucherDetailDialogComponent } from './voucher-detail-dialog.component';
|
||||||
import { businessToday } from 'app/core/utils/business-today';
|
import { businessToday } from 'app/core/utils/business-today';
|
||||||
|
import { VoucherStatusComponent, VoucherStatus } from '../voucher-status/voucher-status.component';
|
||||||
|
|
||||||
/** What the signed-in user's part in a voucher was. */
|
/** What the signed-in user's part in a voucher was. */
|
||||||
export type MyRole = 'RAISED' | 'VERIFIED' | 'RAISED_AND_VERIFIED';
|
export type MyRole = 'RAISED' | 'VERIFIED' | 'RAISED_AND_VERIFIED';
|
||||||
@@ -24,6 +25,11 @@ export type VoucherSide = 'CR' | 'DR' | 'JV';
|
|||||||
|
|
||||||
interface MyWorkRow {
|
interface MyWorkRow {
|
||||||
voucherNo: string;
|
voucherNo: string;
|
||||||
|
/** Where the voucher stands after a correction: active, revoked, under correction, or corrected. */
|
||||||
|
voucherStatus?: VoucherStatus;
|
||||||
|
revokedByVoucherNo?: string | null;
|
||||||
|
replacesVoucherNo?: string | null;
|
||||||
|
correctionNote?: string | null;
|
||||||
side: VoucherSide | null;
|
side: VoucherSide | null;
|
||||||
voucherType: string | null;
|
voucherType: string | null;
|
||||||
amount: number | null;
|
amount: number | null;
|
||||||
@@ -76,7 +82,8 @@ interface MyWorkReport {
|
|||||||
CommonModule,
|
CommonModule,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
MatIconModule,
|
MatIconModule,
|
||||||
MatDialogModule
|
MatDialogModule,
|
||||||
|
VoucherStatusComponent
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class MyWorkComponent implements OnInit {
|
export class MyWorkComponent implements OnInit {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { CommonModule } from '@angular/common';
|
|||||||
import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
|
import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { VoucherStatusComponent, VoucherStatus } from '../voucher-status/voucher-status.component';
|
||||||
|
|
||||||
export interface VoucherDetailDialogData {
|
export interface VoucherDetailDialogData {
|
||||||
/** Already-loaded detail from CashierService.voucherDetail() — the caller fetches, this dialog
|
/** Already-loaded detail from CashierService.voucherDetail() — the caller fetches, this dialog
|
||||||
@@ -18,6 +19,10 @@ export interface VoucherDetailDialogData {
|
|||||||
* duplicated inside every dialog that could ever show a voucher. */
|
* duplicated inside every dialog that could ever show a voucher. */
|
||||||
detail: {
|
detail: {
|
||||||
voucherNo: string;
|
voucherNo: string;
|
||||||
|
voucherStatus?: VoucherStatus;
|
||||||
|
revokedByVoucherNo?: string | null;
|
||||||
|
replacesVoucherNo?: string | null;
|
||||||
|
correctionNote?: string | null;
|
||||||
voucherType: string | null;
|
voucherType: string | null;
|
||||||
amount: number | null;
|
amount: number | null;
|
||||||
branchName: string | null;
|
branchName: string | null;
|
||||||
@@ -58,7 +63,8 @@ export interface VoucherDetailDialogData {
|
|||||||
CommonModule,
|
CommonModule,
|
||||||
MatDialogModule,
|
MatDialogModule,
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
MatIconModule
|
MatIconModule,
|
||||||
|
VoucherStatusComponent
|
||||||
],
|
],
|
||||||
template: `
|
template: `
|
||||||
<div class="vd-shell">
|
<div class="vd-shell">
|
||||||
@@ -66,7 +72,15 @@ export interface VoucherDetailDialogData {
|
|||||||
<div class="vd-header__title">
|
<div class="vd-header__title">
|
||||||
<mat-icon class="vd-icon">receipt_long</mat-icon>
|
<mat-icon class="vd-icon">receipt_long</mat-icon>
|
||||||
<div>
|
<div>
|
||||||
<h2>{{ detail.voucherNo }}</h2>
|
<h2>
|
||||||
|
{{ detail.voucherNo }}
|
||||||
|
<mifosx-voucher-status
|
||||||
|
[status]="detail.voucherStatus"
|
||||||
|
[revokedBy]="detail.revokedByVoucherNo"
|
||||||
|
[replaces]="detail.replacesVoucherNo"
|
||||||
|
[note]="detail.correctionNote"
|
||||||
|
></mifosx-voucher-status>
|
||||||
|
</h2>
|
||||||
<p>{{ detail.voucherType || 'Voucher' }} · {{ detail.branchName || 'Head Office' }}</p>
|
<p>{{ detail.voucherType || 'Voucher' }} · {{ detail.branchName || 'Head Office' }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -117,6 +117,8 @@
|
|||||||
<select
|
<select
|
||||||
class="ws-f__ctl"
|
class="ws-f__ctl"
|
||||||
[(ngModel)]="row.typ"
|
[(ngModel)]="row.typ"
|
||||||
|
[disabled]="isTypLocked(row)"
|
||||||
|
[title]="isTypLocked(row) ? 'Token withdrawal - TYPE is fixed to Payment' : ''"
|
||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
(change)="onTypChange(row)"
|
(change)="onTypChange(row)"
|
||||||
>
|
>
|
||||||
@@ -127,11 +129,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="ws-f ws-f--branch">
|
<div class="ws-f ws-f--branch">
|
||||||
<label class="ws-f__lbl" title="Branch">BRANCH</label>
|
<label class="ws-f__lbl" title="Branch">BRANCH</label>
|
||||||
<select class="ws-f__ctl" [(ngModel)]="row.br" (click)="$event.stopPropagation()">
|
<select
|
||||||
<option [ngValue]="1">001 · Main</option>
|
class="ws-f__ctl"
|
||||||
<option [ngValue]="2">002 · M.G. Road</option>
|
[(ngModel)]="row.br"
|
||||||
<option [ngValue]="14">014 · Station Rd</option>
|
[disabled]="branches.length <= 1"
|
||||||
<option [ngValue]="27">027 · Industrial</option>
|
[title]="branches.length <= 1 ? 'Your branch' : 'Branch'"
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
|
>
|
||||||
|
@for (b of branches; track b.id) {
|
||||||
|
<option [ngValue]="b.id">{{ b.label }}</option>
|
||||||
|
}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@if (row.typ === 'DD') {
|
@if (row.typ === 'DD') {
|
||||||
@@ -177,13 +184,7 @@
|
|||||||
class="ws-f__ctl"
|
class="ws-f__ctl"
|
||||||
[(ngModel)]="row.acTyp"
|
[(ngModel)]="row.acTyp"
|
||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
(change)="
|
(change)="isCreditOnly(row) && (row.dbCr = 'Cr')"
|
||||||
row.acTyp === 'GL' &&
|
|
||||||
row.typ !== 'Disbursement' &&
|
|
||||||
row.typ !== 'Fund Transfer' &&
|
|
||||||
row.typ !== 'Enter Bank Transfer' &&
|
|
||||||
(row.dbCr = 'Cr')
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
@for (a of acTypOptionsFor(row); track a) {
|
@for (a of acTypOptionsFor(row); track a) {
|
||||||
<option [value]="a">{{ a }}</option>
|
<option [value]="a">{{ a }}</option>
|
||||||
@@ -266,12 +267,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="ws-seg__btn ws-seg__btn--dr"
|
class="ws-seg__btn ws-seg__btn--dr"
|
||||||
[class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'"
|
[class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'"
|
||||||
[disabled]="
|
[disabled]="isCreditOnly(row)"
|
||||||
row.acTyp === 'GL' &&
|
|
||||||
row.typ !== 'Disbursement' &&
|
|
||||||
row.typ !== 'Fund Transfer' &&
|
|
||||||
row.typ !== 'Enter Bank Transfer'
|
|
||||||
"
|
|
||||||
(click)="row.dbCr = 'Dr'"
|
(click)="row.dbCr = 'Dr'"
|
||||||
>
|
>
|
||||||
DR
|
DR
|
||||||
@@ -280,6 +276,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="ws-seg__btn ws-seg__btn--cr"
|
class="ws-seg__btn ws-seg__btn--cr"
|
||||||
[class.ws-seg__btn--active-cr]="row.dbCr === 'Cr'"
|
[class.ws-seg__btn--active-cr]="row.dbCr === 'Cr'"
|
||||||
|
[disabled]="row.typ === 'Withdrawal Slip'"
|
||||||
(click)="row.dbCr = 'Cr'"
|
(click)="row.dbCr = 'Cr'"
|
||||||
>
|
>
|
||||||
CR
|
CR
|
||||||
@@ -711,70 +708,81 @@
|
|||||||
<circle cx="12" cy="8" r="4" />
|
<circle cx="12" cy="8" r="4" />
|
||||||
<path d="M4 20c0-4 3.6-7 8-7s8 3 8 7" />
|
<path d="M4 20c0-4 3.6-7 8-7s8 3 8 7" />
|
||||||
</svg>
|
</svg>
|
||||||
Personal Information
|
{{ isGlAccount ? 'GL Account Information' : 'Personal Information' }}
|
||||||
</div>
|
</div>
|
||||||
<div class="te-panel__body">
|
@if (isGlAccount) {
|
||||||
<div class="te-field">
|
<div class="te-panel__body">
|
||||||
<span class="te-label">Branch</span><span class="te-value">{{ officeName }}</span>
|
@for (f of glInfo; track f.label) {
|
||||||
|
<div class="te-field">
|
||||||
|
<span class="te-label">{{ f.label }}</span
|
||||||
|
><span class="te-value te-value-wrap" [class.te-mono]="f.mono">{{ f.value }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="te-field">
|
} @else {
|
||||||
<span class="te-label">A/c No.</span
|
<div class="te-panel__body">
|
||||||
><span class="te-value te-mono">{{ accountDetails.accountNo }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="te-field">
|
|
||||||
<span class="te-label">Name</span><span class="te-value te-value-name">{{ clientName }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="te-field">
|
|
||||||
<span class="te-label">S/D/W/H/O</span><span class="te-value">{{ guardianName }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="te-field">
|
|
||||||
<span class="te-label">Address</span><span class="te-value te-value-wrap">{{ clientAddress }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="te-field">
|
|
||||||
<span class="te-label">Email</span><span class="te-value">{{ clientEmail }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="te-field-row">
|
|
||||||
<div class="te-field">
|
<div class="te-field">
|
||||||
<span class="te-label">Last Txn</span><span class="te-value">{{ lastTransactionDate }}</span>
|
<span class="te-label">Branch</span><span class="te-value">{{ officeName }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="te-field">
|
<div class="te-field">
|
||||||
<span class="te-label">Type</span
|
<span class="te-label">A/c No.</span
|
||||||
><span class="te-value">{{ accountDetails.depositType?.value ?? selectedRow?.acTyp ?? '—' }}</span>
|
><span class="te-value te-mono">{{ accountDetails.accountNo }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="te-field">
|
||||||
|
<span class="te-label">Name</span><span class="te-value te-value-name">{{ clientName }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="te-field">
|
||||||
|
<span class="te-label">S/D/W/H/O</span><span class="te-value">{{ guardianName }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="te-field">
|
||||||
|
<span class="te-label">Address</span><span class="te-value te-value-wrap">{{ clientAddress }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="te-field">
|
||||||
|
<span class="te-label">Email</span><span class="te-value">{{ clientEmail }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="te-field-row">
|
||||||
|
<div class="te-field">
|
||||||
|
<span class="te-label">Last Txn</span><span class="te-value">{{ lastTransactionDate }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="te-field">
|
||||||
|
<span class="te-label">Type</span
|
||||||
|
><span class="te-value">{{ accountDetails.depositType?.value ?? selectedRow?.acTyp ?? '—' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="te-field-row">
|
||||||
|
<div class="te-field">
|
||||||
|
<span class="te-label">Opening Dt</span><span class="te-value">{{ openingDate }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="te-field">
|
||||||
|
<span class="te-label">Status</span
|
||||||
|
><span class="te-value">{{ accountDetails.status?.value ?? '—' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="te-field">
|
||||||
|
<span class="te-label">CIF No.</span><span class="te-value te-mono">{{ cifNumber }}</span>
|
||||||
|
</div>
|
||||||
|
@if (isNonIndividual) {
|
||||||
|
<div class="te-field">
|
||||||
|
<span class="te-label">Constitution</span>
|
||||||
|
<span class="te-value">{{
|
||||||
|
nonPersonDetails?.constitution?.name ?? nonPersonDetails?.constitutionType?.name ?? '—'
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="te-field">
|
||||||
|
<span class="te-label">Incorp. No.</span>
|
||||||
|
<span class="te-value te-mono">{{
|
||||||
|
nonPersonDetails?.incorpNo ?? nonPersonDetails?.incorporationNo ?? '—'
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="te-field">
|
||||||
|
<span class="te-label">Main Business</span>
|
||||||
|
<span class="te-value">{{
|
||||||
|
nonPersonDetails?.mainBusinessLine?.name ?? nonPersonDetails?.mainBusinessLineType?.name ?? '—'
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="te-field-row">
|
}
|
||||||
<div class="te-field">
|
|
||||||
<span class="te-label">Opening Dt</span><span class="te-value">{{ openingDate }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="te-field">
|
|
||||||
<span class="te-label">Status</span
|
|
||||||
><span class="te-value">{{ accountDetails.status?.value ?? '—' }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="te-field">
|
|
||||||
<span class="te-label">CIF No.</span><span class="te-value te-mono">{{ cifNumber }}</span>
|
|
||||||
</div>
|
|
||||||
@if (isNonIndividual) {
|
|
||||||
<div class="te-field">
|
|
||||||
<span class="te-label">Constitution</span>
|
|
||||||
<span class="te-value">{{
|
|
||||||
nonPersonDetails?.constitution?.name ?? nonPersonDetails?.constitutionType?.name ?? '—'
|
|
||||||
}}</span>
|
|
||||||
</div>
|
|
||||||
<div class="te-field">
|
|
||||||
<span class="te-label">Incorp. No.</span>
|
|
||||||
<span class="te-value te-mono">{{
|
|
||||||
nonPersonDetails?.incorpNo ?? nonPersonDetails?.incorporationNo ?? '—'
|
|
||||||
}}</span>
|
|
||||||
</div>
|
|
||||||
<div class="te-field">
|
|
||||||
<span class="te-label">Main Business</span>
|
|
||||||
<span class="te-value">{{
|
|
||||||
nonPersonDetails?.mainBusinessLine?.name ?? nonPersonDetails?.mainBusinessLineType?.name ?? '—'
|
|
||||||
}}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Joint Holders + Nominees -->
|
<!-- Joint Holders + Nominees -->
|
||||||
@@ -929,76 +937,78 @@
|
|||||||
|
|
||||||
<!-- Other Info + KYC + Account Balances -->
|
<!-- Other Info + KYC + Account Balances -->
|
||||||
<div class="te-panel">
|
<div class="te-panel">
|
||||||
<div class="te-panel__header">
|
@if (!isGlAccount) {
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<div class="te-panel__header">
|
||||||
<rect x="3" y="4" width="18" height="18" rx="2" />
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<line x1="16" y1="2" x2="16" y2="6" />
|
<rect x="3" y="4" width="18" height="18" rx="2" />
|
||||||
<line x1="8" y1="2" x2="8" y2="6" />
|
<line x1="16" y1="2" x2="16" y2="6" />
|
||||||
<line x1="3" y1="10" x2="21" y2="10" />
|
<line x1="8" y1="2" x2="8" y2="6" />
|
||||||
</svg>
|
<line x1="3" y1="10" x2="21" y2="10" />
|
||||||
Other Information
|
</svg>
|
||||||
</div>
|
Other Information
|
||||||
<div class="te-panel__body">
|
|
||||||
<div class="te-field">
|
|
||||||
<span class="te-label">Phone No.</span><span class="te-value">{{ clientPhone }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="te-field">
|
<div class="te-panel__body">
|
||||||
<span class="te-label">Client ID</span
|
|
||||||
><span class="te-value te-mono">{{ accountDetails.clientId }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="te-field">
|
|
||||||
<span class="te-label">PAN</span><span class="te-value te-mono">{{ panNumber }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="te-field">
|
|
||||||
<span class="te-label">KYC Status</span>
|
|
||||||
<span class="te-value" [style.color]="kycDetails?.kycStatus === 300 ? '#059669' : '#f59e0b'">{{
|
|
||||||
kycStatus
|
|
||||||
}}</span>
|
|
||||||
</div>
|
|
||||||
<div class="te-field">
|
|
||||||
<span class="te-label">CKYC No.</span><span class="te-value te-mono">{{ ckycNumber }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="te-field">
|
|
||||||
<span class="te-label">Aadhaar</span><span class="te-value te-mono">{{ aadhaarNumber }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="te-field">
|
|
||||||
<span class="te-label">Product</span>
|
|
||||||
<span class="te-value">{{
|
|
||||||
accountDetails.savingsProductName ||
|
|
||||||
accountDetails.depositProductName ||
|
|
||||||
accountDetails.productName ||
|
|
||||||
'—'
|
|
||||||
}}</span>
|
|
||||||
</div>
|
|
||||||
@if (selectedRow?.acTyp === 'SHR') {
|
|
||||||
<div class="te-field">
|
<div class="te-field">
|
||||||
<span class="te-label">No. of Shares Held</span
|
<span class="te-label">Phone No.</span><span class="te-value">{{ clientPhone }}</span>
|
||||||
><span class="te-value te-mono">{{ accountDetails.summary?.totalApprovedShares ?? 0 }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="te-field">
|
<div class="te-field">
|
||||||
<span class="te-label">Market Price / Share</span
|
<span class="te-label">Client ID</span
|
||||||
><span class="te-value te-mono"
|
><span class="te-value te-mono">{{ accountDetails.clientId }}</span>
|
||||||
>{{ currencySymbol }}{{ accountDetails.currentMarketPrice | number: '1.2-2' }}</span
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="te-field">
|
<div class="te-field">
|
||||||
<span class="te-label">Resolution No.</span
|
<span class="te-label">PAN</span><span class="te-value te-mono">{{ panNumber }}</span>
|
||||||
><span class="te-value te-mono">{{ accountDetails.resolutionNumber || '—' }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="te-field">
|
<div class="te-field">
|
||||||
<span class="te-label">Linked Savings A/C No.</span
|
<span class="te-label">KYC Status</span>
|
||||||
><span class="te-value te-mono">{{ accountDetails.savingsAccountNumber || '—' }}</span>
|
<span class="te-value" [style.color]="kycDetails?.kycStatus === 300 ? '#059669' : '#f59e0b'">{{
|
||||||
|
kycStatus
|
||||||
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
@if (accountDetails.linkedLoanAccountNo) {
|
<div class="te-field">
|
||||||
|
<span class="te-label">CKYC No.</span><span class="te-value te-mono">{{ ckycNumber }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="te-field">
|
||||||
|
<span class="te-label">Aadhaar</span><span class="te-value te-mono">{{ aadhaarNumber }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="te-field">
|
||||||
|
<span class="te-label">Product</span>
|
||||||
|
<span class="te-value">{{
|
||||||
|
accountDetails.savingsProductName ||
|
||||||
|
accountDetails.depositProductName ||
|
||||||
|
accountDetails.productName ||
|
||||||
|
'—'
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
@if (selectedRow?.acTyp === 'SHR') {
|
||||||
<div class="te-field">
|
<div class="te-field">
|
||||||
<span class="te-label">Linked Loan A/C No.</span
|
<span class="te-label">No. of Shares Held</span
|
||||||
><span class="te-value te-mono">{{ accountDetails.linkedLoanAccountNo }}</span>
|
><span class="te-value te-mono">{{ accountDetails.summary?.totalApprovedShares ?? 0 }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="te-field">
|
||||||
|
<span class="te-label">Market Price / Share</span
|
||||||
|
><span class="te-value te-mono"
|
||||||
|
>{{ currencySymbol }}{{ accountDetails.currentMarketPrice | number: '1.2-2' }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="te-field">
|
||||||
|
<span class="te-label">Resolution No.</span
|
||||||
|
><span class="te-value te-mono">{{ accountDetails.resolutionNumber || '—' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="te-field">
|
||||||
|
<span class="te-label">Linked Savings A/C No.</span
|
||||||
|
><span class="te-value te-mono">{{ accountDetails.savingsAccountNumber || '—' }}</span>
|
||||||
|
</div>
|
||||||
|
@if (accountDetails.linkedLoanAccountNo) {
|
||||||
|
<div class="te-field">
|
||||||
|
<span class="te-label">Linked Loan A/C No.</span
|
||||||
|
><span class="te-value te-mono">{{ accountDetails.linkedLoanAccountNo }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
</div>
|
||||||
</div>
|
}
|
||||||
|
|
||||||
<div class="te-panel__header te-panel__header--mt">
|
<div class="te-panel__header" [class.te-panel__header--mt]="!isGlAccount">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<line x1="12" y1="1" x2="12" y2="23" />
|
<line x1="12" y1="1" x2="12" y2="23" />
|
||||||
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
|
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
|
||||||
@@ -1567,6 +1577,8 @@
|
|||||||
<select
|
<select
|
||||||
class="ws-f__ctl"
|
class="ws-f__ctl"
|
||||||
[(ngModel)]="row.typ"
|
[(ngModel)]="row.typ"
|
||||||
|
[disabled]="isTypLocked(row)"
|
||||||
|
[title]="isTypLocked(row) ? 'Token withdrawal - TYPE is fixed to Payment' : ''"
|
||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
(change)="onTypChange(row)"
|
(change)="onTypChange(row)"
|
||||||
>
|
>
|
||||||
@@ -1577,11 +1589,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="ws-f ws-f--branch">
|
<div class="ws-f ws-f--branch">
|
||||||
<label class="ws-f__lbl">BRANCH</label>
|
<label class="ws-f__lbl">BRANCH</label>
|
||||||
<select class="ws-f__ctl" [(ngModel)]="row.br" (click)="$event.stopPropagation()">
|
<select
|
||||||
<option [ngValue]="1">001 · Main</option>
|
class="ws-f__ctl"
|
||||||
<option [ngValue]="2">002 · M.G. Road</option>
|
[(ngModel)]="row.br"
|
||||||
<option [ngValue]="14">014 · Station Rd</option>
|
[disabled]="branches.length <= 1"
|
||||||
<option [ngValue]="27">027 · Industrial</option>
|
[title]="branches.length <= 1 ? 'Your branch' : 'Branch'"
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
|
>
|
||||||
|
@for (b of branches; track b.id) {
|
||||||
|
<option [ngValue]="b.id">{{ b.label }}</option>
|
||||||
|
}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@if (row.typ === 'DD') {
|
@if (row.typ === 'DD') {
|
||||||
@@ -1627,13 +1644,7 @@
|
|||||||
class="ws-f__ctl"
|
class="ws-f__ctl"
|
||||||
[(ngModel)]="row.acTyp"
|
[(ngModel)]="row.acTyp"
|
||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
(change)="
|
(change)="isCreditOnly(row) && (row.dbCr = 'Cr')"
|
||||||
row.acTyp === 'GL' &&
|
|
||||||
row.typ !== 'Disbursement' &&
|
|
||||||
row.typ !== 'Fund Transfer' &&
|
|
||||||
row.typ !== 'Enter Bank Transfer' &&
|
|
||||||
(row.dbCr = 'Cr')
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
@for (a of acTypOptionsFor(row); track a) {
|
@for (a of acTypOptionsFor(row); track a) {
|
||||||
<option [value]="a">{{ a }}</option>
|
<option [value]="a">{{ a }}</option>
|
||||||
@@ -1704,12 +1715,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="ws-seg__btn ws-seg__btn--dr"
|
class="ws-seg__btn ws-seg__btn--dr"
|
||||||
[class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'"
|
[class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'"
|
||||||
[disabled]="
|
[disabled]="isCreditOnly(row)"
|
||||||
row.acTyp === 'GL' &&
|
|
||||||
row.typ !== 'Disbursement' &&
|
|
||||||
row.typ !== 'Fund Transfer' &&
|
|
||||||
row.typ !== 'Enter Bank Transfer'
|
|
||||||
"
|
|
||||||
(click)="$event.stopPropagation(); row.dbCr = 'Dr'"
|
(click)="$event.stopPropagation(); row.dbCr = 'Dr'"
|
||||||
>
|
>
|
||||||
DR
|
DR
|
||||||
@@ -1718,6 +1724,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="ws-seg__btn ws-seg__btn--cr"
|
class="ws-seg__btn ws-seg__btn--cr"
|
||||||
[class.ws-seg__btn--active-cr]="row.dbCr === 'Cr'"
|
[class.ws-seg__btn--active-cr]="row.dbCr === 'Cr'"
|
||||||
|
[disabled]="row.typ === 'Withdrawal Slip'"
|
||||||
(click)="$event.stopPropagation(); row.dbCr = 'Cr'"
|
(click)="$event.stopPropagation(); row.dbCr = 'Cr'"
|
||||||
>
|
>
|
||||||
CR
|
CR
|
||||||
@@ -2043,7 +2050,13 @@
|
|||||||
<button class="ws-btn ws-btn--ghost" type="button" (click)="onSearch()">Search</button>
|
<button class="ws-btn ws-btn--ghost" type="button" (click)="onSearch()">Search</button>
|
||||||
<button class="ws-btn ws-btn--mode" type="button">Change Tr.Mode</button>
|
<button class="ws-btn ws-btn--mode" type="button">Change Tr.Mode</button>
|
||||||
@if (isClerk) {
|
@if (isClerk) {
|
||||||
<button class="ws-btn ws-btn--add" type="button" (click)="addRow()" title="Add another transaction row">
|
<button
|
||||||
|
class="ws-btn ws-btn--add"
|
||||||
|
type="button"
|
||||||
|
[disabled]="selectedRow?.typ === 'Withdrawal Slip'"
|
||||||
|
(click)="addRow()"
|
||||||
|
title="Add another transaction row"
|
||||||
|
>
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
<line x1="12" y1="5" x2="12" y2="19" />
|
<line x1="12" y1="5" x2="12" y2="19" />
|
||||||
<line x1="5" y1="12" x2="19" y2="12" />
|
<line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
|||||||
@@ -1658,6 +1658,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
// Full account + client data
|
// Full account + client data
|
||||||
accountDetails: any = null;
|
accountDetails: any = null;
|
||||||
|
/** Parent (header) GL of the selected GL account, shown in the GL Account Information panel. */
|
||||||
|
glParent: { glCode?: string; name?: string } | null = null;
|
||||||
clientDetails: any = null;
|
clientDetails: any = null;
|
||||||
kycDetails: any = null;
|
kycDetails: any = null;
|
||||||
nominees: any[] = [];
|
nominees: any[] = [];
|
||||||
@@ -1685,10 +1687,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
private searchToken = 0;
|
private searchToken = 0;
|
||||||
private lastLoadedAcNo = '';
|
private lastLoadedAcNo = '';
|
||||||
private acNoInput$ = new Subject<{ row: TxnRow; index: number; value: string }>();
|
private acNoInput$ = new Subject<{ row: TxnRow; index: number; value: string }>();
|
||||||
|
/** How many characters of the A/C NO. start the automatic account search. */
|
||||||
|
private static readonly AC_NO_AUTO_SEARCH_MIN_LENGTH = 6;
|
||||||
cashVaultGlId: number | null = null;
|
cashVaultGlId: number | null = null;
|
||||||
/** The Cash Vault GL account's own code and name — the denomination breakdown for a manual GL
|
/** The Cash Vault GL account's own code and name — the denomination breakdown for a manual GL
|
||||||
* entry has to be filed against THIS account, not whichever account the teller typed in, since
|
* entry has to be filed against THIS account, not whichever account the teller typed in, since
|
||||||
* that account is never the one physical notes actually moved through (see submitGlJournalEntry).
|
* that account is never the one physical notes actually moved through.
|
||||||
* Both resolved dynamically off whatever this tenant's own financial-activity mapping names as
|
* Both resolved dynamically off whatever this tenant's own financial-activity mapping names as
|
||||||
* its cash/vault account (see loadCashVaultGl) — never a literal account name or code. */
|
* its cash/vault account (see loadCashVaultGl) — never a literal account name or code. */
|
||||||
cashVaultGlCode: string | null = null;
|
cashVaultGlCode: string | null = null;
|
||||||
@@ -1873,6 +1877,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
const d = businessToday();
|
const d = businessToday();
|
||||||
this.today = `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`;
|
this.today = `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`;
|
||||||
|
this.loadBranches();
|
||||||
this.addRow();
|
this.addRow();
|
||||||
this.loadDenominations();
|
this.loadDenominations();
|
||||||
this.loadOpeningPosition();
|
this.loadOpeningPosition();
|
||||||
@@ -1903,7 +1908,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
distinctUntilChanged((a, b) => a.value === b.value && a.row === b.row)
|
distinctUntilChanged((a, b) => a.value === b.value && a.row === b.row)
|
||||||
)
|
)
|
||||||
.subscribe(({ row, index, value }) => {
|
.subscribe(({ row, index, value }) => {
|
||||||
if (value.trim().length >= 4) {
|
// Searches on its own once six characters are typed, for clerk and cashier alike.
|
||||||
|
if (value.trim().length >= TransactionEntryComponent.AC_NO_AUTO_SEARCH_MIN_LENGTH) {
|
||||||
this.selectedRow = row;
|
this.selectedRow = row;
|
||||||
this.loadAccountDetails(value.trim(), row.acTyp);
|
this.loadAccountDetails(value.trim(), row.acTyp);
|
||||||
}
|
}
|
||||||
@@ -2304,7 +2310,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
// anyway, just without the extra click to get there.
|
// anyway, just without the extra click to get there.
|
||||||
const row: TxnRow = {
|
const row: TxnRow = {
|
||||||
typ: this.isClerk ? 'Withdrawal Slip' : 'Receipt',
|
typ: this.isClerk ? 'Withdrawal Slip' : 'Receipt',
|
||||||
br: 1,
|
br: this.defaultBranchId,
|
||||||
trTp: '',
|
trTp: '',
|
||||||
acTyp: 'SD',
|
acTyp: 'SD',
|
||||||
acNo: '',
|
acNo: '',
|
||||||
@@ -2323,8 +2329,54 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
this.rows.push(row);
|
this.rows.push(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A GL row is credit-only (money received into the account) except on the transfer types, which pick
|
||||||
|
* either side, and on a Withdrawal Slip, which is always a debit: the GL is debited and the cash
|
||||||
|
* vault credited when cash is paid out against it (a clerk's request, approved, then paid by the cashier).
|
||||||
|
*/
|
||||||
|
isGlCreditOnly(row: TxnRow): boolean {
|
||||||
|
return (
|
||||||
|
row.acTyp === 'GL' &&
|
||||||
|
row.typ !== 'Disbursement' &&
|
||||||
|
row.typ !== 'Fund Transfer' &&
|
||||||
|
row.typ !== 'Enter Bank Transfer' &&
|
||||||
|
row.typ !== 'Withdrawal Slip'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Fixed Deposit row is credit-only on both the clerk and the cashier screens: an FD is funded once, with
|
||||||
|
* exactly its principal, and money leaves it only through its maturity or premature closure. The one
|
||||||
|
* exception is the clerk's own "Close this account" flow, which raises a closure request.
|
||||||
|
*/
|
||||||
|
isFdCreditOnly(row: TxnRow): boolean {
|
||||||
|
return row.acTyp === 'FD' && !this.pendingClosure;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** DR is not offered for this row (see isGlCreditOnly and isFdCreditOnly). */
|
||||||
|
isCreditOnly(row: TxnRow): boolean {
|
||||||
|
return this.isGlCreditOnly(row) || this.isFdCreditOnly(row);
|
||||||
|
}
|
||||||
|
|
||||||
/** Called when the TYPE dropdown changes — sets sensible defaults for TXN TP and DR/CR */
|
/** Called when the TYPE dropdown changes — sets sensible defaults for TXN TP and DR/CR */
|
||||||
|
/**
|
||||||
|
* A cashier row that was filled from an approved token (withdrawal / account-closure) is cash
|
||||||
|
* leaving the branch to the member, so its TYPE is fixed to Payment and cannot be changed.
|
||||||
|
* Never true for the clerk screen or for rows keyed in by hand.
|
||||||
|
*/
|
||||||
|
isTypLocked(row: TxnRow): boolean {
|
||||||
|
return (
|
||||||
|
!this.isClerk &&
|
||||||
|
!!row.tokNo?.trim() &&
|
||||||
|
!!(this.activeWithdrawalRequest || this.activeDividendClaimRequest || this.activeClosureRequest)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
onTypChange(row: TxnRow): void {
|
onTypChange(row: TxnRow): void {
|
||||||
|
if (this.isTypLocked(row)) {
|
||||||
|
row.typ = 'Payment';
|
||||||
|
return;
|
||||||
|
}
|
||||||
switch (row.typ) {
|
switch (row.typ) {
|
||||||
case 'Withdrawal Slip':
|
case 'Withdrawal Slip':
|
||||||
row.dbCr = 'Dr';
|
row.dbCr = 'Dr';
|
||||||
@@ -2440,7 +2492,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
* such ambiguity — the account itself says what it's for.
|
* such ambiguity — the account itself says what it's for.
|
||||||
*/
|
*/
|
||||||
showsEntryType(row: TxnRow): boolean {
|
showsEntryType(row: TxnRow): boolean {
|
||||||
return row.acTyp === 'GL' && row.dbCr === 'Cr';
|
return row.acTyp === 'GL';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2618,14 +2670,47 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
return !!this.selectedRow && Math.abs(this.denominationTotal - this.ddTargetTotal) < 0.005;
|
return !!this.selectedRow && Math.abs(this.denominationTotal - this.ddTargetTotal) < 0.005;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The branches an entry can be posted against: the real offices of this installation. Read from the offices
|
||||||
|
* themselves rather than from a fixed list, so a branch that is added or renamed shows up here without a code change.
|
||||||
|
*/
|
||||||
|
branches: { id: number; label: string }[] = [];
|
||||||
|
|
||||||
|
private loadBranches(): void {
|
||||||
|
const own = this.authService.getCredentials();
|
||||||
|
const ownBranch = own?.officeId
|
||||||
|
? [{ id: own.officeId, label: this.branchLabel(own.officeId, own.officeName) }]
|
||||||
|
: [];
|
||||||
|
// The office list is only asked for by a login allowed to read it. A cashier or clerk is not, and asking anyway
|
||||||
|
// makes the server refuse and the screen raise an "Unauthorized Request" popup; such a login works in its own
|
||||||
|
// office, which is the one branch it is offered.
|
||||||
|
const permissions = own?.permissions ?? [];
|
||||||
|
const mayReadOffices = permissions.includes('ALL_FUNCTIONS') || permissions.includes('READ_OFFICE');
|
||||||
|
if (!mayReadOffices) {
|
||||||
|
this.branches = ownBranch;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.http
|
||||||
|
.get('/offices')
|
||||||
|
.pipe(catchError(() => of([])))
|
||||||
|
.subscribe((list: any) => {
|
||||||
|
const offices: any[] = Array.isArray(list) ? list : [];
|
||||||
|
this.branches =
|
||||||
|
offices.length > 0 ? offices.map((o) => ({ id: o.id, label: this.branchLabel(o.id, o.name) })) : ownBranch;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private branchLabel(id: number, name: string | null | undefined): string {
|
||||||
|
return `${String(id).padStart(3, '0')} · ${name ?? 'Branch ' + id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The office the signed-in user works in, which a new entry is posted against unless they pick another. */
|
||||||
|
get defaultBranchId(): number {
|
||||||
|
return this.authService.getCredentials()?.officeId ?? 1;
|
||||||
|
}
|
||||||
|
|
||||||
branchNameFor(branchId: number): string {
|
branchNameFor(branchId: number): string {
|
||||||
const map: Record<number, string> = {
|
return this.branches.find((b) => b.id === branchId)?.label ?? `Branch ${branchId}`;
|
||||||
1: '001 · Main',
|
|
||||||
2: '002 · M.G. Road',
|
|
||||||
14: '014 · Station Rd',
|
|
||||||
27: '027 · Industrial'
|
|
||||||
};
|
|
||||||
return map[branchId] || `Branch ${branchId}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -3918,7 +4003,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
SAVINGS: 'SD',
|
SAVINGS: 'SD',
|
||||||
CURRENT: 'CA',
|
CURRENT: 'CA',
|
||||||
FIXED_DEPOSIT: 'FD',
|
FIXED_DEPOSIT: 'FD',
|
||||||
RECURRING_DEPOSIT: 'RD'
|
RECURRING_DEPOSIT: 'RD',
|
||||||
|
GL: 'GL'
|
||||||
};
|
};
|
||||||
|
|
||||||
openCustomerSearch(): void {
|
openCustomerSearch(): void {
|
||||||
@@ -3974,8 +4060,11 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
.pipe(catchError(() => of(null)))
|
.pipe(catchError(() => of(null)))
|
||||||
.subscribe((res: any) => {
|
.subscribe((res: any) => {
|
||||||
if (token !== this.searchToken) return; // stale result — newer search started
|
if (token !== this.searchToken) return; // stale result — newer search started
|
||||||
const allItems: any[] = res?.pageItems ?? (Array.isArray(res) ? res : []);
|
const found: any[] = res?.pageItems ?? (Array.isArray(res) ? res : []);
|
||||||
const f = cfg.matchField;
|
const f = cfg.matchField;
|
||||||
|
// SD, FD and RD share one list that returns any kind of deposit account: keep only the kind chosen in A/C TP.
|
||||||
|
const allItems =
|
||||||
|
cfg.depositTypeId != null ? found.filter((x: any) => x?.depositType?.id === cfg.depositTypeId) : found;
|
||||||
// Exact match first, then suffix/partial match. Some account types (e.g. Share — its
|
// Exact match first, then suffix/partial match. Some account types (e.g. Share — its
|
||||||
// list endpoint only accepts offset/limit, no accountNo filter at all) always return the
|
// list endpoint only accepts offset/limit, no accountNo filter at all) always return the
|
||||||
const exactMatch = allItems.find((x: any) => String(x[f] ?? '').trim() === String(acNo).trim());
|
const exactMatch = allItems.find((x: any) => String(x[f] ?? '').trim() === String(acNo).trim());
|
||||||
@@ -4005,7 +4094,18 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.loadingAccount = false;
|
this.loadingAccount = false;
|
||||||
this.accountError = 'Account not found for: ' + acNo;
|
// The number may exist as another kind of account (an FD typed under SD, say): say so, so the clerk can fix A/C TP.
|
||||||
|
const otherKind =
|
||||||
|
cfg.depositTypeId != null
|
||||||
|
? found.find(
|
||||||
|
(x: any) =>
|
||||||
|
x?.depositType?.id !== cfg.depositTypeId &&
|
||||||
|
(String(x[f] ?? '').trim() === String(acNo).trim() || String(x[f] ?? '').endsWith(acNo))
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
this.accountError = otherKind
|
||||||
|
? `${acTyp} account not found for: ${acNo} (this number is a ${otherKind.depositType?.value ?? 'different kind of'} account - change A/C TP)`
|
||||||
|
: `${acTyp} account not found for: ${acNo}`;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4069,6 +4169,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
* account type — used to keep Header GL accounts out of the results a clerk/cashier can pick
|
* account type — used to keep Header GL accounts out of the results a clerk/cashier can pick
|
||||||
* for a transaction; a Header can never receive a posting, so it should never be offered. */
|
* for a transaction; a Header can never receive a posting, so it should never be offered. */
|
||||||
extraListQueryParams?: string;
|
extraListQueryParams?: string;
|
||||||
|
/** SD, FD and RD are all served by the one /savingsaccounts list, which answers with a deposit
|
||||||
|
* account of any kind for a number. This is the depositType.id the chosen A/C type accepts
|
||||||
|
* (100 savings, 200 fixed, 300 recurring), so a number of another kind is "not found" here. */
|
||||||
|
depositTypeId?: number;
|
||||||
} {
|
} {
|
||||||
const t = acTyp?.toUpperCase();
|
const t = acTyp?.toUpperCase();
|
||||||
if (t === 'LA') {
|
if (t === 'LA') {
|
||||||
@@ -4149,6 +4253,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
return {
|
return {
|
||||||
list: '/savingsaccounts',
|
list: '/savingsaccounts',
|
||||||
detail: '/fixeddepositaccounts',
|
detail: '/fixeddepositaccounts',
|
||||||
|
depositTypeId: 200,
|
||||||
nominee: 'FD',
|
nominee: 'FD',
|
||||||
search: 'fixeddeposit',
|
search: 'fixeddeposit',
|
||||||
siAccountType: 2,
|
siAccountType: 2,
|
||||||
@@ -4166,6 +4271,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
return {
|
return {
|
||||||
list: '/savingsaccounts',
|
list: '/savingsaccounts',
|
||||||
detail: '/recurringdepositaccounts',
|
detail: '/recurringdepositaccounts',
|
||||||
|
depositTypeId: 300,
|
||||||
nominee: 'RD',
|
nominee: 'RD',
|
||||||
search: 'recurringdeposit',
|
search: 'recurringdeposit',
|
||||||
siAccountType: 2,
|
siAccountType: 2,
|
||||||
@@ -4183,6 +4289,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
return {
|
return {
|
||||||
list: '/savingsaccounts',
|
list: '/savingsaccounts',
|
||||||
detail: '/savingsaccounts',
|
detail: '/savingsaccounts',
|
||||||
|
depositTypeId: 100,
|
||||||
nominee: 'SB',
|
nominee: 'SB',
|
||||||
search: 'saving',
|
search: 'saving',
|
||||||
siAccountType: 2,
|
siAccountType: 2,
|
||||||
@@ -4243,6 +4350,17 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
this.accountDetails = acc;
|
this.accountDetails = acc;
|
||||||
this.jointHolders = acc.jointHolders ?? [];
|
this.jointHolders = acc.jointHolders ?? [];
|
||||||
|
// GL only: look up the parent (header) GL's name. Never runs for any other account type.
|
||||||
|
this.glParent = null;
|
||||||
|
if (acTyp?.toUpperCase() === 'GL' && acc.parentId) {
|
||||||
|
this.http
|
||||||
|
.get<any>(`/glaccounts/${acc.parentId}`)
|
||||||
|
.pipe(catchError(() => of(null)))
|
||||||
|
.subscribe((p: any) => {
|
||||||
|
if (token !== this.searchToken) return;
|
||||||
|
this.glParent = p ? { glCode: p.glCode, name: p.name } : null;
|
||||||
|
});
|
||||||
|
}
|
||||||
// Cash Hold / Clearing Hold breakdown only exists for plain savings accounts (m_savings_account)
|
// Cash Hold / Clearing Hold breakdown only exists for plain savings accounts (m_savings_account)
|
||||||
// — FD/RD/Current/Share/GL have no such row, so there's nothing to ask for.
|
// — FD/RD/Current/Share/GL have no such row, so there's nothing to ask for.
|
||||||
if (acTyp?.toUpperCase() === 'SD' && acc.id) {
|
if (acTyp?.toUpperCase() === 'SD' && acc.id) {
|
||||||
@@ -4813,6 +4931,40 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
return this.accountDetails?.officeName ?? this.clientDetails?.officeName ?? '—';
|
return this.accountDetails?.officeName ?? this.clientDetails?.officeName ?? '—';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True when the loaded account is a General Ledger account (/glaccounts), which has no client behind it. */
|
||||||
|
get isGlAccount(): boolean {
|
||||||
|
return !!this.accountDetails?.glCode && this.selectedRow?.acTyp === 'GL';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The GL account's own details, shown in place of the client panels. */
|
||||||
|
get glInfo(): { label: string; value: string; mono?: boolean }[] {
|
||||||
|
const gl = this.accountDetails;
|
||||||
|
if (!gl) return [];
|
||||||
|
const type = gl.type?.value ?? '—';
|
||||||
|
const nature = [
|
||||||
|
'ASSET',
|
||||||
|
'EXPENSE'
|
||||||
|
].includes(String(type).toUpperCase()) ? 'Debit' : 'Credit';
|
||||||
|
return [
|
||||||
|
{ label: 'GL Code', value: gl.glCode ?? '—', mono: true },
|
||||||
|
{ label: 'GL Name', value: gl.name ?? '—' },
|
||||||
|
{ label: 'Account Type', value: type },
|
||||||
|
{ label: 'Usage', value: gl.usage?.value ?? '—' },
|
||||||
|
{ label: 'Normal Balance', value: nature },
|
||||||
|
{
|
||||||
|
label: 'Parent GL',
|
||||||
|
value: this.glParent?.name
|
||||||
|
? `${this.glParent.glCode ? this.glParent.glCode.trim() + ' - ' : ''}${this.glParent.name.trim()}`
|
||||||
|
: gl.parentId != null
|
||||||
|
? `ID ${gl.parentId}`
|
||||||
|
: '—'
|
||||||
|
},
|
||||||
|
{ label: 'Manual Entries', value: gl.manualEntriesAllowed ? 'Allowed' : 'Not allowed' },
|
||||||
|
{ label: 'Status', value: gl.disabled ? 'Disabled' : 'Enabled' },
|
||||||
|
{ label: 'Description', value: gl.description || '—' }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
get openingDate(): string {
|
get openingDate(): string {
|
||||||
const raw =
|
const raw =
|
||||||
this.accountDetails?.timeline?.submittedOnDate ??
|
this.accountDetails?.timeline?.submittedOnDate ??
|
||||||
@@ -5155,6 +5307,20 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onSave(): void {
|
onSave(): void {
|
||||||
|
// Money never leaves a Fixed Deposit from this screen, clerk or cashier: no withdrawal slip, payment,
|
||||||
|
// collection or transfer debits an FD. It is paid out only through its maturity or premature closure
|
||||||
|
// (the clerk's "Close this account" flow). The DR button is already disabled for FD rows (isFdCreditOnly);
|
||||||
|
// this also catches a row set to DR by its TYPE, such as a Withdrawal Slip.
|
||||||
|
const fdDebitRows = this.rows.filter((r) => this.isFdCreditOnly(r) && r.dbCr === 'Dr');
|
||||||
|
if (fdDebitRows.length > 0) {
|
||||||
|
this.showAlert('error', 'Fixed Deposit Cannot Be Debited', [
|
||||||
|
`FD account(s) ${fdDebitRows.map((r) => r.acNo).join(', ')} cannot be debited or withdrawn from.`,
|
||||||
|
'Money leaves a Fixed Deposit only through its maturity or premature closure.',
|
||||||
|
'An FD can only be credited — once, with exactly its principal.'
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Demand drafts commit here like everything else on the worksheet. Checked first because a DD
|
// Demand drafts commit here like everything else on the worksheet. Checked first because a DD
|
||||||
// row shares its shape with the ordinary entries below — without this it would fall through and
|
// row shares its shape with the ordinary entries below — without this it would fall through and
|
||||||
// post as a plain transaction, with the draft never raised and the charges silently dropped.
|
// post as a plain transaction, with the draft never raised and the charges silently dropped.
|
||||||
@@ -5232,7 +5398,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bank transfer: only savings (SD) or current (CA/CB) accounts allowed
|
// Bank transfer: savings (SD) or current (CA/CB) accounts on either side, plus a Fixed Deposit (FD) on
|
||||||
|
// the CR side only -- funding it from the customer's own account. The server checks the funding rules
|
||||||
|
// (first and only funding, exactly the principal, the source account's owner and available balance).
|
||||||
const allowedTypes = new Set([
|
const allowedTypes = new Set([
|
||||||
'SD',
|
'SD',
|
||||||
'CA',
|
'CA',
|
||||||
@@ -5240,13 +5408,13 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
]);
|
]);
|
||||||
const invalidAccounts = [
|
const invalidAccounts = [
|
||||||
...drRows,
|
...drRows,
|
||||||
...crRows
|
...crRows.filter((r) => r.acTyp !== 'FD')
|
||||||
].filter((r) => !allowedTypes.has(r.acTyp));
|
].filter((r) => !allowedTypes.has(r.acTyp));
|
||||||
if (invalidAccounts.length > 0) {
|
if (invalidAccounts.length > 0) {
|
||||||
this.showAlert('error', 'Invalid Account Type', [
|
this.showAlert('error', 'Invalid Account Type', [
|
||||||
`Account type(s) not allowed in Bank Transfer: ${invalidAccounts.map((r) => r.acTyp + ' (' + r.acNo + ')').join(', ')}`,
|
`Account type(s) not allowed in Bank Transfer: ${invalidAccounts.map((r) => r.acTyp + ' (' + r.acNo + ')').join(', ')}`,
|
||||||
'Only Savings (SD) and Current (CA) accounts are permitted.',
|
'Debit side: only Savings (SD) and Current (CA) accounts. Credit side: SD, CA or a Fixed Deposit (FD).',
|
||||||
'GL, FD and SHR accounts are not allowed — use Disbursement/Collection instead.',
|
'GL and SHR accounts are not allowed — use Disbursement/Collection instead.',
|
||||||
'RD installments are paid through a cashier Receipt or Auto Debit.'
|
'RD installments are paid through a cashier Receipt or Auto Debit.'
|
||||||
]);
|
]);
|
||||||
return;
|
return;
|
||||||
@@ -5847,10 +6015,14 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// GL withdrawal → direct journal entry (no maker-checker)
|
// A GL withdrawal slip is raised by the clerk and paid by the cashier against its token, exactly like a
|
||||||
// GL deposit → falls through to maker-checker block below
|
// savings withdrawal (the clerk branch and the token branch below). Nothing posts from this screen directly:
|
||||||
if (row.acTyp === 'GL' && !isDeposit) {
|
// manual journal entries are switched off on the server.
|
||||||
this.submitGlJournalEntry(row, amount, isDeposit);
|
if (row.acTyp === 'GL' && !isDeposit && !this.clerkMode && !this.activeWithdrawalRequest) {
|
||||||
|
this.showAlert('error', 'GL Withdrawal', [
|
||||||
|
'A GL withdrawal is raised by a clerk and paid by the cashier against its token.',
|
||||||
|
'Enter the token number of the approved request to pay it.'
|
||||||
|
]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6223,17 +6395,34 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
const txnDateIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
const txnDateIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||||
const selectedPt = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
|
const selectedPt = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
|
||||||
|
|
||||||
|
const isGl = row.acTyp === 'GL';
|
||||||
|
// The Entry Type picked for a GL slip has no column of its own on the request, so it rides on the narration.
|
||||||
|
const entryTypeName = isGl ? this.entryTypeOptions.find((e) => e.id === row.entryTypeCvId)?.name : undefined;
|
||||||
|
const description = [
|
||||||
|
row.description,
|
||||||
|
entryTypeName ? `[Entry Type: ${entryTypeName}]` : ''
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accountNo: this.accountDetails.accountNo,
|
accountNo: isGl ? (this.accountDetails.glCode ?? this.accountDetails.name) : this.accountDetails.accountNo,
|
||||||
accountType: this.creditEntryAccountType(row.acTyp),
|
accountType: this.creditEntryAccountType(row.acTyp),
|
||||||
savingsAccountId: this.accountDetails.id,
|
savingsAccountId: isGl ? null : this.accountDetails.id,
|
||||||
clientId: this.accountDetails.clientId ?? this.clientDetails?.id ?? null,
|
// GL-specific fields: the GL being paid out of and the cash GL it is paid from (null for other types)
|
||||||
clientName: this.clientDetails?.displayName ?? this.accountDetails?.clientName ?? null,
|
glAccountId: isGl ? this.accountDetails.id : null,
|
||||||
|
contraGlId: isGl ? this.cashVaultGlId : null,
|
||||||
|
officeId: isGl ? row.br || 1 : null,
|
||||||
|
currencyCode: isGl ? (this.accountDetails?.currency?.code ?? 'INR') : null,
|
||||||
|
clientId: isGl ? null : (this.accountDetails.clientId ?? this.clientDetails?.id ?? null),
|
||||||
|
clientName: isGl
|
||||||
|
? (this.accountDetails?.name ?? null)
|
||||||
|
: (this.clientDetails?.displayName ?? this.accountDetails?.clientName ?? null),
|
||||||
amount,
|
amount,
|
||||||
txnDate: txnDateIso,
|
txnDate: txnDateIso,
|
||||||
paymentTypeId: row.paymentTypeId || null,
|
paymentTypeId: row.paymentTypeId || null,
|
||||||
paymentTypeName: selectedPt?.name ?? 'Cash',
|
paymentTypeName: selectedPt?.name ?? 'Cash',
|
||||||
description: row.description || null,
|
description: description || null,
|
||||||
chequeNo: row.chequeNo || null,
|
chequeNo: row.chequeNo || null,
|
||||||
bankName: row.bankName || null,
|
bankName: row.bankName || null,
|
||||||
tokNo: row.tokNo?.trim() || null,
|
tokNo: row.tokNo?.trim() || null,
|
||||||
@@ -6425,106 +6614,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
this.addRow();
|
this.addRow();
|
||||||
}
|
}
|
||||||
|
|
||||||
private submitGlJournalEntry(row: TxnRow, amount: number, isDeposit: boolean): void {
|
|
||||||
if (!this.cashVaultGlId) {
|
|
||||||
this.showAlert('error', 'Cash Vault GL Not Configured', [
|
|
||||||
'Financial Activity Account (CASH_AT_TELLER) is not configured.',
|
|
||||||
'Please configure it in System → Financial Activity Accounts.'
|
|
||||||
]);
|
|
||||||
this.saving = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const today = businessToday();
|
|
||||||
const txnDate = `${String(today.getDate()).padStart(2, '0')}/${String(today.getMonth() + 1).padStart(2, '0')}/${today.getFullYear()}`;
|
|
||||||
const txnDateIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
|
||||||
|
|
||||||
const selectedGlId = this.accountDetails.id;
|
|
||||||
const contraGlId = this.cashVaultGlId;
|
|
||||||
|
|
||||||
// isDeposit (CR button) = cashier receives cash → credit selected GL, debit contra (cash vault)
|
|
||||||
// isWithdrawal (DR button) = cashier pays out cash → debit selected GL, credit contra (cash vault)
|
|
||||||
const credits = isDeposit ? [{ glAccountId: selectedGlId, amount }] : [{ glAccountId: contraGlId, amount }];
|
|
||||||
const debits = isDeposit ? [{ glAccountId: contraGlId, amount }] : [{ glAccountId: selectedGlId, amount }];
|
|
||||||
|
|
||||||
const payload: any = {
|
|
||||||
officeId: row.br || 1,
|
|
||||||
transactionDate: txnDate,
|
|
||||||
dateFormat: 'dd/MM/yyyy',
|
|
||||||
locale: 'en',
|
|
||||||
currencyCode: this.accountDetails?.currency?.code ?? 'INR',
|
|
||||||
comments: row.description || null,
|
|
||||||
credits,
|
|
||||||
debits,
|
|
||||||
paymentTypeId: row.paymentTypeId
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check if cash payment so we can record denomination after journal entry
|
|
||||||
const selectedPt = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
|
|
||||||
const isCashPayment = !!selectedPt?.isCashPayment;
|
|
||||||
|
|
||||||
this.saving = true;
|
|
||||||
this.http
|
|
||||||
.post('/journalentries', payload)
|
|
||||||
.pipe(
|
|
||||||
catchError((err: any) => {
|
|
||||||
const msg =
|
|
||||||
err?.error?.errors?.[0]?.defaultUserMessage ??
|
|
||||||
err?.error?.developerMessage ??
|
|
||||||
'Journal entry failed. Please try again.';
|
|
||||||
this.showAlert('error', 'Transaction Failed', [msg]);
|
|
||||||
this.saving = false;
|
|
||||||
return of(null);
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.subscribe((res: any) => {
|
|
||||||
this.saving = false;
|
|
||||||
if (res) {
|
|
||||||
// Record denomination so cashier position stays accurate
|
|
||||||
// Same condition as savings: cash + denom entered + denom matches posted amount
|
|
||||||
if (isCashPayment && this.denominationTotal > 0 && this.denominationTotal === amount) {
|
|
||||||
const denomBreakdown = [
|
|
||||||
...this.notes,
|
|
||||||
...this.coins
|
|
||||||
]
|
|
||||||
.filter((d) => this.countFor(d) > 0)
|
|
||||||
.map((d) => ({
|
|
||||||
denominationId: d.id,
|
|
||||||
denominationValue: d.value,
|
|
||||||
kind: d.kind,
|
|
||||||
count: this.countFor(d),
|
|
||||||
subtotal: this.subtotalFor(d)
|
|
||||||
}));
|
|
||||||
// Filed against the Cash Vault GL, not the account the teller selected on screen: that
|
|
||||||
// selected account (e.g. Sundry Creditors) is the other leg of the journal entry, and
|
|
||||||
// never the one whose physical notes actually changed hands. Tagging the breakdown to it
|
|
||||||
// instead of the vault is why this cash never reached the cashier/vault position.
|
|
||||||
this.http
|
|
||||||
.post('/cashier/gl-denomination', {
|
|
||||||
txnId: res.transactionId ?? res.resourceId,
|
|
||||||
accountNo: this.cashVaultGlCode ?? row.acNo,
|
|
||||||
accountName: this.cashVaultGlName ?? 'Cash at Vault',
|
|
||||||
txnType: isDeposit ? 'DEPOSIT' : 'WITHDRAWAL',
|
|
||||||
txnDate: txnDateIso,
|
|
||||||
paymentTypeName: selectedPt?.name ?? 'Cash',
|
|
||||||
denominationBreakdown: denomBreakdown
|
|
||||||
})
|
|
||||||
.pipe(catchError(() => of(null)))
|
|
||||||
.subscribe();
|
|
||||||
}
|
|
||||||
|
|
||||||
const ref = res.transactionId ?? res.resourceId ?? 'N/A';
|
|
||||||
this.showAlert('success', 'GL Transaction Posted', [
|
|
||||||
`Journal entry of ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })} posted successfully.`,
|
|
||||||
`Transaction ID: ${ref}`
|
|
||||||
]);
|
|
||||||
this.clearDenominations();
|
|
||||||
this.loadOpeningPosition();
|
|
||||||
this.loadAccountDetails(row.acNo.trim(), row.acTyp);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
onSearch(): void {
|
onSearch(): void {
|
||||||
if (this.selectedRow?.acNo.trim()) {
|
if (this.selectedRow?.acNo.trim()) {
|
||||||
this.loadAccountDetails(this.selectedRow.acNo.trim(), this.selectedRow.acTyp);
|
this.loadAccountDetails(this.selectedRow.acNo.trim(), this.selectedRow.acTyp);
|
||||||
|
|||||||
+170
@@ -0,0 +1,170 @@
|
|||||||
|
<!--
|
||||||
|
Copyright since 2025 Mifos Initiative
|
||||||
|
|
||||||
|
This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<div class="cv-tab-content vca-tab">
|
||||||
|
<!-- ── TITLE BAR ── -->
|
||||||
|
<div class="vca-action-bar">
|
||||||
|
<div class="vca-action-bar__left">
|
||||||
|
<mat-icon class="vca-action-bar__icon">undo</mat-icon>
|
||||||
|
<div>
|
||||||
|
<div class="vca-action-bar__title">Voucher Corrections</div>
|
||||||
|
<div class="vca-action-bar__sub">
|
||||||
|
Approve or reject a voucher unpass, and the correction entry that follows it. Only the correction approval
|
||||||
|
moves money.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="vca-counts">
|
||||||
|
<span class="vca-count"
|
||||||
|
><strong>{{ unpassCount }}</strong> unpass</span
|
||||||
|
>
|
||||||
|
<span class="vca-count"
|
||||||
|
><strong>{{ correctionCount }}</strong> correction</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── FILTER BAR ── -->
|
||||||
|
<div class="vca-filter-bar">
|
||||||
|
<div class="vca-search-box">
|
||||||
|
<mat-icon>search</mat-icon>
|
||||||
|
<input type="text" placeholder="Search by voucher, account, customer or user" [(ngModel)]="searchTerm" />
|
||||||
|
@if (searchTerm) {
|
||||||
|
<button type="button" class="vca-search-box__clear" (click)="searchTerm = ''">
|
||||||
|
<mat-icon>close</mat-icon>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="vca-fb-sep"></span>
|
||||||
|
|
||||||
|
<select class="vca-filter-select" [(ngModel)]="kindFilter">
|
||||||
|
@for (opt of kindOptions; track opt.value) {
|
||||||
|
<option [value]="opt.value">{{ opt.label }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button type="button" class="vca-btn vca-btn--refresh" (click)="load()" [disabled]="loading">
|
||||||
|
<mat-icon>refresh</mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── TABLE ── -->
|
||||||
|
@if (loading && !loaded) {
|
||||||
|
<div class="vca-empty"><p class="vca-empty__title">Loading…</p></div>
|
||||||
|
} @else if (rows.length === 0) {
|
||||||
|
<div class="vca-empty">
|
||||||
|
<mat-icon class="vca-empty__icon">inbox</mat-icon>
|
||||||
|
<p class="vca-empty__title">No voucher correction waiting</p>
|
||||||
|
<p class="vca-empty__text">There is no unpass request or correction entry waiting for review.</p>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="vca-table-wrap">
|
||||||
|
<table class="vca-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Voucher No</th>
|
||||||
|
<th>Request</th>
|
||||||
|
<th>Posted To (Wrong)</th>
|
||||||
|
<th>Correct Account</th>
|
||||||
|
<th class="num">Amount</th>
|
||||||
|
<th>Reason</th>
|
||||||
|
<th>Raised By</th>
|
||||||
|
<th>Raised At</th>
|
||||||
|
<th class="vca-th-action">Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (r of rows; track r.request.id) {
|
||||||
|
<tr class="vca-row">
|
||||||
|
<td class="vca-mono vca-voucher">{{ r.request.original_voucher_no }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="vca-kind" [class]="'vca-kind vca-kind--' + r.kind">{{ kindLabel(r.kind) }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="vca-mono">{{ r.request.wrong_account_no }}</div>
|
||||||
|
<div class="vca-sub">{{ r.request.wrong_client_name || '—' }}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if (r.kind === 'correction') {
|
||||||
|
<div class="vca-mono vca-strong">{{ r.request.correct_account_no }}</div>
|
||||||
|
<div class="vca-sub">{{ r.request.correct_client_name || '—' }}</div>
|
||||||
|
} @else {
|
||||||
|
<span class="vca-none">—</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="num vca-amount">₹{{ r.request.amount | number: '1.2-2' }}</td>
|
||||||
|
<td class="vca-reason" [title]="r.request.reason || ''">{{ r.request.reason || '—' }}</td>
|
||||||
|
<td>{{ r.raisedBy || '—' }}</td>
|
||||||
|
<td class="vca-dt">{{ formatDateTime(r.raisedAt) }}</td>
|
||||||
|
<td class="vca-action-cell">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="vca-btn vca-btn--reject"
|
||||||
|
[disabled]="busyId === r.request.id"
|
||||||
|
(click)="startReject(r)"
|
||||||
|
>
|
||||||
|
<mat-icon>close</mat-icon> Reject
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="vca-btn vca-btn--approve"
|
||||||
|
[disabled]="busyId === r.request.id"
|
||||||
|
(click)="approve(r)"
|
||||||
|
>
|
||||||
|
<mat-icon>check</mat-icon>
|
||||||
|
{{ r.kind === 'unpass' ? 'Approve unpass' : 'Approve correction' }}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ─── Popup: reject with a reason ───────────────────────────────────────── -->
|
||||||
|
<ng-template #rejectDialog>
|
||||||
|
@if (rejecting) {
|
||||||
|
<div class="vca-dlg">
|
||||||
|
<header class="vca-dlg__head">
|
||||||
|
<mat-icon class="vca-dlg__ico">block</mat-icon>
|
||||||
|
<div class="vca-dlg__titles">
|
||||||
|
<div class="vca-dlg__title">
|
||||||
|
Reject {{ rejecting.kind === 'unpass' ? 'unpass request' : 'correction entry' }}
|
||||||
|
</div>
|
||||||
|
<div class="vca-dlg__sub vca-mono">{{ rejecting.request.original_voucher_no }}</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="vca-dlg__x" aria-label="Close" (click)="cancelReject()">
|
||||||
|
<mat-icon>close</mat-icon>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div class="vca-dlg__body">
|
||||||
|
<label class="vca-label" for="vca-reason">Reason (required)</label>
|
||||||
|
<textarea
|
||||||
|
id="vca-reason"
|
||||||
|
rows="3"
|
||||||
|
placeholder="Why is this being rejected?"
|
||||||
|
[(ngModel)]="rejectReason"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
<footer class="vca-dlg__foot">
|
||||||
|
<button type="button" class="vca-btn vca-btn--ghost" (click)="cancelReject()">Cancel</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="vca-btn vca-btn--reject-solid"
|
||||||
|
[disabled]="!rejectReason.trim() || busyId === rejecting.request.id"
|
||||||
|
(click)="confirmReject()"
|
||||||
|
>
|
||||||
|
Confirm reject
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</ng-template>
|
||||||
+485
@@ -0,0 +1,485 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Laid out like the other approval tabs (Withdrawal Requests): title bar, filter bar, one table.
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vca-tab {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vca-action-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
|
||||||
|
&__left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
font-size: 22px;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
color: #0f4f3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0d1b2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__sub {
|
||||||
|
max-width: 640px;
|
||||||
|
margin-top: 1px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.vca-counts {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vca-count {
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #475569;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: #0f4f3c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.vca-filter-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 6px 16px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vca-fb-sep {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 1px;
|
||||||
|
height: 22px;
|
||||||
|
margin: 0 4px;
|
||||||
|
background: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vca-search-box {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
height: 30px;
|
||||||
|
min-width: 240px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 17px;
|
||||||
|
width: 17px;
|
||||||
|
height: 17px;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-within {
|
||||||
|
border-color: #0f4f3c;
|
||||||
|
box-shadow: 0 0 0 3px rgb(15 79 60 / 8%);
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
flex: 1;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #0d1b2a;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__clear {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: none;
|
||||||
|
color: #94a3b8;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 15px;
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #dc2626;
|
||||||
|
background: #fef2f2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.vca-filter-select {
|
||||||
|
height: 30px;
|
||||||
|
min-width: 140px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: #fff;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #0d1b2a;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #0f4f3c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Buttons ─────────────────────────────────────────────────────────────────
|
||||||
|
.vca-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
height: 26px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 7px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--refresh {
|
||||||
|
height: 30px;
|
||||||
|
width: 30px;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
border-color: #e2e8f0;
|
||||||
|
background: #fff;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--approve {
|
||||||
|
background: #0f4f3c;
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: #0a3d2e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--reject {
|
||||||
|
border-color: #fecaca;
|
||||||
|
background: #fff;
|
||||||
|
color: #dc2626;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: #fef2f2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--reject-solid {
|
||||||
|
background: #dc2626;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--ghost {
|
||||||
|
border-color: #e2e8f0;
|
||||||
|
background: #fff;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Table ───────────────────────────────────────────────────────────────────
|
||||||
|
.vca-table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vca-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 11px;
|
||||||
|
|
||||||
|
th {
|
||||||
|
padding: 7px 10px;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
background: #f8fafc;
|
||||||
|
font-size: 9.5px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-align: left;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #94a3b8;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-bottom: 1px solid #eef2f7;
|
||||||
|
vertical-align: middle;
|
||||||
|
color: #0d1b2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.num {
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vca-th-action {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.vca-row:hover td {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vca-mono {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vca-voucher {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f4f3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vca-strong {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vca-sub {
|
||||||
|
margin-top: 1px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vca-none {
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vca-amount {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vca-reason {
|
||||||
|
max-width: 220px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vca-dt {
|
||||||
|
white-space: nowrap;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vca-kind {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
|
||||||
|
&--unpass {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--correction {
|
||||||
|
background: #dbeafe;
|
||||||
|
color: #1e40af;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.vca-action-cell {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Empty ───────────────────────────────────────────────────────────────────
|
||||||
|
.vca-empty {
|
||||||
|
padding: 28px 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: #94a3b8;
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto 6px;
|
||||||
|
font-size: 30px;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__text {
|
||||||
|
margin: 3px 0 0;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Reject popup ────────────────────────────────────────────────────────────
|
||||||
|
.vca-dlg {
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
&__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__ico {
|
||||||
|
flex: 0 0 32px;
|
||||||
|
height: 32px;
|
||||||
|
width: 32px;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #dc2626;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 32px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__titles {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__x {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 28px;
|
||||||
|
width: 28px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: transparent;
|
||||||
|
color: #64748b;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
height: 18px;
|
||||||
|
width: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__body {
|
||||||
|
padding: 12px 14px;
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 8px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
resize: vertical;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: 0;
|
||||||
|
border-color: #0f4f3c;
|
||||||
|
box-shadow: 0 0 0 3px rgb(15 79 60 / 8%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__foot {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-top: 1px solid #e2e8f0;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.vca-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
+192
@@ -0,0 +1,192 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, OnInit, TemplateRef, ViewChild, inject } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
import { catchError } from 'rxjs/operators';
|
||||||
|
import { VoucherCorrection, VoucherCorrectionService } from '../voucher-correction.service';
|
||||||
|
|
||||||
|
type Kind = 'unpass' | 'correction';
|
||||||
|
type KindFilter = 'ALL' | Kind;
|
||||||
|
|
||||||
|
/** One row of the approvals table: a request, and which of the two steps it is waiting at. */
|
||||||
|
interface ApprovalRow {
|
||||||
|
kind: Kind;
|
||||||
|
request: VoucherCorrection;
|
||||||
|
raisedBy: string | null;
|
||||||
|
raisedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manager side of the voucher correction, laid out like the other approval tabs: approves or rejects the unpass of a
|
||||||
|
* voucher, and later the correction entry the cashier made. Only the second approval moves money.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-voucher-correction-approvals',
|
||||||
|
templateUrl: './voucher-correction-approvals.component.html',
|
||||||
|
styleUrls: ['./voucher-correction-approvals.component.scss'],
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
MatDialogModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatSnackBarModule
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class VoucherCorrectionApprovalsComponent implements OnInit {
|
||||||
|
private service = inject(VoucherCorrectionService);
|
||||||
|
private snackBar = inject(MatSnackBar);
|
||||||
|
private dialog = inject(MatDialog);
|
||||||
|
|
||||||
|
@ViewChild('rejectDialog') rejectDialog!: TemplateRef<unknown>;
|
||||||
|
private dialogRef: MatDialogRef<unknown> | null = null;
|
||||||
|
|
||||||
|
all: ApprovalRow[] = [];
|
||||||
|
loading = false;
|
||||||
|
loaded = false;
|
||||||
|
busyId: number | null = null;
|
||||||
|
|
||||||
|
kindFilter: KindFilter = 'ALL';
|
||||||
|
searchTerm = '';
|
||||||
|
|
||||||
|
/** The row being rejected, and the reason typed for it. */
|
||||||
|
rejecting: ApprovalRow | null = null;
|
||||||
|
rejectReason = '';
|
||||||
|
|
||||||
|
readonly kindOptions: { value: KindFilter; label: string }[] = [
|
||||||
|
{ value: 'ALL', label: 'All requests' },
|
||||||
|
{ value: 'unpass', label: 'Unpass requests' },
|
||||||
|
{ value: 'correction', label: 'Correction entries' }
|
||||||
|
];
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
load(): void {
|
||||||
|
this.loading = true;
|
||||||
|
this.service
|
||||||
|
.list('UNPASS_PENDING,CORRECTION_PENDING')
|
||||||
|
.pipe(catchError(() => of([] as VoucherCorrection[])))
|
||||||
|
.subscribe((rows) => {
|
||||||
|
this.all = rows.map((r) =>
|
||||||
|
r.status === 'UNPASS_PENDING'
|
||||||
|
? { kind: 'unpass', request: r, raisedBy: r.unpass_by_name, raisedAt: r.unpass_at }
|
||||||
|
: { kind: 'correction', request: r, raisedBy: r.correction_by_name, raisedAt: r.correction_at }
|
||||||
|
);
|
||||||
|
this.loading = false;
|
||||||
|
this.loaded = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The table: the kind picked, narrowed by whatever is typed in the search box. */
|
||||||
|
get rows(): ApprovalRow[] {
|
||||||
|
const term = this.searchTerm.trim().toLowerCase();
|
||||||
|
return this.all.filter((r) => {
|
||||||
|
if (this.kindFilter !== 'ALL' && r.kind !== this.kindFilter) return false;
|
||||||
|
if (!term) return true;
|
||||||
|
const q = r.request;
|
||||||
|
return [
|
||||||
|
q.original_voucher_no,
|
||||||
|
q.wrong_account_no,
|
||||||
|
q.wrong_client_name,
|
||||||
|
q.correct_account_no,
|
||||||
|
q.correct_client_name,
|
||||||
|
r.raisedBy
|
||||||
|
].some((v) => (v ?? '').toLowerCase().includes(term));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get unpassCount(): number {
|
||||||
|
return this.all.filter((r) => r.kind === 'unpass').length;
|
||||||
|
}
|
||||||
|
|
||||||
|
get correctionCount(): number {
|
||||||
|
return this.all.filter((r) => r.kind === 'correction').length;
|
||||||
|
}
|
||||||
|
|
||||||
|
kindLabel(kind: Kind): string {
|
||||||
|
return kind === 'unpass' ? 'Unpass' : 'Correction';
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDateTime(value: string | null): string {
|
||||||
|
if (!value) return '—';
|
||||||
|
const d = new Date(value);
|
||||||
|
return isNaN(d.getTime())
|
||||||
|
? value
|
||||||
|
: d.toLocaleString('en-IN', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
approve(row: ApprovalRow): void {
|
||||||
|
if (this.busyId) return;
|
||||||
|
const id = row.request.id;
|
||||||
|
this.busyId = id;
|
||||||
|
const call = row.kind === 'unpass' ? this.service.approveUnpass(id) : this.service.approveCorrection(id);
|
||||||
|
call.subscribe({
|
||||||
|
next: (res: any) => {
|
||||||
|
this.busyId = null;
|
||||||
|
this.snackBar.open(
|
||||||
|
row.kind === 'unpass'
|
||||||
|
? `Voucher ${row.request.original_voucher_no} unpassed — the cashier can now enter the correct account`
|
||||||
|
: `Corrected. New voucher ${res?.newVoucherNo ?? ''}`,
|
||||||
|
'Close',
|
||||||
|
{ duration: 6000 }
|
||||||
|
);
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.busyId = null;
|
||||||
|
this.fail(err, 'Could not approve');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
startReject(row: ApprovalRow): void {
|
||||||
|
this.rejecting = row;
|
||||||
|
this.rejectReason = '';
|
||||||
|
this.dialogRef = this.dialog.open(this.rejectDialog, { width: '440px', maxWidth: '95vw', autoFocus: false });
|
||||||
|
this.dialogRef.afterClosed().subscribe(() => (this.rejecting = null));
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelReject(): void {
|
||||||
|
this.dialogRef?.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmReject(): void {
|
||||||
|
if (!this.rejecting || !this.rejectReason.trim() || this.busyId) return;
|
||||||
|
const row = this.rejecting;
|
||||||
|
const id = row.request.id;
|
||||||
|
this.busyId = id;
|
||||||
|
const reason = this.rejectReason.trim();
|
||||||
|
const call =
|
||||||
|
row.kind === 'unpass' ? this.service.rejectUnpass(id, reason) : this.service.rejectCorrection(id, reason);
|
||||||
|
call.subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.busyId = null;
|
||||||
|
this.dialogRef?.close();
|
||||||
|
this.snackBar.open('Rejected', 'Close', { duration: 4000 });
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.busyId = null;
|
||||||
|
this.fail(err, 'Could not reject');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private fail(err: any, fallback: string): void {
|
||||||
|
const msg = err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.defaultUserMessage ?? fallback;
|
||||||
|
this.snackBar.open(msg, 'Close', { duration: 8000 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
|
/** One row of a voucher correction, as the server keeps it (snake_case columns). */
|
||||||
|
export interface VoucherCorrection {
|
||||||
|
id: number;
|
||||||
|
original_voucher_no: string;
|
||||||
|
original_txn_id: number;
|
||||||
|
wrong_savings_account_id: number;
|
||||||
|
wrong_account_no: string;
|
||||||
|
wrong_client_name: string | null;
|
||||||
|
amount: number;
|
||||||
|
txn_date: string | null;
|
||||||
|
status: 'UNPASS_PENDING' | 'UNPASSED' | 'UNPASS_REJECTED' | 'CORRECTION_PENDING' | 'COMPLETED' | 'CLOSED';
|
||||||
|
reason: string | null;
|
||||||
|
rejection_reason: string | null;
|
||||||
|
unpass_by_name: string | null;
|
||||||
|
unpass_at: string | null;
|
||||||
|
unpass_approved_by_name: string | null;
|
||||||
|
correct_account_no: string | null;
|
||||||
|
correct_client_name: string | null;
|
||||||
|
correction_by_name: string | null;
|
||||||
|
correction_at: string | null;
|
||||||
|
correction_approved_by_name: string | null;
|
||||||
|
new_voucher_no: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A cash deposit voucher of today that can still be unpassed. */
|
||||||
|
export interface EligibleVoucher {
|
||||||
|
voucherNo: string;
|
||||||
|
amount: number;
|
||||||
|
createdAt: string;
|
||||||
|
cashier: string;
|
||||||
|
txnId: number;
|
||||||
|
savingsAccountId: number;
|
||||||
|
accountNo: string;
|
||||||
|
clientName: string | null;
|
||||||
|
paymentType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the server knows about a savings account typed in as the correct one. */
|
||||||
|
export interface AccountLookup {
|
||||||
|
id: number;
|
||||||
|
accountNo: string;
|
||||||
|
clientId: number | null;
|
||||||
|
clientName: string | null;
|
||||||
|
balance: number;
|
||||||
|
currencyCode: string;
|
||||||
|
canReceive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** HTTP service for the voucher correction workflow: unpass, approve, re-enter, approve. */
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class VoucherCorrectionService {
|
||||||
|
private http = inject(HttpClient);
|
||||||
|
private readonly base = '/vouchers/corrections';
|
||||||
|
|
||||||
|
eligible(search?: string): Observable<EligibleVoucher[]> {
|
||||||
|
let params = new HttpParams();
|
||||||
|
if (search) params = params.set('search', search);
|
||||||
|
return this.http.get<EligibleVoucher[]>(`${this.base}/eligible`, { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
unpass(voucherNo: string, reason: string): Observable<any> {
|
||||||
|
return this.http.post(`${this.base}/unpass`, { voucherNo, reason });
|
||||||
|
}
|
||||||
|
|
||||||
|
list(status?: string, mine = false): Observable<VoucherCorrection[]> {
|
||||||
|
let params = new HttpParams().set('mine', String(mine));
|
||||||
|
if (status) params = params.set('status', status);
|
||||||
|
return this.http.get<VoucherCorrection[]>(this.base, { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
get(id: number): Observable<VoucherCorrection> {
|
||||||
|
return this.http.get<VoucherCorrection>(`${this.base}/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
accountLookup(accountNo: string): Observable<AccountLookup> {
|
||||||
|
return this.http.get<AccountLookup>(`${this.base}/account-lookup`, { params: { accountNo } });
|
||||||
|
}
|
||||||
|
|
||||||
|
submitCorrection(id: number, correctAccountNo: string): Observable<any> {
|
||||||
|
return this.http.post(`${this.base}/${id}/correction`, { correctAccountNo });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The cashier gives up the correction, e.g. when the wrong account no longer holds the money. */
|
||||||
|
close(id: number, reason: string): Observable<any> {
|
||||||
|
return this.http.post(`${this.base}/${id}/close`, { reason });
|
||||||
|
}
|
||||||
|
|
||||||
|
approveUnpass(id: number): Observable<any> {
|
||||||
|
return this.http.post(`${this.base}/${id}/unpass/approve`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
rejectUnpass(id: number, reason: string): Observable<any> {
|
||||||
|
return this.http.post(`${this.base}/${id}/unpass/reject`, { reason });
|
||||||
|
}
|
||||||
|
|
||||||
|
approveCorrection(id: number): Observable<any> {
|
||||||
|
return this.http.post(`${this.base}/${id}/correction/approve`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
rejectCorrection(id: number, reason: string): Observable<any> {
|
||||||
|
return this.http.post(`${this.base}/${id}/correction/reject`, { reason });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ids of the transactions of this account that a completed correction removed. */
|
||||||
|
voidedTransactions(savingsAccountId: number): Observable<number[]> {
|
||||||
|
return this.http.get<number[]>(`${this.base}/voided-transactions`, { params: { savingsAccountId } });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,446 @@
|
|||||||
|
<!--
|
||||||
|
Copyright since 2025 Mifos Initiative
|
||||||
|
|
||||||
|
This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<div class="pg">
|
||||||
|
<header class="pg-head">
|
||||||
|
<div>
|
||||||
|
<div class="pg-eyebrow">CASHIER · VOUCHER CORRECTION</div>
|
||||||
|
<h1 class="pg-title">Voucher Correction</h1>
|
||||||
|
<p class="pg-sub">
|
||||||
|
Move a cash deposit that was posted to the wrong account. Nothing changes on any account until the final
|
||||||
|
approval.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- The four steps, so it is always clear where a voucher is. -->
|
||||||
|
<ol class="vc-steps">
|
||||||
|
<li class="vc-step" [class.vc-step--on]="tab === 'today'">
|
||||||
|
<span class="vc-step__no">1</span>
|
||||||
|
<span class="vc-step__txt"><strong>Unpass</strong> the voucher</span>
|
||||||
|
</li>
|
||||||
|
<li class="vc-step">
|
||||||
|
<span class="vc-step__no">2</span>
|
||||||
|
<span class="vc-step__txt"><strong>Manager</strong> approves</span>
|
||||||
|
</li>
|
||||||
|
<li class="vc-step" [class.vc-step--on]="tab === 'unpassed'">
|
||||||
|
<span class="vc-step__no">3</span>
|
||||||
|
<span class="vc-step__txt">Enter the <strong>correct account</strong></span>
|
||||||
|
</li>
|
||||||
|
<li class="vc-step">
|
||||||
|
<span class="vc-step__no">4</span>
|
||||||
|
<span class="vc-step__txt"><strong>Manager</strong> approves, deposit moves</span>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<nav class="vc-tabs">
|
||||||
|
@if (mayUnpass) {
|
||||||
|
<button type="button" class="vc-tab" [class.vc-tab--on]="tab === 'today'" (click)="setTab('today')">
|
||||||
|
<mat-icon>receipt_long</mat-icon> Today's Vouchers
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@if (mayCorrect) {
|
||||||
|
<button type="button" class="vc-tab" [class.vc-tab--on]="tab === 'unpassed'" (click)="setTab('unpassed')">
|
||||||
|
<mat-icon>edit_note</mat-icon> Unpassed — Correct Entry
|
||||||
|
@if (unpassed.length) {
|
||||||
|
<span class="vc-count">{{ unpassed.length }}</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
<button type="button" class="vc-tab" [class.vc-tab--on]="tab === 'mine'" (click)="setTab('mine')">
|
||||||
|
<mat-icon>history</mat-icon> My Requests
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- ─── Today's vouchers ─────────────────────────────────────────────── -->
|
||||||
|
@if (tab === 'today') {
|
||||||
|
<div class="vc-toolbar">
|
||||||
|
<div class="vc-search">
|
||||||
|
<mat-icon>search</mat-icon>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search by voucher, account or customer name"
|
||||||
|
[(ngModel)]="search"
|
||||||
|
(keyup.enter)="loadVouchers()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="pg-btn pg-btn--primary" (click)="loadVouchers()">
|
||||||
|
<mat-icon>search</mat-icon> Search
|
||||||
|
</button>
|
||||||
|
<button type="button" class="pg-btn pg-btn--ghost" (click)="search = ''; loadVouchers()">
|
||||||
|
<mat-icon>refresh</mat-icon> Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pg-card">
|
||||||
|
<table class="pg-table vc-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Voucher</th>
|
||||||
|
<th>Account</th>
|
||||||
|
<th>Customer</th>
|
||||||
|
<th>Mode</th>
|
||||||
|
<th>Cashier</th>
|
||||||
|
<th class="num">Amount</th>
|
||||||
|
<th class="vc-col-action"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (v of vouchers; track v.voucherNo) {
|
||||||
|
<tr [class.vc-row--on]="selectedVoucher?.voucherNo === v.voucherNo" (click)="pick(v)">
|
||||||
|
<td>
|
||||||
|
<span class="pg-chip">{{ v.voucherNo }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="vc-mono">{{ v.accountNo }}</td>
|
||||||
|
<td>{{ v.clientName || '—' }}</td>
|
||||||
|
<td>{{ v.paymentType }}</td>
|
||||||
|
<td>{{ v.cashier }}</td>
|
||||||
|
<td class="num">₹{{ v.amount | number: '1.2-2' }}</td>
|
||||||
|
<td class="vc-col-action">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--ghost vc-btn-sm"
|
||||||
|
(click)="pick(v); $event.stopPropagation()"
|
||||||
|
>
|
||||||
|
<mat-icon>undo</mat-icon> Unpass
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
@if (!vouchers.length) {
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="vc-empty">
|
||||||
|
<mat-icon>{{ loadingVouchers ? 'hourglass_empty' : 'inbox' }}</mat-icon>
|
||||||
|
<div>{{ loadingVouchers ? 'Loading…' : 'No cash deposit voucher of today can be unpassed.' }}</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- ─── Unpassed: enter the correct account ──────────────────────────── -->
|
||||||
|
@if (tab === 'unpassed') {
|
||||||
|
<div class="pg-card">
|
||||||
|
<table class="pg-table vc-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Voucher</th>
|
||||||
|
<th>Posted to</th>
|
||||||
|
<th>Customer</th>
|
||||||
|
<th>Reason</th>
|
||||||
|
<th class="num">Amount</th>
|
||||||
|
<th class="vc-col-action"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (u of unpassed; track u.id) {
|
||||||
|
<tr [class.vc-row--on]="active?.id === u.id" (click)="open(u)">
|
||||||
|
<td>
|
||||||
|
<span class="pg-chip">{{ u.original_voucher_no }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="vc-mono">{{ u.wrong_account_no }}</td>
|
||||||
|
<td>{{ u.wrong_client_name || '—' }}</td>
|
||||||
|
<td class="vc-clip">{{ u.reason }}</td>
|
||||||
|
<td class="num">₹{{ u.amount | number: '1.2-2' }}</td>
|
||||||
|
<td class="vc-col-action">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--primary vc-btn-sm"
|
||||||
|
(click)="open(u); $event.stopPropagation()"
|
||||||
|
>
|
||||||
|
<mat-icon>edit</mat-icon> Correct
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
@if (!unpassed.length) {
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="vc-empty">
|
||||||
|
<mat-icon>{{ loadingUnpassed ? 'hourglass_empty' : 'task_alt' }}</mat-icon>
|
||||||
|
<div>
|
||||||
|
{{ loadingUnpassed ? 'Loading…' : 'No unpassed voucher is waiting for its correct account.' }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- ─── My requests ──────────────────────────────────────────────────── -->
|
||||||
|
@if (tab === 'mine') {
|
||||||
|
<div class="pg-card">
|
||||||
|
<table class="pg-table vc-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Voucher</th>
|
||||||
|
<th>Posted to</th>
|
||||||
|
<th>Corrected to</th>
|
||||||
|
<th class="num">Amount</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>New voucher</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (m of mine; track m.id) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span class="pg-chip">{{ m.original_voucher_no }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="vc-mono">{{ m.wrong_account_no }}</td>
|
||||||
|
<td class="vc-mono">{{ m.correct_account_no || '—' }}</td>
|
||||||
|
<td class="num">₹{{ m.amount | number: '1.2-2' }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="vc-status vc-status--{{ m.status }}">{{ statusLabel[m.status] }}</span>
|
||||||
|
@if (m.rejection_reason) {
|
||||||
|
<div class="vc-note">{{ m.rejection_reason }}</div>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>{{ m.new_voucher_no || '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
@if (!mine.length) {
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="vc-empty">
|
||||||
|
<mat-icon>{{ loadingMine ? 'hourglass_empty' : 'history' }}</mat-icon>
|
||||||
|
<div>{{ loadingMine ? 'Loading…' : 'You have no voucher corrections yet.' }}</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ─── Popup: unpass a voucher ──────────────────────────────────────────── -->
|
||||||
|
<ng-template #unpassDialog>
|
||||||
|
@if (selectedVoucher) {
|
||||||
|
<div class="vc-dlg">
|
||||||
|
<header class="vc-dlg__head">
|
||||||
|
<mat-icon class="vc-dlg__ico">undo</mat-icon>
|
||||||
|
<div class="vc-dlg__titles">
|
||||||
|
<div class="vc-dlg__title">Unpass voucher</div>
|
||||||
|
<div class="vc-dlg__sub vc-mono">{{ selectedVoucher.voucherNo }}</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="vc-dlg__x" aria-label="Close" (click)="closeUnpass()">
|
||||||
|
<mat-icon>close</mat-icon>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="vc-dlg__body">
|
||||||
|
<div class="vc-dlg__amount">
|
||||||
|
<span>Deposit amount</span>
|
||||||
|
<strong>₹{{ selectedVoucher.amount | number: '1.2-2' }}</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="vc-grid">
|
||||||
|
<div class="vc-cell">
|
||||||
|
<div class="vc-label">Voucher</div>
|
||||||
|
<div class="vc-value vc-mono">{{ selectedVoucher.voucherNo }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="vc-cell">
|
||||||
|
<div class="vc-label">Created</div>
|
||||||
|
<div class="vc-value">{{ selectedVoucher.createdAt | date: 'dd MMM yyyy, hh:mm a' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="vc-cell">
|
||||||
|
<div class="vc-label">Cashier</div>
|
||||||
|
<div class="vc-value">{{ selectedVoucher.cashier }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="vc-cell">
|
||||||
|
<div class="vc-label">Mode</div>
|
||||||
|
<div class="vc-value">{{ selectedVoucher.paymentType }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="vc-cell">
|
||||||
|
<div class="vc-label">Account</div>
|
||||||
|
<div class="vc-value vc-mono">{{ selectedVoucher.accountNo }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="vc-cell">
|
||||||
|
<div class="vc-label">Customer</div>
|
||||||
|
<div class="vc-value">{{ selectedVoucher.clientName || '—' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="vc-cell">
|
||||||
|
<div class="vc-label">Account balance now</div>
|
||||||
|
<div class="vc-value">
|
||||||
|
@if (voucherAccount) {
|
||||||
|
₹{{ voucherAccount.balance | number: '1.2-2' }}
|
||||||
|
} @else {
|
||||||
|
—
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="vc-cell">
|
||||||
|
<div class="vc-label">Account status</div>
|
||||||
|
<div class="vc-value">
|
||||||
|
@if (voucherAccount) {
|
||||||
|
{{ voucherAccount.canReceive ? 'Active' : 'Not active' }} · {{ voucherAccount.currencyCode }}
|
||||||
|
} @else {
|
||||||
|
—
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="vc-label" for="vc-reason">Reason for unpassing</label>
|
||||||
|
<textarea
|
||||||
|
id="vc-reason"
|
||||||
|
class="vc-reason"
|
||||||
|
rows="2"
|
||||||
|
placeholder="Why is this voucher wrong? e.g. deposited in the wrong account (required)"
|
||||||
|
[(ngModel)]="unpassReason"
|
||||||
|
></textarea>
|
||||||
|
|
||||||
|
<div class="vc-info">
|
||||||
|
<mat-icon>info</mat-icon>
|
||||||
|
A manager approves the unpass first. Nothing changes on any account until the corrected entry is approved: the
|
||||||
|
deposit stays on {{ selectedVoucher.accountNo }} till then.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="vc-dlg__foot">
|
||||||
|
<button type="button" class="pg-btn pg-btn--ghost" (click)="closeUnpass()">Cancel</button>
|
||||||
|
<button type="button" class="pg-btn pg-btn--primary" [disabled]="!canUnpass" (click)="unpass()">
|
||||||
|
<mat-icon>send</mat-icon> Send for approval
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
|
<!-- ─── Popup: enter the correct account ─────────────────────────────────── -->
|
||||||
|
<ng-template #correctionDialog>
|
||||||
|
@if (active) {
|
||||||
|
<div class="vc-dlg">
|
||||||
|
<header class="vc-dlg__head">
|
||||||
|
<mat-icon class="vc-dlg__ico">edit_note</mat-icon>
|
||||||
|
<div class="vc-dlg__titles">
|
||||||
|
<div class="vc-dlg__title">Correction entry</div>
|
||||||
|
<div class="vc-dlg__sub vc-mono">{{ active.original_voucher_no }}</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="vc-dlg__x" aria-label="Close" (click)="closeActive()">
|
||||||
|
<mat-icon>close</mat-icon>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="vc-dlg__body">
|
||||||
|
<div class="vc-dlg__amount">
|
||||||
|
<span>Amount to move</span>
|
||||||
|
<strong>₹{{ active.amount | number: '1.2-2' }}</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Everything but the account number is fixed. -->
|
||||||
|
<div class="vc-grid">
|
||||||
|
<div class="vc-cell">
|
||||||
|
<div class="vc-label">Transaction date</div>
|
||||||
|
<div class="vc-value">{{ active.txn_date || '—' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="vc-cell">
|
||||||
|
<div class="vc-label">Mode</div>
|
||||||
|
<div class="vc-value">Cash</div>
|
||||||
|
</div>
|
||||||
|
<div class="vc-cell">
|
||||||
|
<div class="vc-label">Unpassed by</div>
|
||||||
|
<div class="vc-value">{{ active.unpass_by_name || '—' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="vc-cell">
|
||||||
|
<div class="vc-label">Approved by</div>
|
||||||
|
<div class="vc-value">{{ active.unpass_approved_by_name || '—' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="vc-cell vc-cell--wide">
|
||||||
|
<div class="vc-label">Reason</div>
|
||||||
|
<div class="vc-value">{{ active.reason }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="vc-compare">
|
||||||
|
<!-- The account the deposit went to -->
|
||||||
|
<div class="vc-acct vc-acct--wrong">
|
||||||
|
<div class="vc-acct__tag">Posted to (wrong)</div>
|
||||||
|
<div class="vc-acct__no vc-mono">{{ active.wrong_account_no }}</div>
|
||||||
|
<div class="vc-acct__name">{{ active.wrong_client_name || '—' }}</div>
|
||||||
|
<div class="vc-acct__bal">
|
||||||
|
Balance:
|
||||||
|
@if (wrongAccount) {
|
||||||
|
<strong>₹{{ wrongAccount.balance | number: '1.2-2' }}</strong>
|
||||||
|
@if (wrongHasBalance) {
|
||||||
|
<span class="vc-ok"><mat-icon>check_circle</mat-icon> covers the deposit</span>
|
||||||
|
} @else {
|
||||||
|
<span class="vc-bad"
|
||||||
|
><mat-icon>cancel</mat-icon> does not cover ₹{{ active.amount | number: '1.2-2' }}</span
|
||||||
|
>
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
—
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<mat-icon class="vc-arrow">arrow_forward</mat-icon>
|
||||||
|
|
||||||
|
<!-- The account it should have gone to -->
|
||||||
|
<div class="vc-acct" [class.vc-acct--ok]="correctOk" [class.vc-acct--wrong]="!!lookupError">
|
||||||
|
<div class="vc-acct__tag">Correct account</div>
|
||||||
|
<input
|
||||||
|
id="vc-correct"
|
||||||
|
class="vc-acct__input vc-mono"
|
||||||
|
type="text"
|
||||||
|
placeholder="Type the account number"
|
||||||
|
[ngModel]="correctAccountNo"
|
||||||
|
(ngModelChange)="onAccountInput($event)"
|
||||||
|
(blur)="lookup()"
|
||||||
|
/>
|
||||||
|
@if (lookingUp) {
|
||||||
|
<div class="vc-acct__bal">Looking up…</div>
|
||||||
|
}
|
||||||
|
@if (correctAccount) {
|
||||||
|
<div class="vc-acct__name">{{ correctAccount.clientName || '—' }}</div>
|
||||||
|
<div class="vc-acct__bal">
|
||||||
|
Balance: <strong>₹{{ correctAccount.balance | number: '1.2-2' }}</strong>
|
||||||
|
@if (correctOk) {
|
||||||
|
<span class="vc-ok"><mat-icon>check_circle</mat-icon> active, can receive</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (lookupError) {
|
||||||
|
<div class="vc-bad vc-acct__err"><mat-icon>error</mat-icon> {{ lookupError }}</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (wrongAccount && !wrongHasBalance) {
|
||||||
|
<div class="vc-warn">
|
||||||
|
<mat-icon>warning</mat-icon>
|
||||||
|
<div>
|
||||||
|
<strong>The customer has already used this money,</strong> so it cannot be taken back from
|
||||||
|
{{ active.wrong_account_no }}. Deposit ₹{{ active.amount | number: '1.2-2' }} into the correct account
|
||||||
|
from your own pocket as a fresh deposit (Transaction Entry), then press <strong>Close request</strong>.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="vc-dlg__foot">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--ghost vc-dlg__close-req"
|
||||||
|
[disabled]="sending"
|
||||||
|
(click)="closeRequest()"
|
||||||
|
>
|
||||||
|
<mat-icon>block</mat-icon> Close request
|
||||||
|
</button>
|
||||||
|
<button type="button" class="pg-btn pg-btn--ghost" (click)="closeActive()">Cancel</button>
|
||||||
|
<button type="button" class="pg-btn pg-btn--primary" [disabled]="!canSave" (click)="save()">
|
||||||
|
<mat-icon>check</mat-icon> Save & send for approval
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</ng-template>
|
||||||
@@ -0,0 +1,599 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Compact sizing: the page has to fit a 100% browser window without scrolling for the common case.
|
||||||
|
.pg {
|
||||||
|
padding: 14px 20px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pg-title {
|
||||||
|
font-size: 20px;
|
||||||
|
margin: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pg-sub {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pg-btn {
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
height: 16px;
|
||||||
|
width: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pg-table {
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
th {
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 7px 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Steps ───────────────────────────────────────────────────────────────────
|
||||||
|
.vc-steps {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-step {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
|
||||||
|
&__no {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 0 0 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #e2e8f0;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--on {
|
||||||
|
border-color: #1f7a4d;
|
||||||
|
background: #f0fdf4;
|
||||||
|
color: #0f4f3c;
|
||||||
|
|
||||||
|
.vc-step__no {
|
||||||
|
background: #1f7a4d;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tabs ────────────────────────────────────────────────────────────────────
|
||||||
|
.vc-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-tab {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
margin-bottom: -1px;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #64748b;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
height: 16px;
|
||||||
|
width: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #0f4f3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--on {
|
||||||
|
color: #0f4f3c;
|
||||||
|
border-bottom-color: #1f7a4d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-count {
|
||||||
|
padding: 0 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f59e0b;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Search ──────────────────────────────────────────────────────────────────
|
||||||
|
.vc-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-search {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex: 1 1 280px;
|
||||||
|
max-width: 420px;
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #fff;
|
||||||
|
|
||||||
|
&:focus-within {
|
||||||
|
border-color: #1f7a4d;
|
||||||
|
box-shadow: 0 0 0 3px rgb(31 122 77 / 12%);
|
||||||
|
}
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
height: 16px;
|
||||||
|
width: 16px;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
border: 0;
|
||||||
|
outline: 0;
|
||||||
|
background: transparent;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Table ───────────────────────────────────────────────────────────────────
|
||||||
|
.vc-table {
|
||||||
|
tbody tr {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr.vc-row--on td {
|
||||||
|
background: #f0fdf4;
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
box-shadow: inset 3px 0 0 #1f7a4d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-col-action {
|
||||||
|
width: 1%;
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-clip {
|
||||||
|
max-width: 240px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pg-chip {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-btn-sm {
|
||||||
|
height: 26px;
|
||||||
|
padding: 0 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-mono {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-empty {
|
||||||
|
padding: 24px 12px;
|
||||||
|
text-align: center;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto 4px;
|
||||||
|
font-size: 28px;
|
||||||
|
height: 28px;
|
||||||
|
width: 28px;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Popups ──────────────────────────────────────────────────────────────────
|
||||||
|
.vc-dlg {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-height: 86vh;
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
&__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__ico {
|
||||||
|
flex: 0 0 32px;
|
||||||
|
height: 32px;
|
||||||
|
width: 32px;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #f0fdf4;
|
||||||
|
color: #1f7a4d;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 32px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__titles {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__x {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 28px;
|
||||||
|
width: 28px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: transparent;
|
||||||
|
color: #64748b;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
height: 18px;
|
||||||
|
width: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #e2e8f0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__body {
|
||||||
|
padding: 12px 14px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__amount {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #ecfdf5;
|
||||||
|
border: 1px solid #a7f3d0;
|
||||||
|
color: #065f46;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 800;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__foot {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-top: 1px solid #e2e8f0;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-warn {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fffbeb;
|
||||||
|
border: 1px solid #fde68a;
|
||||||
|
color: #92400e;
|
||||||
|
font-size: 11px;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
flex: 0 0 18px;
|
||||||
|
height: 18px;
|
||||||
|
width: 18px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-dlg__close-req {
|
||||||
|
margin-right: auto;
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-reason {
|
||||||
|
width: 100%;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 8px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
resize: vertical;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: 0;
|
||||||
|
border-color: #1f7a4d;
|
||||||
|
box-shadow: 0 0 0 3px rgb(31 122 77 / 12%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #1e40af;
|
||||||
|
font-size: 11px;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
flex: 0 0 16px;
|
||||||
|
height: 16px;
|
||||||
|
width: 16px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-cell {
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #eef2f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-cell--wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-value {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #0f172a;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Wrong → Correct ─────────────────────────────────────────────────────────
|
||||||
|
.vc-compare {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
@media (width <= 640px) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-arrow {
|
||||||
|
align-self: center;
|
||||||
|
color: #94a3b8;
|
||||||
|
|
||||||
|
@media (width <= 640px) {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
justify-self: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-acct {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
|
||||||
|
&--wrong {
|
||||||
|
border-color: #fecaca;
|
||||||
|
background: #fef2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--ok {
|
||||||
|
border-color: #a7f3d0;
|
||||||
|
background: #ecfdf5;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__tag {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__no {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__input {
|
||||||
|
width: 100%;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: 0;
|
||||||
|
border-color: #1f7a4d;
|
||||||
|
box-shadow: 0 0 0 3px rgb(31 122 77 / 12%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__name {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__bal {
|
||||||
|
margin-top: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__err {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-ok,
|
||||||
|
.vc-bad {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
margin-left: 6px;
|
||||||
|
font-weight: 700;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
height: 14px;
|
||||||
|
width: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-ok {
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-bad {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vc-note {
|
||||||
|
margin-top: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Status ──────────────────────────────────────────────────────────────────
|
||||||
|
.vc-status {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #e2e8f0;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
|
||||||
|
&--UNPASS_PENDING,
|
||||||
|
&--CORRECTION_PENDING {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--UNPASSED {
|
||||||
|
background: #dbeafe;
|
||||||
|
color: #1e40af;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--COMPLETED {
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--CLOSED {
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--UNPASS_REJECTED {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, OnInit, TemplateRef, ViewChild, inject } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
import { catchError } from 'rxjs/operators';
|
||||||
|
import { AuthenticationService } from '../../core/authentication/authentication.service';
|
||||||
|
import {
|
||||||
|
AccountLookup,
|
||||||
|
EligibleVoucher,
|
||||||
|
VoucherCorrection,
|
||||||
|
VoucherCorrectionService
|
||||||
|
} from '../voucher-correction.service';
|
||||||
|
|
||||||
|
type Tab = 'today' | 'unpassed' | 'mine';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cashier side of correcting a cash deposit that went to the wrong account.
|
||||||
|
*
|
||||||
|
* Today vouchers: pick the voucher and unpass it (needs a manager approval; nothing moves).
|
||||||
|
* Unpassed: open the approved voucher, whose details are fixed, change only the account number and save
|
||||||
|
* (needs a manager approval again; only then do the accounts change).
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-voucher-correction',
|
||||||
|
templateUrl: './voucher-correction.component.html',
|
||||||
|
styleUrls: [
|
||||||
|
'../cashier-shared.scss',
|
||||||
|
'./voucher-correction.component.scss'
|
||||||
|
],
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatDialogModule,
|
||||||
|
MatSnackBarModule
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class VoucherCorrectionComponent implements OnInit {
|
||||||
|
private service = inject(VoucherCorrectionService);
|
||||||
|
private snackBar = inject(MatSnackBar);
|
||||||
|
private dialog = inject(MatDialog);
|
||||||
|
private auth = inject(AuthenticationService);
|
||||||
|
|
||||||
|
/** Unpassing is for the back office (an administrator or a clerk); re-entering the account is the cashier only. */
|
||||||
|
readonly mayUnpass = this.auth.isClerkUser() || !!this.auth.getCredentials()?.permissions?.includes('ALL_FUNCTIONS');
|
||||||
|
readonly mayCorrect = this.auth.isCashierUser();
|
||||||
|
|
||||||
|
@ViewChild('unpassDialog') unpassDialog!: TemplateRef<unknown>;
|
||||||
|
@ViewChild('correctionDialog') correctionDialog!: TemplateRef<unknown>;
|
||||||
|
private dialogRef: MatDialogRef<unknown> | null = null;
|
||||||
|
|
||||||
|
tab: Tab = this.mayUnpass ? 'today' : this.mayCorrect ? 'unpassed' : 'mine';
|
||||||
|
|
||||||
|
// Today vouchers
|
||||||
|
search = '';
|
||||||
|
vouchers: EligibleVoucher[] = [];
|
||||||
|
loadingVouchers = false;
|
||||||
|
selectedVoucher: EligibleVoucher | null = null;
|
||||||
|
/** The account the voucher was posted to, for the balance and status shown in the popup. */
|
||||||
|
voucherAccount: AccountLookup | null = null;
|
||||||
|
unpassReason = '';
|
||||||
|
sending = false;
|
||||||
|
|
||||||
|
// Unpassed vouchers waiting for the correct account
|
||||||
|
unpassed: VoucherCorrection[] = [];
|
||||||
|
loadingUnpassed = false;
|
||||||
|
active: VoucherCorrection | null = null;
|
||||||
|
wrongAccount: AccountLookup | null = null;
|
||||||
|
correctAccountNo = '';
|
||||||
|
correctAccount: AccountLookup | null = null;
|
||||||
|
lookupError = '';
|
||||||
|
lookingUp = false;
|
||||||
|
|
||||||
|
// The cashier own requests
|
||||||
|
mine: VoucherCorrection[] = [];
|
||||||
|
loadingMine = false;
|
||||||
|
|
||||||
|
readonly statusLabel: Record<string, string> = {
|
||||||
|
UNPASS_PENDING: 'Unpass awaiting approval',
|
||||||
|
UNPASSED: 'Unpassed — enter correct account',
|
||||||
|
UNPASS_REJECTED: 'Unpass rejected',
|
||||||
|
CORRECTION_PENDING: 'Correction awaiting approval',
|
||||||
|
COMPLETED: 'Corrected',
|
||||||
|
CLOSED: 'Closed — cashier deposits separately'
|
||||||
|
};
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
if (this.mayUnpass) this.loadVouchers();
|
||||||
|
if (this.mayCorrect) this.loadUnpassed();
|
||||||
|
if (this.tab === 'mine') this.loadMine();
|
||||||
|
}
|
||||||
|
|
||||||
|
setTab(tab: Tab): void {
|
||||||
|
this.tab = tab;
|
||||||
|
if (tab === 'today') this.loadVouchers();
|
||||||
|
if (tab === 'unpassed') this.loadUnpassed();
|
||||||
|
if (tab === 'mine') this.loadMine();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Today vouchers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
loadVouchers(): void {
|
||||||
|
this.loadingVouchers = true;
|
||||||
|
this.service
|
||||||
|
.eligible(this.search.trim())
|
||||||
|
.pipe(catchError(() => of([] as EligibleVoucher[])))
|
||||||
|
.subscribe((rows) => {
|
||||||
|
this.vouchers = rows;
|
||||||
|
this.loadingVouchers = false;
|
||||||
|
if (this.selectedVoucher && !rows.some((r) => r.voucherNo === this.selectedVoucher?.voucherNo)) {
|
||||||
|
this.selectedVoucher = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pick(v: EligibleVoucher): void {
|
||||||
|
this.selectedVoucher = v;
|
||||||
|
this.unpassReason = '';
|
||||||
|
this.voucherAccount = null;
|
||||||
|
this.service
|
||||||
|
.accountLookup(v.accountNo)
|
||||||
|
.pipe(catchError(() => of(null)))
|
||||||
|
.subscribe((a) => (this.voucherAccount = a));
|
||||||
|
this.showDialog(this.unpassDialog, () => (this.selectedVoucher = null));
|
||||||
|
}
|
||||||
|
|
||||||
|
closeUnpass(): void {
|
||||||
|
this.dialogRef?.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
get canUnpass(): boolean {
|
||||||
|
return !!this.selectedVoucher && this.unpassReason.trim().length > 0 && !this.sending;
|
||||||
|
}
|
||||||
|
|
||||||
|
unpass(): void {
|
||||||
|
if (!this.selectedVoucher || !this.canUnpass) return;
|
||||||
|
this.sending = true;
|
||||||
|
this.service.unpass(this.selectedVoucher.voucherNo, this.unpassReason.trim()).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.sending = false;
|
||||||
|
this.snackBar.open(`Voucher ${this.selectedVoucher?.voucherNo} sent for unpass approval`, 'Close', {
|
||||||
|
duration: 4000
|
||||||
|
});
|
||||||
|
this.dialogRef?.close();
|
||||||
|
this.unpassReason = '';
|
||||||
|
this.loadVouchers();
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.sending = false;
|
||||||
|
this.fail(err, 'Could not unpass the voucher');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Unpassed vouchers: enter the correct account ───────────────────────────
|
||||||
|
|
||||||
|
loadUnpassed(): void {
|
||||||
|
this.loadingUnpassed = true;
|
||||||
|
this.service
|
||||||
|
.list('UNPASSED')
|
||||||
|
.pipe(catchError(() => of([] as VoucherCorrection[])))
|
||||||
|
.subscribe((rows) => {
|
||||||
|
this.unpassed = rows;
|
||||||
|
this.loadingUnpassed = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
open(row: VoucherCorrection): void {
|
||||||
|
this.active = row;
|
||||||
|
this.correctAccountNo = '';
|
||||||
|
this.correctAccount = null;
|
||||||
|
this.lookupError = '';
|
||||||
|
this.wrongAccount = null;
|
||||||
|
// The account the deposit went to: its balance is what the "can this be taken back" check reads.
|
||||||
|
this.service
|
||||||
|
.accountLookup(row.wrong_account_no)
|
||||||
|
.pipe(catchError(() => of(null)))
|
||||||
|
.subscribe((a) => (this.wrongAccount = a));
|
||||||
|
this.showDialog(this.correctionDialog, () => (this.active = null));
|
||||||
|
}
|
||||||
|
|
||||||
|
closeActive(): void {
|
||||||
|
this.dialogRef?.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
onAccountInput(value: string): void {
|
||||||
|
this.correctAccountNo = value;
|
||||||
|
this.correctAccount = null;
|
||||||
|
this.lookupError = '';
|
||||||
|
// Same rule as the Transaction Entry screen: searches on its own once six characters are typed.
|
||||||
|
if (value.trim().length >= 6) this.lookup();
|
||||||
|
}
|
||||||
|
|
||||||
|
lookup(): void {
|
||||||
|
const accountNo = this.correctAccountNo.trim();
|
||||||
|
if (!accountNo || this.lookingUp) return;
|
||||||
|
this.lookingUp = true;
|
||||||
|
this.service.accountLookup(accountNo).subscribe({
|
||||||
|
next: (a) => {
|
||||||
|
this.lookingUp = false;
|
||||||
|
// A late answer for a number no longer typed is dropped.
|
||||||
|
if (accountNo !== this.correctAccountNo.trim()) return;
|
||||||
|
this.correctAccount = a;
|
||||||
|
if (this.active && a.id === this.active.wrong_savings_account_id) {
|
||||||
|
this.lookupError = 'This is the account the deposit was already posted to.';
|
||||||
|
} else if (!a.canReceive) {
|
||||||
|
this.lookupError = 'This account is not active, so it cannot receive the deposit.';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.lookingUp = false;
|
||||||
|
this.lookupError = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Account not found.';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The wrong account still holds the deposit, so it can be taken back without overdrawing it. */
|
||||||
|
get wrongHasBalance(): boolean {
|
||||||
|
return !!this.wrongAccount && !!this.active && this.wrongAccount.balance >= this.active.amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
get correctOk(): boolean {
|
||||||
|
return !!this.correctAccount && this.correctAccount.canReceive && !this.lookupError;
|
||||||
|
}
|
||||||
|
|
||||||
|
get canSave(): boolean {
|
||||||
|
return !!this.active && this.correctOk && this.wrongHasBalance && !this.sending;
|
||||||
|
}
|
||||||
|
|
||||||
|
save(): void {
|
||||||
|
if (!this.active || !this.canSave) return;
|
||||||
|
this.sending = true;
|
||||||
|
this.service.submitCorrection(this.active.id, this.correctAccountNo.trim()).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.sending = false;
|
||||||
|
this.snackBar.open('Correction sent for approval', 'Close', { duration: 4000 });
|
||||||
|
this.dialogRef?.close();
|
||||||
|
this.loadUnpassed();
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.sending = false;
|
||||||
|
this.fail(err, 'Could not save the correction');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gives up this correction. The usual reason is that the wrong account no longer holds the money, so the cashier
|
||||||
|
* puts the amount into the right account from their own pocket as a fresh deposit instead.
|
||||||
|
*/
|
||||||
|
closeRequest(): void {
|
||||||
|
if (!this.active || this.sending) return;
|
||||||
|
const reason = this.wrongHasBalance
|
||||||
|
? 'Closed by the cashier'
|
||||||
|
: 'Wrong account no longer holds the amount; cashier deposits it into the correct account separately';
|
||||||
|
this.sending = true;
|
||||||
|
this.service.close(this.active.id, reason).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.sending = false;
|
||||||
|
this.snackBar.open('Request closed', 'Close', { duration: 4000 });
|
||||||
|
this.dialogRef?.close();
|
||||||
|
this.loadUnpassed();
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.sending = false;
|
||||||
|
this.fail(err, 'Could not close the request');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── My requests ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
loadMine(): void {
|
||||||
|
this.loadingMine = true;
|
||||||
|
this.service
|
||||||
|
.list(undefined, true)
|
||||||
|
.pipe(catchError(() => of([] as VoucherCorrection[])))
|
||||||
|
.subscribe((rows) => {
|
||||||
|
this.mine = rows;
|
||||||
|
this.loadingMine = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opens one of the two popups; onClose runs however it is dismissed (button, backdrop or Esc). */
|
||||||
|
private showDialog(template: TemplateRef<unknown>, onClose: () => void): void {
|
||||||
|
this.dialogRef?.close();
|
||||||
|
this.dialogRef = this.dialog.open(template, { width: '600px', maxWidth: '95vw', autoFocus: false });
|
||||||
|
this.dialogRef.afterClosed().subscribe(onClose);
|
||||||
|
}
|
||||||
|
|
||||||
|
private fail(err: any, fallback: string): void {
|
||||||
|
const msg = err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.defaultUserMessage ?? fallback;
|
||||||
|
this.snackBar.open(msg, 'Close', { duration: 6000 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -169,6 +169,7 @@
|
|||||||
<th>Debit</th>
|
<th>Debit</th>
|
||||||
<th>Credit</th>
|
<th>Credit</th>
|
||||||
<th class="num">Amount</th>
|
<th class="num">Amount</th>
|
||||||
|
<th>Correction</th>
|
||||||
<th>User</th>
|
<th>User</th>
|
||||||
<!-- Two dates, not one, on the system register.
|
<!-- Two dates, not one, on the system register.
|
||||||
A voucher's timestamp is the minute it was written; the posting behind it carries
|
A voucher's timestamp is the minute it was written; the posting behind it carries
|
||||||
@@ -217,6 +218,18 @@
|
|||||||
</td>
|
</td>
|
||||||
}
|
}
|
||||||
<td class="num">{{ row.amount | number: '1.2-2' }}</td>
|
<td class="num">{{ row.amount | number: '1.2-2' }}</td>
|
||||||
|
<!-- Where the voucher stands after a correction: unpassed, revoked, closed or corrected. -->
|
||||||
|
<td>
|
||||||
|
<mifosx-voucher-status
|
||||||
|
[status]="row.voucherStatus"
|
||||||
|
[revokedBy]="row.revokedByVoucherNo"
|
||||||
|
[replaces]="row.replacesVoucherNo"
|
||||||
|
[note]="row.correctionNote"
|
||||||
|
></mifosx-voucher-status>
|
||||||
|
@if (!row.voucherStatus || row.voucherStatus === 'ACTIVE') {
|
||||||
|
<span class="vr-active">—</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
<td>{{ row.username || '—' }}</td>
|
<td>{{ row.username || '—' }}</td>
|
||||||
@if (systemOnly) {
|
@if (systemOnly) {
|
||||||
<td class="vr-ledgerdate" [class.vr-ledgerdate--prior]="row.postedAfterClose">
|
<td class="vr-ledgerdate" [class.vr-ledgerdate--prior]="row.postedAfterClose">
|
||||||
|
|||||||
@@ -92,3 +92,8 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: #7c3aed;
|
color: #7c3aed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vr-active {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { MatIconModule } from '@angular/material/icon';
|
|||||||
import { catchError, of } from 'rxjs';
|
import { catchError, of } from 'rxjs';
|
||||||
import { CashierService } from '../cashier.service';
|
import { CashierService } from '../cashier.service';
|
||||||
import { businessToday } from 'app/core/utils/business-today';
|
import { businessToday } from 'app/core/utils/business-today';
|
||||||
|
import { VoucherStatusComponent, VoucherStatus } from '../voucher-status/voucher-status.component';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Which side of the register a screen shows.
|
* Which side of the register a screen shows.
|
||||||
@@ -26,6 +27,11 @@ export type VoucherSide = 'CR' | 'DR' | 'ALL';
|
|||||||
/** One voucher as the register shows it: the number, the two sides, and who raised it. */
|
/** One voucher as the register shows it: the number, the two sides, and who raised it. */
|
||||||
interface VoucherRow {
|
interface VoucherRow {
|
||||||
voucherNo: string;
|
voucherNo: string;
|
||||||
|
/** Where the voucher stands after a correction: active, revoked, under correction, or corrected. */
|
||||||
|
voucherStatus?: VoucherStatus;
|
||||||
|
revokedByVoucherNo?: string | null;
|
||||||
|
replacesVoucherNo?: string | null;
|
||||||
|
correctionNote?: string | null;
|
||||||
voucherType: string | null;
|
voucherType: string | null;
|
||||||
amount: number | null;
|
amount: number | null;
|
||||||
createdAt: string | null;
|
createdAt: string | null;
|
||||||
@@ -82,7 +88,8 @@ interface VoucherReport {
|
|||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
MatIconModule
|
MatIconModule,
|
||||||
|
VoucherStatusComponent
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class VoucherReportComponent implements OnInit {
|
export class VoucherReportComponent implements OnInit {
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, Input } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
|
||||||
|
/** Where a voucher stands after a voucher correction. */
|
||||||
|
export type VoucherStatus =
|
||||||
|
| 'ACTIVE'
|
||||||
|
| 'REVOKED'
|
||||||
|
| 'UNPASS_PENDING'
|
||||||
|
| 'UNPASSED'
|
||||||
|
| 'CORRECTION_PENDING'
|
||||||
|
| 'CORRECTED'
|
||||||
|
| 'CLOSED'
|
||||||
|
| null
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A small badge saying whether a voucher was revoked, is under correction, or replaced another one.
|
||||||
|
* Nothing is shown for an ordinary (active) voucher, so the registers read as before.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-voucher-status',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
template: `
|
||||||
|
@if (label) {
|
||||||
|
<span class="vs" [class]="'vs vs--' + status" [title]="hint">{{ label }}</span>
|
||||||
|
@if (note) {
|
||||||
|
<div class="vs-note">{{ note }}</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
styles: [
|
||||||
|
`
|
||||||
|
.vs {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 6px;
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #334155;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vs-note {
|
||||||
|
margin-top: 2px;
|
||||||
|
max-width: 280px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vs--REVOKED {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vs--UNPASS_PENDING,
|
||||||
|
.vs--CORRECTION_PENDING {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vs--UNPASSED {
|
||||||
|
background: #dbeafe;
|
||||||
|
color: #1e40af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vs--CLOSED {
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vs--CORRECTED {
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class VoucherStatusComponent {
|
||||||
|
@Input() status: VoucherStatus;
|
||||||
|
/** The voucher that replaced this one, when it is revoked. */
|
||||||
|
@Input() revokedBy: string | null | undefined;
|
||||||
|
/** The voucher this one replaced, when it is the corrected one. */
|
||||||
|
@Input() replaces: string | null | undefined;
|
||||||
|
/** Why: the reason the voucher was unpassed, or why the correction was closed. */
|
||||||
|
@Input() note: string | null | undefined;
|
||||||
|
|
||||||
|
get label(): string {
|
||||||
|
switch (this.status) {
|
||||||
|
case 'REVOKED':
|
||||||
|
return this.revokedBy ? `Revoked → ${this.revokedBy}` : 'Revoked';
|
||||||
|
case 'UNPASS_PENDING':
|
||||||
|
return 'Unpass pending';
|
||||||
|
case 'UNPASSED':
|
||||||
|
return 'Unpassed';
|
||||||
|
case 'CORRECTION_PENDING':
|
||||||
|
return 'Correction pending';
|
||||||
|
case 'CLOSED':
|
||||||
|
return 'Closed — cashier deposits separately';
|
||||||
|
case 'CORRECTED':
|
||||||
|
return this.replaces ? `Corrected (replaces ${this.replaces})` : 'Corrected';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get hint(): string {
|
||||||
|
switch (this.status) {
|
||||||
|
case 'REVOKED':
|
||||||
|
return 'This voucher was revoked: its deposit was moved to another account and a new voucher replaced it.';
|
||||||
|
case 'UNPASS_PENDING':
|
||||||
|
return 'A manager has not yet approved unpassing this voucher.';
|
||||||
|
case 'UNPASSED':
|
||||||
|
return 'Unpassed: waiting for the cashier to enter the correct account.';
|
||||||
|
case 'CORRECTION_PENDING':
|
||||||
|
return 'The correct account is entered; waiting for a manager to approve.';
|
||||||
|
case 'CLOSED':
|
||||||
|
return 'The correction was closed by the cashier; the voucher stays as it was.';
|
||||||
|
case 'CORRECTED':
|
||||||
|
return 'This voucher was raised by a correction and replaces the earlier one.';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,18 @@ import { ReportMuleComponent } from './report-mule/report-mule.component';
|
|||||||
import { ReportFraudTxnComponent } from './report-fraud-txn/report-fraud-txn.component';
|
import { ReportFraudTxnComponent } from './report-fraud-txn/report-fraud-txn.component';
|
||||||
import { FmrSubmissionComponent } from './fmr-submission/fmr-submission.component';
|
import { FmrSubmissionComponent } from './fmr-submission/fmr-submission.component';
|
||||||
import { CfrLookupComponent } from './cfr-lookup/cfr-lookup.component';
|
import { CfrLookupComponent } from './cfr-lookup/cfr-lookup.component';
|
||||||
|
import { I4cShellComponent } from './i4c/i4c-shell/i4c-shell.component';
|
||||||
|
import { I4cDashboardComponent } from './i4c/i4c-dashboard/i4c-dashboard.component';
|
||||||
|
import { I4cRequestsComponent } from './i4c/i4c-requests/i4c-requests.component';
|
||||||
|
import { I4cRequestDetailComponent } from './i4c/i4c-request-detail/i4c-request-detail.component';
|
||||||
|
import { I4cHoldsComponent } from './i4c/i4c-holds/i4c-holds.component';
|
||||||
|
import { I4cApprovalsComponent } from './i4c/i4c-approvals/i4c-approvals.component';
|
||||||
|
import { I4cOutboxComponent } from './i4c/i4c-outbox/i4c-outbox.component';
|
||||||
|
import { I4cBankMasterComponent } from './i4c/i4c-bank-master/i4c-bank-master.component';
|
||||||
|
import { I4cIntimationSettingsComponent } from './i4c/i4c-intimation-settings/i4c-intimation-settings.component';
|
||||||
|
import { I4cGrievancesComponent } from './i4c/i4c-grievances/i4c-grievances.component';
|
||||||
|
import { I4cRestorationsComponent } from './i4c/i4c-restorations/i4c-restorations.component';
|
||||||
|
import { I4cMrmReportComponent } from './i4c/i4c-mrm-report/i4c-mrm-report.component';
|
||||||
|
|
||||||
const routes: Routes = [
|
const routes: Routes = [
|
||||||
Route.withShell([
|
Route.withShell([
|
||||||
@@ -76,6 +88,67 @@ const routes: Routes = [
|
|||||||
path: 'cfr-lookup',
|
path: 'cfr-lookup',
|
||||||
component: CfrLookupComponent,
|
component: CfrLookupComponent,
|
||||||
data: { title: 'CFR Lookup', breadcrumb: 'CFR Lookup' }
|
data: { title: 'CFR Lookup', breadcrumb: 'CFR Lookup' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Live I4C-MHA integration (NCRP-CFCFRMS API). Served by /v1/i4c/admin on the CBS; the demo mock
|
||||||
|
// interceptor does not match these paths.
|
||||||
|
path: 'i4c',
|
||||||
|
component: I4cShellComponent,
|
||||||
|
data: { title: 'I4C Live Integration', breadcrumb: 'I4C Live' },
|
||||||
|
children: [
|
||||||
|
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
|
||||||
|
{
|
||||||
|
path: 'dashboard',
|
||||||
|
component: I4cDashboardComponent,
|
||||||
|
data: { title: 'I4C Dashboard', breadcrumb: 'Dashboard' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'requests',
|
||||||
|
component: I4cRequestsComponent,
|
||||||
|
data: { title: 'Complaints from I4C', breadcrumb: 'Complaints' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'requests/:id',
|
||||||
|
component: I4cRequestDetailComponent,
|
||||||
|
data: { title: 'I4C Complaint', breadcrumb: 'Complaint' }
|
||||||
|
},
|
||||||
|
{ path: 'holds', component: I4cHoldsComponent, data: { title: 'I4C Holds Register', breadcrumb: 'Holds' } },
|
||||||
|
{
|
||||||
|
path: 'approvals',
|
||||||
|
component: I4cApprovalsComponent,
|
||||||
|
data: { title: 'I4C Approvals', breadcrumb: 'Approvals' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'outbox',
|
||||||
|
component: I4cOutboxComponent,
|
||||||
|
data: { title: 'I4C Responses', breadcrumb: 'Responses' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'bank-master',
|
||||||
|
component: I4cBankMasterComponent,
|
||||||
|
data: { title: 'I4C Bank Code Master', breadcrumb: 'Bank Master' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'grievances',
|
||||||
|
component: I4cGrievancesComponent,
|
||||||
|
data: { title: 'I4C GRM Grievances', breadcrumb: 'GRM Grievances' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'restorations',
|
||||||
|
component: I4cRestorationsComponent,
|
||||||
|
data: { title: 'I4C MRM Restorations', breadcrumb: 'MRM Restorations' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'mrm-report',
|
||||||
|
component: I4cMrmReportComponent,
|
||||||
|
data: { title: 'I4C MRM Balance Report', breadcrumb: 'MRM Balance Report' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'intimation-settings',
|
||||||
|
component: I4cIntimationSettingsComponent,
|
||||||
|
data: { title: 'I4C Intimation Settings', breadcrumb: 'Intimation Settings' }
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,17 @@
|
|||||||
<fa-icon icon="chart-line" class="cfc-tab__ic"></fa-icon>
|
<fa-icon icon="chart-line" class="cfc-tab__ic"></fa-icon>
|
||||||
<span>Dashboard</span>
|
<span>Dashboard</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a
|
||||||
|
mat-tab-link
|
||||||
|
class="cfc-tab"
|
||||||
|
[routerLink]="['./i4c']"
|
||||||
|
routerLinkActive
|
||||||
|
#i4cLive="routerLinkActive"
|
||||||
|
[active]="i4cLive.isActive"
|
||||||
|
>
|
||||||
|
<fa-icon icon="satellite-dish" class="cfc-tab__ic"></fa-icon>
|
||||||
|
<span>I4C Live</span>
|
||||||
|
</a>
|
||||||
<a
|
<a
|
||||||
mat-tab-link
|
mat-tab-link
|
||||||
class="cfc-tab"
|
class="cfc-tab"
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Shared by the GRM grievance, MRM restoration and MRM report pages.
|
||||||
|
@mixin i4c-case-page {
|
||||||
|
.modal-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count-tag {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #64748b;
|
||||||
|
background: #f1f5f9;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: 260px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-btn-close {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #334155;
|
||||||
|
border: none;
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #cbd5e1;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.num {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrap {
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
mat-form-field {
|
||||||
|
width: 220px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__wide {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
& + & {
|
||||||
|
margin-top: 14px;
|
||||||
|
padding-top: 14px;
|
||||||
|
border-top: 1px dashed #e2e8f0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 11px;
|
||||||
|
|
||||||
|
&__stage {
|
||||||
|
padding: 3px 9px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #64748b;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
&--done {
|
||||||
|
background: #ecfdf5;
|
||||||
|
color: #047857;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--now {
|
||||||
|
background: #2563eb;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__arrow {
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.deadline {
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
&--late {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-2 {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 900px) {
|
||||||
|
.grid-2 {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,789 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Modern design system for the I4C (NCRP-CFCFRMS) subsystem:
|
||||||
|
Glassmorphism, sleek modern cards, rich data tables, glowing status pills,
|
||||||
|
and animated popup window modals. */
|
||||||
|
|
||||||
|
@mixin i4c-skin {
|
||||||
|
.i4c-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
animation: i4c-fade-in 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modern Toolbar */
|
||||||
|
.i4c-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 8px rgb(15 23 42 / 3%);
|
||||||
|
|
||||||
|
&__hint {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__spacer {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
mat-form-field {
|
||||||
|
min-width: 180px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Extra filters and the date range, under the toolbar */
|
||||||
|
.i4c-filter-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px 14px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
|
||||||
|
mat-form-field {
|
||||||
|
width: 170px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__clear {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inside a card, under its head
|
||||||
|
&--inset {
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 1px solid #f1f5f9;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Paginator at the foot of a list card */
|
||||||
|
.i4c-paginator {
|
||||||
|
border-top: 1px solid #f1f5f9;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Filter Chip bar */
|
||||||
|
.i4c-filter-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
.filter-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #475569;
|
||||||
|
background: #f1f5f9;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #0f172a;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--active {
|
||||||
|
background: #2563eb;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #2563eb;
|
||||||
|
box-shadow: 0 3px 8px rgb(37 99 235 / 25%);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #1d4ed8;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-chip-count {
|
||||||
|
background: rgb(255 255 255 / 25%);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-chip-count {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: #e2e8f0;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modern KPI Cards */
|
||||||
|
.i4c-kpis {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.i4c-kpi {
|
||||||
|
position: relative;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
box-shadow: 0 4px 10px rgb(15 23 42 / 3%);
|
||||||
|
transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
overflow: hidden;
|
||||||
|
text-decoration: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 3px;
|
||||||
|
background: linear-gradient(90deg, #3b82f6, #60a5fa);
|
||||||
|
border-radius: 12px 12px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 18px -4px rgb(15 23 42 / 8%);
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__value {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #0f172a;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__sub {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--green::before {
|
||||||
|
background: linear-gradient(90deg, #10b981, #34d399);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--amber::before {
|
||||||
|
background: linear-gradient(90deg, #f59e0b, #fbbf24);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--red::before {
|
||||||
|
background: linear-gradient(90deg, #ef4444, #f87171);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--purple::before {
|
||||||
|
background: linear-gradient(90deg, #8b5cf6, #a78bfa);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modern Card Container */
|
||||||
|
.i4c-card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 4px 14px rgb(15 23 42 / 3%);
|
||||||
|
overflow: hidden;
|
||||||
|
transition: box-shadow 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: 0 6px 18px rgb(15 23 42 / 5%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: linear-gradient(180deg, #fafbfd 0%, #f4f6fa 100%);
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1e293b;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__body {
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.i4c-grid-2 {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.i4c-table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modern Data Table */
|
||||||
|
table.i4c-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 0;
|
||||||
|
font-size: 11.5px;
|
||||||
|
|
||||||
|
th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 2;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #475569;
|
||||||
|
background: #f8fafc;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 10.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-bottom: 1px solid #f1f5f9;
|
||||||
|
color: #1e293b;
|
||||||
|
vertical-align: middle;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover td {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tr.selected td {
|
||||||
|
background: #eff6ff;
|
||||||
|
border-bottom-color: #dbeafe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.num {
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glowing Modern Status Pills */
|
||||||
|
.i4c-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #334155;
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
display: inline-block;
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentcolor;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--green {
|
||||||
|
background: #ecfdf5;
|
||||||
|
color: #059669;
|
||||||
|
border-color: #a7f3d0;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
box-shadow: 0 0 6px #10b981;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--amber {
|
||||||
|
background: #fffbeb;
|
||||||
|
color: #d97706;
|
||||||
|
border-color: #fde68a;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
box-shadow: 0 0 6px #f59e0b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--red {
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #dc2626;
|
||||||
|
border-color: #fecaca;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
box-shadow: 0 0 6px #ef4444;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--blue {
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #2563eb;
|
||||||
|
border-color: #bfdbfe;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
box-shadow: 0 0 6px #3b82f6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--purple {
|
||||||
|
background: #f5f3ff;
|
||||||
|
color: #7c3aed;
|
||||||
|
border-color: #ddd6fe;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
box-shadow: 0 0 6px #8b5cf6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Key-Value details layout */
|
||||||
|
.i4c-kv {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 160px 1fr;
|
||||||
|
gap: 8px 12px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
dt {
|
||||||
|
color: #64748b;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
dd {
|
||||||
|
margin: 0;
|
||||||
|
color: #0f172a;
|
||||||
|
font-weight: 600;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.i4c-empty {
|
||||||
|
padding: 30px 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
font-size: 26px;
|
||||||
|
color: #cbd5e1;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.i4c-error {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
color: #991b1b;
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.i4c-note {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
color: #475569;
|
||||||
|
font-size: 11.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sleek Dark JSON / Code Viewer */
|
||||||
|
pre.i4c-json {
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 14px 20px;
|
||||||
|
max-height: 380px;
|
||||||
|
overflow: auto;
|
||||||
|
background: #0f172a;
|
||||||
|
color: #38bdf8;
|
||||||
|
border: 1px solid #1e293b;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-family: 'JetBrains Mono', Consolas, Monaco, monospace;
|
||||||
|
font-size: 10.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
box-shadow: inset 0 2px 4px rgb(0 0 0 / 40%);
|
||||||
|
box-sizing: border-box;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
background: #1e293b;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: #475569;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #64748b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.i4c-form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 8px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.i4c-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Quick action button inside tables */
|
||||||
|
.table-action-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #2563eb;
|
||||||
|
background: #eff6ff;
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #2563eb;
|
||||||
|
color: #fff;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Copy Button */
|
||||||
|
.copy-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #475569;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Expandable JSON Row inside tables */
|
||||||
|
.finding-json-cell {
|
||||||
|
padding: 12px 16px !important;
|
||||||
|
background: #0b1329 !important;
|
||||||
|
border-bottom: 2px solid #1e293b !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finding-json-box {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.finding-json-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #94a3b8;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
border-bottom: 1px solid #1e293b;
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
color: #60a5fa;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pre.i4c-json {
|
||||||
|
max-height: none;
|
||||||
|
overflow: auto visible;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
padding: 6px 0 16px;
|
||||||
|
box-shadow: none;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #38bdf8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================================
|
||||||
|
MODERN POPUP WINDOW MODAL SYSTEM (Ultra Modern Backdrop & Window)
|
||||||
|
======================================================== */
|
||||||
|
.i4c-modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgb(15 23 42 / 65%);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 9999;
|
||||||
|
padding: 16px;
|
||||||
|
animation: i4c-fade-in 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.i4c-modal-window {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid rgb(255 255 255 / 20%);
|
||||||
|
box-shadow: 0 20px 50px -15px rgb(15 23 42 / 35%);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 660px;
|
||||||
|
max-height: 86vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
animation: i4c-modal-pop 0.25s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
|
||||||
|
&--wide {
|
||||||
|
max-width: 860px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--full {
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 88vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--compact {
|
||||||
|
max-width: 440px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.i4c-modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 11px 16px;
|
||||||
|
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
|
||||||
|
color: #fff;
|
||||||
|
border-bottom: 1px solid rgb(255 255 255 / 10%);
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
color: #60a5fa;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-weight: normal;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close-btn {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgb(255 255 255 / 10%);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgb(239 68 68 / 80%);
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.i4c-modal-tabs {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 16px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
overflow-x: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
.modal-tab {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #64748b;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #0f172a;
|
||||||
|
background: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--active {
|
||||||
|
color: #2563eb;
|
||||||
|
background: #fff;
|
||||||
|
border-color: #e2e8f0;
|
||||||
|
box-shadow: 0 1px 3px rgb(15 23 42 / 5%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.i4c-modal-body {
|
||||||
|
padding: 14px 16px;
|
||||||
|
overflow: hidden auto;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
|
||||||
|
/* Content keeps its natural height and the body scrolls; a flex child would otherwise shrink to fit and
|
||||||
|
clip what is inside it (a card has overflow: hidden). */
|
||||||
|
> div {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
background: #f1f5f9;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: #cbd5e1;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #94a3b8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.i4c-modal-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-top: 1px solid #e2e8f0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animations */
|
||||||
|
@keyframes i4c-fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes i4c-modal-pop {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.92) translateY(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1) translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
<!--
|
||||||
|
Copyright since 2025 Mifos Initiative
|
||||||
|
|
||||||
|
This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<div class="i4c-page">
|
||||||
|
<!-- Modern Toolbar & Filter Chips -->
|
||||||
|
<div class="i4c-toolbar">
|
||||||
|
<div class="i4c-filter-chips">
|
||||||
|
@for (s of statuses; track s) {
|
||||||
|
<button class="filter-chip" [class.filter-chip--active]="status === s" (click)="setStatus(s)">
|
||||||
|
<span>{{ s || 'All Requests' }}</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="i4c-toolbar__spacer"></span>
|
||||||
|
|
||||||
|
<div class="maker-checker-pill">
|
||||||
|
<fa-icon icon="shield-alt"></fa-icon>
|
||||||
|
<span>Maker-Checker Enforced: you cannot approve a request you raised.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button mat-flat-button color="primary" class="refresh-btn" (click)="load()" [disabled]="loading">
|
||||||
|
<fa-icon icon="sync" [animation]="loading ? 'spin' : undefined"></fa-icon> Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-filter-bar">
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="search-field">
|
||||||
|
<mat-label>Account / ack / order no. / officer</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
[(ngModel)]="search"
|
||||||
|
(keyup.enter)="load(true)"
|
||||||
|
(blur)="load(true)"
|
||||||
|
placeholder="Type & hit enter..."
|
||||||
|
/>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Action</mat-label>
|
||||||
|
<mat-select [(ngModel)]="action" (selectionChange)="load(true)">
|
||||||
|
<mat-option value="">All actions</mat-option>
|
||||||
|
<mat-option value="RELEASE">Release hold</mat-option>
|
||||||
|
<mat-option value="FREEZE">Freeze debits</mat-option>
|
||||||
|
<mat-option value="UNFREEZE">Unfreeze debits</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Reason</mat-label>
|
||||||
|
<mat-select [(ngModel)]="reasonType" (selectionChange)="load(true)">
|
||||||
|
<mat-option value="">All reasons</mat-option>
|
||||||
|
@for (r of reasonTypes; track r.value) {
|
||||||
|
<mat-option [value]="r.value">{{ r.label }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mifosx-i4c-date-range label="Raised on" [range]="dates" (rangeChange)="onDates($event)"></mifosx-i4c-date-range>
|
||||||
|
<button mat-stroked-button class="i4c-filter-bar__clear" (click)="clearFilters()" [disabled]="!hasFilters">
|
||||||
|
<fa-icon icon="times"></fa-icon> Clear filters
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (loading) {
|
||||||
|
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
|
||||||
|
}
|
||||||
|
@if (error) {
|
||||||
|
<div class="i4c-error">
|
||||||
|
<fa-icon icon="exclamation-circle"></fa-icon>
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Approvals Table Card -->
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head">
|
||||||
|
<fa-icon icon="user-check"></fa-icon>
|
||||||
|
<span>Checker Approval Queue (I4C Account Holds)</span>
|
||||||
|
<span class="head-hint">{{ total }} requests in current queue</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-table-wrap">
|
||||||
|
<table class="i4c-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Raised On</th>
|
||||||
|
<th>Requested Action</th>
|
||||||
|
<th>Target Account / Complaint</th>
|
||||||
|
<th class="num">Held Amount (₹)</th>
|
||||||
|
<th>Reason & Legal Order</th>
|
||||||
|
<th>Maker Remarks</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Decision Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (r of rows; track r.id) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span class="date-main">{{ r.maker_at | date: 'dd MMM yyyy, HH:mm' }}</span>
|
||||||
|
<div class="sub">
|
||||||
|
Raised by: <b>{{ r.maker_name }}</b>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
class="i4c-badge"
|
||||||
|
[class.i4c-badge--red]="r.action !== 'RELEASE'"
|
||||||
|
[class.i4c-badge--blue]="r.action === 'RELEASE'"
|
||||||
|
>
|
||||||
|
{{ r.action }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a [routerLink]="['../holds']" [queryParams]="{ view: '' }" class="acc-link font-semibold">
|
||||||
|
{{ r.account_no }}
|
||||||
|
</a>
|
||||||
|
<div class="sub">{{ r.account_type }} • Ack: {{ r.acknowledgement_no }}</div>
|
||||||
|
</td>
|
||||||
|
<td class="num">
|
||||||
|
<span class="font-bold text-accent">₹{{ r.amount_held | number: '1.2-2' }}</span>
|
||||||
|
<div class="sub">Disputed: ₹{{ r.disputed_amount | number: '1.2-2' }}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="reason-title">{{ reasonLabel(r.reason_type) }}</div>
|
||||||
|
<div class="sub">
|
||||||
|
Ref: {{ r.order_reference }} {{ r.order_date ? '(' + r.order_date + ')' : '' }}
|
||||||
|
{{ r.authority ? '• ' + r.authority : '' }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="remarks-text">{{ r.remarks || '-' }}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
class="i4c-badge"
|
||||||
|
[class.i4c-badge--green]="r.status === 'APPROVED'"
|
||||||
|
[class.i4c-badge--red]="r.status === 'REJECTED' || r.status === 'FAILED'"
|
||||||
|
[class.i4c-badge--amber]="r.status === 'PENDING'"
|
||||||
|
>
|
||||||
|
{{ r.status }}
|
||||||
|
</span>
|
||||||
|
@if (r.checker_name) {
|
||||||
|
<div class="sub">Checked by {{ r.checker_name }}, {{ r.checker_at | date: 'dd MMM, HH:mm' }}</div>
|
||||||
|
}
|
||||||
|
@if (r.rejection_reason || r.failure_message) {
|
||||||
|
<div class="sub err-text">{{ r.rejection_reason || r.failure_message }}</div>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="actions-cell">
|
||||||
|
@if (r.status === 'PENDING') {
|
||||||
|
<div class="i4c-actions">
|
||||||
|
<button class="approve-btn" (click)="startApprove(r)" [disabled]="busyId === r.id">
|
||||||
|
<fa-icon icon="check"></fa-icon> Approve
|
||||||
|
</button>
|
||||||
|
<button class="reject-btn" (click)="startReject(r)" [disabled]="busyId === r.id">
|
||||||
|
<fa-icon icon="times"></fa-icon> Reject
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<span class="text-muted">Decision Recorded</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="8" class="i4c-empty">
|
||||||
|
<div class="empty-icon"><fa-icon icon="user-check"></fa-icon></div>
|
||||||
|
<div>No approval requests in this category.</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<mat-paginator
|
||||||
|
class="i4c-paginator"
|
||||||
|
[length]="total"
|
||||||
|
[pageIndex]="pageIndex"
|
||||||
|
[pageSize]="pageSize"
|
||||||
|
[pageSizeOptions]="pageSizes"
|
||||||
|
(page)="onPage($event)"
|
||||||
|
showFirstLastButtons
|
||||||
|
aria-label="Approval requests pages"
|
||||||
|
></mat-paginator>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ========================================================
|
||||||
|
MODERN POPUP WINDOW: APPROVE CONFIRMATION MODAL
|
||||||
|
======================================================== -->
|
||||||
|
@if (approvingRow) {
|
||||||
|
<div class="i4c-modal-backdrop" (click)="closeApprove()">
|
||||||
|
<div class="i4c-modal-window i4c-modal-window--compact" (click)="$event.stopPropagation()">
|
||||||
|
<div class="i4c-modal-header approve-header">
|
||||||
|
<div class="i4c-modal-header__title">
|
||||||
|
<fa-icon icon="check-circle" class="text-green"></fa-icon>
|
||||||
|
<div>
|
||||||
|
<span>Confirm Checker Approval</span>
|
||||||
|
<div class="i4c-modal-header__sub">Executing live CBS account action</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="modal-close-btn" (click)="closeApprove()" aria-label="Close">
|
||||||
|
<fa-icon icon="times"></fa-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-modal-body">
|
||||||
|
<div class="approve-card">
|
||||||
|
<div class="approve-action-badge">
|
||||||
|
<span
|
||||||
|
>ACTION: <b>{{ approvingRow.action }}</b></span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="i4c-kv">
|
||||||
|
<dt>Account Number</dt>
|
||||||
|
<dd>
|
||||||
|
<b>{{ approvingRow.account_no }}</b>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>Held Amount</dt>
|
||||||
|
<dd>
|
||||||
|
<b class="text-accent">₹{{ approvingRow.amount_held | number: '1.2-2' }}</b>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>Reason</dt>
|
||||||
|
<dd>{{ reasonLabel(approvingRow.reason_type) }}</dd>
|
||||||
|
|
||||||
|
<dt>Order Reference</dt>
|
||||||
|
<dd>
|
||||||
|
{{ approvingRow.order_reference }}
|
||||||
|
{{ approvingRow.authority ? '(' + approvingRow.authority + ')' : '' }}
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>Maker Details</dt>
|
||||||
|
<dd>{{ approvingRow.maker_name }} on {{ approvingRow.maker_at | date: 'dd MMM yyyy, HH:mm' }}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-note warn-note">
|
||||||
|
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||||
|
<span
|
||||||
|
>Warning: Once confirmed, the hold change or freeze will be executed immediately on the core banking
|
||||||
|
system.</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-modal-footer">
|
||||||
|
<button class="footer-btn-close" (click)="closeApprove()">Cancel</button>
|
||||||
|
<button
|
||||||
|
mat-flat-button
|
||||||
|
color="primary"
|
||||||
|
class="confirm-approve-btn"
|
||||||
|
(click)="confirmApprove()"
|
||||||
|
[disabled]="busyId === approvingRow.id"
|
||||||
|
>
|
||||||
|
<fa-icon
|
||||||
|
[icon]="busyId === approvingRow.id ? 'sync' : 'check'"
|
||||||
|
[animation]="busyId === approvingRow.id ? 'spin' : undefined"
|
||||||
|
></fa-icon>
|
||||||
|
Confirm & Approve
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- ========================================================
|
||||||
|
MODERN POPUP WINDOW: REJECT REASON MODAL
|
||||||
|
======================================================== -->
|
||||||
|
@if (rejectingRow) {
|
||||||
|
<div class="i4c-modal-backdrop" (click)="closeReject()">
|
||||||
|
<div class="i4c-modal-window" (click)="$event.stopPropagation()">
|
||||||
|
<div class="i4c-modal-header reject-header">
|
||||||
|
<div class="i4c-modal-header__title">
|
||||||
|
<fa-icon icon="times-circle" class="text-red"></fa-icon>
|
||||||
|
<div>
|
||||||
|
<span>Reject Request #{{ rejectingRow.id }}</span>
|
||||||
|
<div class="i4c-modal-header__sub">Provide the reason for rejecting this hold action</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="modal-close-btn" (click)="closeReject()" aria-label="Close">
|
||||||
|
<fa-icon icon="times"></fa-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-modal-body">
|
||||||
|
<div class="reject-summary">
|
||||||
|
<span
|
||||||
|
>Rejecting: <b>{{ rejectingRow.action }}</b> on Account <b>{{ rejectingRow.account_no }}</b></span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preset Reason Chips -->
|
||||||
|
<div class="preset-box">
|
||||||
|
<div class="preset-title">Quick Preset Reasons:</div>
|
||||||
|
<div class="preset-chips">
|
||||||
|
@for (preset of presetReasons; track preset) {
|
||||||
|
<button type="button" class="preset-chip" (click)="applyPreset(preset)">
|
||||||
|
{{ preset }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Rejection Reason Input -->
|
||||||
|
<mat-form-field appearance="outline" class="w-full">
|
||||||
|
<mat-label>Rejection Reason (Required)</mat-label>
|
||||||
|
<textarea
|
||||||
|
matInput
|
||||||
|
rows="3"
|
||||||
|
[(ngModel)]="rejectReason"
|
||||||
|
placeholder="Explain why this request is rejected for the maker's audit log..."
|
||||||
|
maxlength="1000"
|
||||||
|
></textarea>
|
||||||
|
<mat-hint align="end">{{ rejectReason.length }}/1000</mat-hint>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-modal-footer">
|
||||||
|
<button class="footer-btn-close" (click)="closeReject()">Cancel</button>
|
||||||
|
<button
|
||||||
|
mat-flat-button
|
||||||
|
color="warn"
|
||||||
|
class="confirm-reject-btn"
|
||||||
|
(click)="confirmReject()"
|
||||||
|
[disabled]="busyId === rejectingRow.id || !rejectReason.trim()"
|
||||||
|
>
|
||||||
|
<fa-icon
|
||||||
|
[icon]="busyId === rejectingRow.id ? 'sync' : 'times'"
|
||||||
|
[animation]="busyId === rejectingRow.id ? 'spin' : undefined"
|
||||||
|
></fa-icon>
|
||||||
|
Confirm Rejection
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@use '../i4c' as *;
|
||||||
|
|
||||||
|
@include i4c-skin;
|
||||||
|
|
||||||
|
.maker-checker-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #475569;
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-btn {
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 11.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head-hint {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-main {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.acc-link {
|
||||||
|
color: #2563eb;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-accent {
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reason-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remarks-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #475569;
|
||||||
|
max-width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.err-text {
|
||||||
|
color: #dc2626;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-cell {
|
||||||
|
min-width: 190px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.approve-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #10b981;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: #059669;
|
||||||
|
box-shadow: 0 4px 10px rgb(16 185 129 / 30%);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.reject-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #dc2626;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: #fee2e2;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Approve Modal styles */
|
||||||
|
.approve-card {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.approve-action-badge {
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: #1e40af;
|
||||||
|
background: #eff6ff;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warn-note {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
background: #fffbeb;
|
||||||
|
border-color: #fde68a;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-green {
|
||||||
|
color: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-red {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-muted {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Preset reasons */
|
||||||
|
.preset-box {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-title {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-chip {
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: #334155;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #0f172a;
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.reject-summary {
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: #475569;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.w-full {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-btn-close {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #334155;
|
||||||
|
border: none;
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #cbd5e1;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
|
import { DatePipe, DecimalPipe } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
|
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||||
|
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||||
|
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||||
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
import { HOLD_REASON_TYPES, I4cService, i4cErrorMessage } from '../i4c.service';
|
||||||
|
import { ALL_DATES, I4cDateRange, I4cDateRangeComponent } from '../i4c-date-range/i4c-date-range.component';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checker's queue: release / freeze / unfreeze requests raised on I4C holds. Approving carries the change out on the
|
||||||
|
* account at once; the server refuses an approval by the officer who raised the request.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-i4c-approvals',
|
||||||
|
templateUrl: './i4c-approvals.component.html',
|
||||||
|
styleUrls: ['./i4c-approvals.component.scss'],
|
||||||
|
imports: [
|
||||||
|
DatePipe,
|
||||||
|
DecimalPipe,
|
||||||
|
FormsModule,
|
||||||
|
RouterLink,
|
||||||
|
MatButtonModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatSelectModule,
|
||||||
|
MatProgressBarModule,
|
||||||
|
MatPaginatorModule,
|
||||||
|
I4cDateRangeComponent,
|
||||||
|
FaIconComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class I4cApprovalsComponent implements OnInit {
|
||||||
|
private i4c = inject(I4cService);
|
||||||
|
private snackBar = inject(MatSnackBar);
|
||||||
|
|
||||||
|
readonly statuses = [
|
||||||
|
'PENDING',
|
||||||
|
'APPROVED',
|
||||||
|
'REJECTED',
|
||||||
|
'FAILED',
|
||||||
|
''
|
||||||
|
];
|
||||||
|
status = 'PENDING';
|
||||||
|
search = '';
|
||||||
|
action = '';
|
||||||
|
reasonType = '';
|
||||||
|
readonly reasonTypes = HOLD_REASON_TYPES;
|
||||||
|
dates: I4cDateRange = { ...ALL_DATES };
|
||||||
|
total = 0;
|
||||||
|
pageIndex = 0;
|
||||||
|
pageSize = 20;
|
||||||
|
readonly pageSizes = [
|
||||||
|
10,
|
||||||
|
20,
|
||||||
|
50,
|
||||||
|
100
|
||||||
|
];
|
||||||
|
rows: any[] = [];
|
||||||
|
loading = false;
|
||||||
|
busyId: number | null = null;
|
||||||
|
error = '';
|
||||||
|
|
||||||
|
// Modern Approval Popup Modal state
|
||||||
|
approvingRow: any = null;
|
||||||
|
|
||||||
|
// Modern Rejection Popup Modal state
|
||||||
|
rejectingRow: any = null;
|
||||||
|
rejectReason = '';
|
||||||
|
|
||||||
|
readonly presetReasons = [
|
||||||
|
'Insufficient court / IO documentation provided.',
|
||||||
|
'Invalid or unverified reference number.',
|
||||||
|
'Duplicate action request raised.',
|
||||||
|
'Wrong account number specified in order.',
|
||||||
|
'Investigation still in progress; hold must remain.'
|
||||||
|
];
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reload the current page; a changed filter passes `fromFirstPage` so the officer lands on page one. */
|
||||||
|
load(fromFirstPage = false): void {
|
||||||
|
if (fromFirstPage) {
|
||||||
|
this.pageIndex = 0;
|
||||||
|
}
|
||||||
|
this.loading = true;
|
||||||
|
this.error = '';
|
||||||
|
this.i4c
|
||||||
|
.holdActions({
|
||||||
|
status: this.status,
|
||||||
|
search: this.search.trim(),
|
||||||
|
action: this.action,
|
||||||
|
reasonType: this.reasonType,
|
||||||
|
fromDate: this.dates.fromDate,
|
||||||
|
toDate: this.dates.toDate,
|
||||||
|
offset: this.pageIndex * this.pageSize,
|
||||||
|
limit: this.pageSize
|
||||||
|
})
|
||||||
|
.subscribe({
|
||||||
|
next: (page) => {
|
||||||
|
this.rows = page.pageItems;
|
||||||
|
this.total = page.totalFilteredRecords;
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.error = i4cErrorMessage(e);
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus(s: string): void {
|
||||||
|
this.status = s;
|
||||||
|
this.load(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
onPage(e: PageEvent): void {
|
||||||
|
this.pageIndex = e.pageIndex;
|
||||||
|
this.pageSize = e.pageSize;
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
onDates(range: I4cDateRange): void {
|
||||||
|
this.dates = range;
|
||||||
|
this.load(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasFilters(): boolean {
|
||||||
|
return !!(this.search || this.action || this.reasonType || this.dates.preset !== 'ALL');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clears the extra filters; the status chip stays as chosen. */
|
||||||
|
clearFilters(): void {
|
||||||
|
this.search = '';
|
||||||
|
this.action = '';
|
||||||
|
this.reasonType = '';
|
||||||
|
this.dates = { ...ALL_DATES };
|
||||||
|
this.load(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open modern approval popup modal
|
||||||
|
startApprove(row: any): void {
|
||||||
|
this.approvingRow = row;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeApprove(): void {
|
||||||
|
this.approvingRow = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmApprove(): void {
|
||||||
|
if (!this.approvingRow) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const row = this.approvingRow;
|
||||||
|
this.busyId = row.id;
|
||||||
|
this.i4c.approveHoldAction(row.id).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.busyId = null;
|
||||||
|
this.approvingRow = null;
|
||||||
|
this.snackBar.open('Approved! The action has been executed immediately on the CBS account.', 'OK', {
|
||||||
|
duration: 5000
|
||||||
|
});
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.busyId = null;
|
||||||
|
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 8000 });
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open modern rejection popup modal
|
||||||
|
startReject(row: any): void {
|
||||||
|
this.rejectingRow = row;
|
||||||
|
this.rejectReason = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
closeReject(): void {
|
||||||
|
this.rejectingRow = null;
|
||||||
|
this.rejectReason = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
applyPreset(preset: string): void {
|
||||||
|
this.rejectReason = preset;
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmReject(): void {
|
||||||
|
if (!this.rejectingRow) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.rejectReason.trim()) {
|
||||||
|
this.snackBar.open('A reason is required to reject.', 'Close', { duration: 4000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const row = this.rejectingRow;
|
||||||
|
this.busyId = row.id;
|
||||||
|
this.i4c.rejectHoldAction(row.id, this.rejectReason.trim()).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.busyId = null;
|
||||||
|
this.rejectingRow = null;
|
||||||
|
this.snackBar.open('Request rejected.', 'OK', { duration: 3000 });
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.busyId = null;
|
||||||
|
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
reasonLabel(value: string): string {
|
||||||
|
return HOLD_REASON_TYPES.find((r) => r.value === value)?.label || value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
<!--
|
||||||
|
Copyright since 2025 Mifos Initiative
|
||||||
|
|
||||||
|
This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<div class="i4c-page">
|
||||||
|
<!-- Top Filters & Search Toolbar -->
|
||||||
|
<div class="i4c-toolbar">
|
||||||
|
<div class="i4c-filter-chips">
|
||||||
|
@for (t of types; track t.value) {
|
||||||
|
<button class="filter-chip" [class.filter-chip--active]="type === t.value" (click)="setType(t.value)">
|
||||||
|
<span>{{ t.label }}</span>
|
||||||
|
@if (type === t.value) {
|
||||||
|
<span class="filter-chip-count">{{ total }}</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="i4c-toolbar__spacer"></span>
|
||||||
|
|
||||||
|
<div class="search-box">
|
||||||
|
<fa-icon icon="search" class="search-icon"></fa-icon>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="search"
|
||||||
|
placeholder="Filter by name, code or IFSC prefix..."
|
||||||
|
(keyup.enter)="load(true)"
|
||||||
|
class="search-input"
|
||||||
|
/>
|
||||||
|
@if (search) {
|
||||||
|
<button class="search-clear-btn" (click)="search = ''; load(true)" aria-label="Clear search">
|
||||||
|
<fa-icon icon="times"></fa-icon>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
<button mat-flat-button color="primary" class="search-btn" (click)="load(true)" [disabled]="loading">
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="ifsc-filter">
|
||||||
|
<mat-label>IFSC prefix</mat-label>
|
||||||
|
<mat-select [(ngModel)]="ifsc" (selectionChange)="load(true)">
|
||||||
|
<mat-option value="">Any</mat-option>
|
||||||
|
<mat-option value="KNOWN">Prefix known</mat-option>
|
||||||
|
<mat-option value="UNKNOWN">Prefix not known</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (loading) {
|
||||||
|
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
|
||||||
|
}
|
||||||
|
@if (error) {
|
||||||
|
<div class="i4c-error">
|
||||||
|
<fa-icon icon="exclamation-circle"></fa-icon>
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Main Directory Card -->
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card-head">
|
||||||
|
<fa-icon icon="university"></fa-icon>
|
||||||
|
<span>I4C Intermediary & Bank Master Directory</span>
|
||||||
|
<span class="head-hint">Official MHA-I4C 2.0 routing table for beneficiary & payee bank mapping</span>
|
||||||
|
|
||||||
|
<span class="i4c-toolbar-spacer"></span>
|
||||||
|
<span class="entries-counter">{{ total }} routing codes</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-table-wrap">
|
||||||
|
<table class="i4c-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="num">Bank Code</th>
|
||||||
|
<th>Institution / Intermediary Name</th>
|
||||||
|
<th>Entity Type</th>
|
||||||
|
<th>IFSC Prefix Pattern</th>
|
||||||
|
<th>Routing Standard</th>
|
||||||
|
<th>Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (b of rows; track b.bank_code) {
|
||||||
|
<tr class="clickable" (click)="openModal(b)">
|
||||||
|
<td class="num">
|
||||||
|
<span class="code-pill font-mono">#{{ b.bank_code }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="bank-name-cell">
|
||||||
|
<div class="entity-avatar entity-avatar--{{ b.entity_type?.toLowerCase() }}">
|
||||||
|
<fa-icon [icon]="getTypeIcon(b.entity_type)"></fa-icon>
|
||||||
|
</div>
|
||||||
|
<div class="bank-title-wrap">
|
||||||
|
<span class="bank-name font-semibold">{{ b.bank_name }}</span>
|
||||||
|
<span class="bank-code-sub">Reported as payee_bank_code</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="entity-type-badge entity-type-badge--{{ b.entity_type?.toLowerCase() }}">
|
||||||
|
{{ b.entity_type }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if (b.ifsc_prefix) {
|
||||||
|
<button
|
||||||
|
class="copy-chip copy-chip--ifsc"
|
||||||
|
(click)="copyText(b.ifsc_prefix, 'ifsc-' + b.bank_code, $event)"
|
||||||
|
title="Click to copy IFSC prefix"
|
||||||
|
>
|
||||||
|
<fa-icon [icon]="copiedKey === 'ifsc-' + b.bank_code ? 'check' : 'copy'"></fa-icon>
|
||||||
|
<span class="font-mono">{{ b.ifsc_prefix }}</span>
|
||||||
|
@if (copiedKey === 'ifsc-' + b.bank_code) {
|
||||||
|
<span class="copy-success-text">Copied!</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
} @else {
|
||||||
|
<span class="text-muted text-xs">Dynamic / Non-IFSC</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="standard-tag"> <fa-icon icon="shield-alt"></fa-icon> MHA-I4C 2.0 </span>
|
||||||
|
</td>
|
||||||
|
<td (click)="$event.stopPropagation()">
|
||||||
|
<button class="table-action-btn" (click)="openModal(b)"><fa-icon icon="eye"></fa-icon> Details</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="i4c-empty">
|
||||||
|
<div class="empty-icon"><fa-icon icon="university"></fa-icon></div>
|
||||||
|
<div>No banking intermediaries found matching "{{ search }}".</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<mat-paginator
|
||||||
|
class="i4c-paginator"
|
||||||
|
[length]="total"
|
||||||
|
[pageIndex]="pageIndex"
|
||||||
|
[pageSize]="pageSize"
|
||||||
|
[pageSizeOptions]="pageSizes"
|
||||||
|
(page)="onPage($event)"
|
||||||
|
showFirstLastButtons
|
||||||
|
aria-label="Bank codes pages"
|
||||||
|
></mat-paginator>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ========================================================
|
||||||
|
MODERN POPUP WINDOW: BANK CODE & ROUTING DETAILS MODAL
|
||||||
|
======================================================== -->
|
||||||
|
@if (selectedBankModal) {
|
||||||
|
<div class="i4c-modal-backdrop" (click)="closeModal()">
|
||||||
|
<div class="i4c-modal-window i4c-modal-window--wide" (click)="$event.stopPropagation()">
|
||||||
|
<!-- Modal Header -->
|
||||||
|
<div class="i4c-modal-header">
|
||||||
|
<div class="i4c-modal-header-title">
|
||||||
|
<div
|
||||||
|
class="entity-avatar entity-avatar--lg entity-avatar--{{ selectedBankModal.entity_type?.toLowerCase() }}"
|
||||||
|
>
|
||||||
|
<fa-icon [icon]="getTypeIcon(selectedBankModal.entity_type)"></fa-icon>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="modal-title-row">
|
||||||
|
<span>{{ selectedBankModal.bank_name }}</span>
|
||||||
|
<span class="entity-type-badge entity-type-badge--{{ selectedBankModal.entity_type?.toLowerCase() }}">
|
||||||
|
{{ selectedBankModal.entity_type }}
|
||||||
|
</span>
|
||||||
|
<span class="code-pill font-mono">Code #{{ selectedBankModal.bank_code }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="modal-header-sub">MHA-I4C Intermediary Specification 2.0 Directory Routing Node</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="modal-close-btn" (click)="closeModal()" aria-label="Close modal">
|
||||||
|
<fa-icon icon="times"></fa-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal Body -->
|
||||||
|
<div class="i4c-modal-body">
|
||||||
|
<!-- Information Grid -->
|
||||||
|
<div class="details-grid">
|
||||||
|
<div class="detail-card">
|
||||||
|
<span class="detail-card-label">I4C Root Bank ID</span>
|
||||||
|
<div class="detail-card-val font-mono">
|
||||||
|
<span>{{ selectedBankModal.bank_code }}</span>
|
||||||
|
<button
|
||||||
|
class="copy-chip copy-chip--sm"
|
||||||
|
(click)="copyText(selectedBankModal.bank_code, 'bank-code-modal')"
|
||||||
|
>
|
||||||
|
<fa-icon [icon]="copiedKey === 'bank-code-modal' ? 'check' : 'copy'"></fa-icon>
|
||||||
|
{{ copiedKey === 'bank-code-modal' ? 'Copied' : 'Copy' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span class="detail-card-sub">Used in root_bankid and payee_bank_code fields</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-card">
|
||||||
|
<span class="detail-card-label">IFSC Prefix</span>
|
||||||
|
<div class="detail-card-val font-mono">
|
||||||
|
<span>{{ selectedBankModal.ifsc_prefix || 'Dynamic Routing' }}</span>
|
||||||
|
@if (selectedBankModal.ifsc_prefix) {
|
||||||
|
<button
|
||||||
|
class="copy-chip copy-chip--sm"
|
||||||
|
(click)="copyText(selectedBankModal.ifsc_prefix, 'ifsc-modal')"
|
||||||
|
>
|
||||||
|
<fa-icon [icon]="copiedKey === 'ifsc-modal' ? 'check' : 'copy'"></fa-icon>
|
||||||
|
{{ copiedKey === 'ifsc-modal' ? 'Copied' : 'Copy' }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<span class="detail-card-sub">Matches starting characters of Indian Financial System Code</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-card">
|
||||||
|
<span class="detail-card-label">Entity Classification</span>
|
||||||
|
<div class="detail-card-val">
|
||||||
|
<span class="entity-type-badge entity-type-badge--{{ selectedBankModal.entity_type?.toLowerCase() }}">
|
||||||
|
{{ selectedBankModal.entity_type }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span class="detail-card-sub">Classification recognized by Indian Cyber Crime Coordination Centre</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-card">
|
||||||
|
<span class="detail-card-label">Supported Layer Depth</span>
|
||||||
|
<div class="detail-card-val font-semibold text-accent">Layer 1 to Layer 10+</div>
|
||||||
|
<span class="detail-card-sub">Valid for multi-layered money trail tracing</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Section 6 Integration Preview -->
|
||||||
|
<div class="payload-box">
|
||||||
|
<div class="payload-box-title">
|
||||||
|
<span>Section 6 Response Payload Example</span>
|
||||||
|
<button class="copy-chip" (click)="copyText(sampleJson(selectedBankModal), 'json-modal')">
|
||||||
|
<fa-icon [icon]="copiedKey === 'json-modal' ? 'check' : 'copy'"></fa-icon>
|
||||||
|
{{ copiedKey === 'json-modal' ? 'Copied!' : 'Copy Schema Snippet' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre class="i4c-json">{{ sampleJson(selectedBankModal) }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal Footer -->
|
||||||
|
<div class="i4c-modal-footer">
|
||||||
|
<button class="footer-btn-close" (click)="closeModal()">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,360 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@use '../i4c' as *;
|
||||||
|
|
||||||
|
@include i4c-skin;
|
||||||
|
|
||||||
|
.search-box {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 9px;
|
||||||
|
padding: 3px 5px 3px 10px;
|
||||||
|
gap: 6px;
|
||||||
|
box-shadow: 0 1px 3px rgb(15 23 42 / 5%);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:focus-within {
|
||||||
|
border-color: #2563eb;
|
||||||
|
box-shadow: 0 0 0 3px rgb(37 99 235 / 15%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-icon {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: #0f172a;
|
||||||
|
width: 220px;
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-clear-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #94a3b8;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 3px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #0f172a;
|
||||||
|
background: #f1f5f9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-btn {
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 7px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.i4c-card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 9px 14px;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid #f1f5f9;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.i4c-toolbar-spacer {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entries-counter {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #475569;
|
||||||
|
background: #f1f5f9;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bank-name-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
.bank-title-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bank-name {
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bank-code-sub {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-avatar {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 12px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #475569;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&--lg {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
font-size: 15px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--bnk {
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #1d4ed8;
|
||||||
|
border-color: #bfdbfe;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--wlt {
|
||||||
|
background: #f5f3ff;
|
||||||
|
color: #7c3aed;
|
||||||
|
border-color: #ddd6fe;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--mer {
|
||||||
|
background: #ecfdf5;
|
||||||
|
color: #059669;
|
||||||
|
border-color: #a7f3d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--ppi {
|
||||||
|
background: #fffbeb;
|
||||||
|
color: #d97706;
|
||||||
|
border-color: #fde68a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-type-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #475569;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
|
||||||
|
&--bnk {
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #1d4ed8;
|
||||||
|
border-color: #bfdbfe;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--wlt {
|
||||||
|
background: #f5f3ff;
|
||||||
|
color: #7c3aed;
|
||||||
|
border-color: #ddd6fe;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--mer {
|
||||||
|
background: #ecfdf5;
|
||||||
|
color: #059669;
|
||||||
|
border-color: #a7f3d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--ppi {
|
||||||
|
background: #fffbeb;
|
||||||
|
color: #d97706;
|
||||||
|
border-color: #fde68a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-pill {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
color: #0f172a;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
color: #334155;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--ifsc {
|
||||||
|
background: #f0fdf4;
|
||||||
|
border-color: #bbf7d0;
|
||||||
|
color: #166534;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #dcfce7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--sm {
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-success-text {
|
||||||
|
color: #16a34a;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.standard-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header-sub {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-weight: normal;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-card {
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
|
||||||
|
&-label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #64748b;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-val {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-sub {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.payload-box {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
&-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-btn-close {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #334155;
|
||||||
|
border: none;
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #cbd5e1;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ifsc-filter {
|
||||||
|
width: 170px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
|
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||||
|
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||||
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
import { I4cService, i4cErrorMessage } from '../i4c.service';
|
||||||
|
|
||||||
|
/** The I4C Bank Code Master from the specification: codes the bank reports as root_bankid / payee_bank_code. */
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-i4c-bank-master',
|
||||||
|
templateUrl: './i4c-bank-master.component.html',
|
||||||
|
styleUrls: ['./i4c-bank-master.component.scss'],
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatSelectModule,
|
||||||
|
MatProgressBarModule,
|
||||||
|
MatPaginatorModule,
|
||||||
|
FaIconComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class I4cBankMasterComponent implements OnInit {
|
||||||
|
private i4c = inject(I4cService);
|
||||||
|
|
||||||
|
readonly types = [
|
||||||
|
{ value: '', label: 'All Entities' },
|
||||||
|
{ value: 'BNK', label: 'Commercial Banks' },
|
||||||
|
{ value: 'WLT', label: 'Digital Wallets' },
|
||||||
|
{ value: 'MER', label: 'Payment Merchants' },
|
||||||
|
{ value: 'PPI', label: 'Prepaid Instruments' },
|
||||||
|
{ value: 'CBD', label: 'CBD' },
|
||||||
|
{ value: 'INS', label: 'Insurance' }
|
||||||
|
];
|
||||||
|
|
||||||
|
search = '';
|
||||||
|
type = '';
|
||||||
|
ifsc = '';
|
||||||
|
total = 0;
|
||||||
|
pageIndex = 0;
|
||||||
|
pageSize = 50;
|
||||||
|
readonly pageSizes = [
|
||||||
|
25,
|
||||||
|
50,
|
||||||
|
100,
|
||||||
|
200
|
||||||
|
];
|
||||||
|
rows: any[] = [];
|
||||||
|
loading = false;
|
||||||
|
error = '';
|
||||||
|
copiedKey = '';
|
||||||
|
selectedBankModal: any = null;
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
setType(t: string): void {
|
||||||
|
if (this.type === t) return;
|
||||||
|
this.type = t;
|
||||||
|
this.load(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
onPage(e: PageEvent): void {
|
||||||
|
this.pageIndex = e.pageIndex;
|
||||||
|
this.pageSize = e.pageSize;
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reload the current page; a changed filter passes `fromFirstPage` so the officer lands on page one. */
|
||||||
|
load(fromFirstPage = false): void {
|
||||||
|
if (fromFirstPage) {
|
||||||
|
this.pageIndex = 0;
|
||||||
|
}
|
||||||
|
this.loading = true;
|
||||||
|
this.error = '';
|
||||||
|
this.i4c
|
||||||
|
.bankMaster({
|
||||||
|
search: this.search.trim(),
|
||||||
|
type: this.type,
|
||||||
|
ifsc: this.ifsc,
|
||||||
|
offset: this.pageIndex * this.pageSize,
|
||||||
|
limit: this.pageSize
|
||||||
|
})
|
||||||
|
.subscribe({
|
||||||
|
next: (page) => {
|
||||||
|
this.rows = page.pageItems;
|
||||||
|
this.total = page.totalFilteredRecords;
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.error = i4cErrorMessage(e);
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
openModal(bank: any): void {
|
||||||
|
this.selectedBankModal = bank;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeModal(): void {
|
||||||
|
this.selectedBankModal = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
copyText(text: string, key: string, e?: MouseEvent): void {
|
||||||
|
if (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
if (!text) return;
|
||||||
|
navigator.clipboard?.writeText(text);
|
||||||
|
this.copiedKey = key;
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.copiedKey === key) {
|
||||||
|
this.copiedKey = '';
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
getTypeIcon(type: string): string {
|
||||||
|
switch (type?.toUpperCase()) {
|
||||||
|
case 'BNK':
|
||||||
|
return 'university';
|
||||||
|
case 'WLT':
|
||||||
|
return 'mobile-alt';
|
||||||
|
case 'MER':
|
||||||
|
return 'coins';
|
||||||
|
case 'PPI':
|
||||||
|
return 'credit-card';
|
||||||
|
default:
|
||||||
|
return 'building';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sampleJson(b: any): string {
|
||||||
|
if (!b) return '';
|
||||||
|
const sample = {
|
||||||
|
layer_number: 1,
|
||||||
|
transaction_details: {
|
||||||
|
payee_bank_code: b.bank_code,
|
||||||
|
payee_bank_name: b.bank_name,
|
||||||
|
payee_ifsc_prefix: b.ifsc_prefix || 'N/A',
|
||||||
|
entity_category: b.entity_type
|
||||||
|
},
|
||||||
|
mha_routing: {
|
||||||
|
root_bankid: b.bank_code,
|
||||||
|
standard: 'I4C-MHA-2.0'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return JSON.stringify(sample, null, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,484 @@
|
|||||||
|
<!--
|
||||||
|
Copyright since 2025 Mifos Initiative
|
||||||
|
|
||||||
|
This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<div class="i4c-page">
|
||||||
|
<!-- Modern Toolbar -->
|
||||||
|
<div class="i4c-toolbar">
|
||||||
|
<div class="i4c-toolbar__hint">
|
||||||
|
<fa-icon icon="info-circle" class="hint-ic"></fa-icon>
|
||||||
|
<span
|
||||||
|
>Live NCRP-CFCFRMS Integration: automated complaint processing, targeted account holds & MHA
|
||||||
|
responses.</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<span class="i4c-toolbar__spacer"></span>
|
||||||
|
<button mat-stroked-button class="action-btn" (click)="showConfigModal = true" [disabled]="!data">
|
||||||
|
<fa-icon icon="cog"></fa-icon> Integration Config
|
||||||
|
</button>
|
||||||
|
<button mat-flat-button color="primary" class="action-btn" (click)="load()" [disabled]="loading">
|
||||||
|
<fa-icon icon="sync" [animation]="loading ? 'spin' : undefined"></fa-icon> Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (loading) {
|
||||||
|
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
|
||||||
|
}
|
||||||
|
@if (error) {
|
||||||
|
<div class="i4c-error">
|
||||||
|
<fa-icon icon="exclamation-circle"></fa-icon>
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (data) {
|
||||||
|
@if (setupGaps.length) {
|
||||||
|
<div class="setup-banner">
|
||||||
|
<div class="setup-banner__icon">
|
||||||
|
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||||
|
</div>
|
||||||
|
<div class="setup-banner__content">
|
||||||
|
<div class="setup-banner__title">Configuration & Production Setup Needed</div>
|
||||||
|
<ul class="setup-banner__list">
|
||||||
|
@for (gap of setupGaps; track gap) {
|
||||||
|
<li>{{ gap }}</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- KPI Groups: Complaints, Holds, Needs Action -->
|
||||||
|
<div class="kpi-groups">
|
||||||
|
<!-- Section 1: Complaints -->
|
||||||
|
<section class="kpi-group">
|
||||||
|
<div class="kpi-group__head">
|
||||||
|
<fa-icon icon="inbox"></fa-icon>
|
||||||
|
<span>Complaints</span>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-group__row">
|
||||||
|
<a class="modern-kpi" routerLink="../requests">
|
||||||
|
<div class="kpi-top">
|
||||||
|
<span class="kpi-icon-circle kpi-icon-circle--blue"><fa-icon icon="clock"></fa-icon></span>
|
||||||
|
<span class="kpi-trend">Last 24h</span>
|
||||||
|
</div>
|
||||||
|
<span class="modern-kpi__value">{{ data.requestsLast24h }}</span>
|
||||||
|
<span class="modern-kpi__label">Received (24 h)</span>
|
||||||
|
<span class="modern-kpi__sub">{{ data.requestsLast30d }} received in 30 days</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a class="modern-kpi" routerLink="../requests" [class.modern-kpi--warn]="data.requestsPending > 0">
|
||||||
|
<div class="kpi-top">
|
||||||
|
<span class="kpi-icon-circle kpi-icon-circle--amber"><fa-icon icon="tasks"></fa-icon></span>
|
||||||
|
@if (data.requestsPending > 0) {
|
||||||
|
<span class="kpi-trend kpi-trend--warn">Needs processing</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<span class="modern-kpi__value">{{ data.requestsPending }}</span>
|
||||||
|
<span class="modern-kpi__label">Still Processing</span>
|
||||||
|
<span class="modern-kpi__sub">queued / retry queue</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Section 2: Holds -->
|
||||||
|
<section class="kpi-group">
|
||||||
|
<div class="kpi-group__head">
|
||||||
|
<fa-icon icon="lock"></fa-icon>
|
||||||
|
<span>Holds Register</span>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-group__row">
|
||||||
|
<a class="modern-kpi" routerLink="../holds" [queryParams]="{ view: 'ACTIVE' }">
|
||||||
|
<div class="kpi-top">
|
||||||
|
<span class="kpi-icon-circle kpi-icon-circle--green"><fa-icon icon="shield-alt"></fa-icon></span>
|
||||||
|
<span class="kpi-trend kpi-trend--green">Active</span>
|
||||||
|
</div>
|
||||||
|
<span class="modern-kpi__value">{{ data.activeHolds }}</span>
|
||||||
|
<span class="modern-kpi__label">Active Account Holds</span>
|
||||||
|
<span class="modern-kpi__sub">disputed amounts restricted</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a class="modern-kpi" routerLink="../holds" [queryParams]="{ view: 'ACTIVE' }">
|
||||||
|
<div class="kpi-top">
|
||||||
|
<span class="kpi-icon-circle kpi-icon-circle--blue"><fa-icon icon="coins"></fa-icon></span>
|
||||||
|
</div>
|
||||||
|
<span class="modern-kpi__value">₹{{ data.amountHeld | number: '1.2-2' }}</span>
|
||||||
|
<span class="modern-kpi__label">Total Amount Held</span>
|
||||||
|
<span class="modern-kpi__sub">customer account balance</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
class="modern-kpi"
|
||||||
|
routerLink="../holds"
|
||||||
|
[queryParams]="{ view: 'PROSPECTIVE' }"
|
||||||
|
[class.modern-kpi--warn]="data.prospectiveShortfall > 0"
|
||||||
|
>
|
||||||
|
<div class="kpi-top">
|
||||||
|
<span class="kpi-icon-circle kpi-icon-circle--amber"><fa-icon icon="exclamation-circle"></fa-icon></span>
|
||||||
|
</div>
|
||||||
|
<span class="modern-kpi__value">₹{{ data.prospectiveShortfall | number: '1.2-2' }}</span>
|
||||||
|
<span class="modern-kpi__label">Prospective Shortfall</span>
|
||||||
|
<span class="modern-kpi__sub">funds pending deposit</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Section 3: Needs Action -->
|
||||||
|
<section class="kpi-group kpi-group--action">
|
||||||
|
<div class="kpi-group__head">
|
||||||
|
<fa-icon icon="bell"></fa-icon>
|
||||||
|
<span>Needs Immediate Action</span>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-group__row">
|
||||||
|
<a
|
||||||
|
class="modern-kpi"
|
||||||
|
routerLink="../holds"
|
||||||
|
[queryParams]="{ view: 'INTIMATION_DUE' }"
|
||||||
|
[class.modern-kpi--alert]="data.intimationOverdue > 0"
|
||||||
|
>
|
||||||
|
<div class="kpi-top">
|
||||||
|
<span class="kpi-icon-circle kpi-icon-circle--red"><fa-icon icon="envelope"></fa-icon></span>
|
||||||
|
@if (data.intimationOverdue > 0) {
|
||||||
|
<span class="kpi-trend kpi-trend--alert">Overdue</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<span class="modern-kpi__value">{{ data.intimationOverdue }}</span>
|
||||||
|
<span class="modern-kpi__label">Intimation Overdue</span>
|
||||||
|
<span class="modern-kpi__sub">RBI notification window</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
class="modern-kpi"
|
||||||
|
routerLink="../holds"
|
||||||
|
[queryParams]="{ view: 'REVIEW_DUE' }"
|
||||||
|
[class.modern-kpi--warn]="data.holdsReviewDue > 0"
|
||||||
|
>
|
||||||
|
<div class="kpi-top">
|
||||||
|
<span class="kpi-icon-circle kpi-icon-circle--amber"><fa-icon icon="calendar-check"></fa-icon></span>
|
||||||
|
</div>
|
||||||
|
<span class="modern-kpi__value">{{ data.holdsReviewDue }}</span>
|
||||||
|
<span class="modern-kpi__label">90-Day Review Due</span>
|
||||||
|
<span class="modern-kpi__sub">due within 15 days</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a class="modern-kpi" routerLink="../approvals" [class.modern-kpi--warn]="data.pendingApprovals > 0">
|
||||||
|
<div class="kpi-top">
|
||||||
|
<span class="kpi-icon-circle kpi-icon-circle--purple"><fa-icon icon="user-check"></fa-icon></span>
|
||||||
|
@if (data.pendingApprovals > 0) {
|
||||||
|
<span class="kpi-trend kpi-trend--warn">Pending Checker</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<span class="modern-kpi__value">{{ data.pendingApprovals }}</span>
|
||||||
|
<span class="modern-kpi__label">Awaiting Approval</span>
|
||||||
|
<span class="modern-kpi__sub">hold release / freeze</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
class="modern-kpi"
|
||||||
|
routerLink="../outbox"
|
||||||
|
[queryParams]="{ status: 'FAILED' }"
|
||||||
|
[class.modern-kpi--alert]="data.outboxFailed > 0"
|
||||||
|
>
|
||||||
|
<div class="kpi-top">
|
||||||
|
<span class="kpi-icon-circle kpi-icon-circle--red"><fa-icon icon="paper-plane"></fa-icon></span>
|
||||||
|
@if (data.outboxFailed > 0) {
|
||||||
|
<span class="kpi-trend kpi-trend--alert">Outbox Fail</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<span class="modern-kpi__value">{{ data.outboxFailed }}</span>
|
||||||
|
<span class="modern-kpi__label">Delivery Failed</span>
|
||||||
|
<span class="modern-kpi__sub">{{ data.outboxPending }} queued to send</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Middle Row: API Performance + Complaints Status -->
|
||||||
|
<div class="dashboard-grid">
|
||||||
|
<!-- API Performance 30 Days -->
|
||||||
|
<div class="i4c-card card-perf">
|
||||||
|
<div class="i4c-card__head">
|
||||||
|
<fa-icon icon="tachometer-alt"></fa-icon>
|
||||||
|
<span>API Gateway Performance (Last 30 Days)</span>
|
||||||
|
<span class="rbi-badge"><fa-icon icon="shield-alt"></fa-icon> RBI Review Periodic Spec</span>
|
||||||
|
</div>
|
||||||
|
<div class="i4c-table-wrap">
|
||||||
|
<table class="i4c-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Direction</th>
|
||||||
|
<th class="num">Calls</th>
|
||||||
|
<th class="num">Succeeded</th>
|
||||||
|
<th class="num">Failed</th>
|
||||||
|
<th class="num">Success Rate</th>
|
||||||
|
<th class="num">Avg (ms)</th>
|
||||||
|
<th class="num">Max (ms)</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (row of data.apiPerformance30d; track row.direction) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span class="direction-chip direction-chip--{{ row.direction?.toLowerCase() }}">
|
||||||
|
{{ row.direction === 'IN' ? '↓ Inbound (I4C → Bank)' : '↑ Outbound (Bank → I4C)' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="num font-semibold">{{ row.calls }}</td>
|
||||||
|
<td class="num success-num">{{ row.succeeded }}</td>
|
||||||
|
<td class="num" [class.fail-num]="row.failed > 0">{{ row.failed }}</td>
|
||||||
|
<td class="num">
|
||||||
|
@if (successRate(row) === null) {
|
||||||
|
<span class="text-muted">-</span>
|
||||||
|
} @else {
|
||||||
|
<div class="modern-rate">
|
||||||
|
<div class="modern-rate__bar">
|
||||||
|
<div class="modern-rate__fill" [style.width.%]="successRate(row)"></div>
|
||||||
|
</div>
|
||||||
|
<span class="modern-rate__text">{{ successRate(row) | number: '1.0-1' }}%</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="num latency-pill">{{ row.avg_ms ? row.avg_ms + ' ms' : '-' }}</td>
|
||||||
|
<td class="num latency-pill">{{ row.max_ms ? row.max_ms + ' ms' : '-' }}</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="i4c-empty">No API calls recorded in the last 30 days.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer-info">
|
||||||
|
<div class="footer-stat">
|
||||||
|
<fa-icon icon="clock"></fa-icon>
|
||||||
|
<span>Average time from complaint arrival to response delivery:</span>
|
||||||
|
<b class="stat-highlight">{{
|
||||||
|
data.avgAckToResponseSeconds === null ? '-' : data.avgAckToResponseSeconds + ' seconds'
|
||||||
|
}}</b>
|
||||||
|
</div>
|
||||||
|
<div class="footer-note">
|
||||||
|
Note: Failed inbound calls include unauthenticated requests and invalid signature payloads.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Complaints by Status -->
|
||||||
|
<div class="i4c-card card-status">
|
||||||
|
<div class="i4c-card__head">
|
||||||
|
<fa-icon icon="tasks"></fa-icon>
|
||||||
|
<span>Complaints by Status</span>
|
||||||
|
</div>
|
||||||
|
<div class="status-grid">
|
||||||
|
@for (row of data.requestsByStatus; track row.status) {
|
||||||
|
<a class="status-tile" [routerLink]="['../requests']" [queryParams]="{ status: row.status }">
|
||||||
|
<div class="status-tile__left">
|
||||||
|
<span class="i4c-badge" [class]="statusClass(row.status)">{{ row.status }}</span>
|
||||||
|
<span class="status-tile__hint">View complaints</span>
|
||||||
|
</div>
|
||||||
|
<span class="status-tile__count">{{ row.count }}</span>
|
||||||
|
</a>
|
||||||
|
} @empty {
|
||||||
|
<div class="i4c-empty">No complaints received yet.</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom Row: Findings + Configuration Preview -->
|
||||||
|
<div class="dashboard-grid dashboard-grid--equal">
|
||||||
|
<!-- Reported to I4C -->
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head">
|
||||||
|
<fa-icon icon="list-alt"></fa-icon>
|
||||||
|
<span>Findings Reported to I4C (Section 6)</span>
|
||||||
|
</div>
|
||||||
|
<div class="i4c-table-wrap">
|
||||||
|
<table class="i4c-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Transaction / Hold Type</th>
|
||||||
|
<th class="num">Entries</th>
|
||||||
|
<th class="num">Reported Amount</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (row of data.findingsByType; track row.txn_type) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<b>{{ row.txn_type }}</b>
|
||||||
|
</td>
|
||||||
|
<td class="num">{{ row.count }}</td>
|
||||||
|
<td class="num highlight-amt">₹{{ row.amount | number: '1.2-2' }}</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="3" class="i4c-empty">No findings reported to I4C yet.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Configuration Snapshot -->
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head">
|
||||||
|
<fa-icon icon="cog"></fa-icon>
|
||||||
|
<span>Live Gateway Configuration</span>
|
||||||
|
<button class="config-popup-trigger" (click)="showConfigModal = true">
|
||||||
|
<fa-icon icon="external-link-alt"></fa-icon> Details
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="config-grid">
|
||||||
|
<div class="config-tile">
|
||||||
|
<div class="config-tile__label">Gateway Status</div>
|
||||||
|
<div class="config-tile__value">
|
||||||
|
<span class="i4c-badge" [class]="data.configuration.enabled ? 'i4c-badge--green' : 'i4c-badge--red'">
|
||||||
|
{{ data.configuration.enabled ? 'ENABLED' : 'DISABLED' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="config-tile">
|
||||||
|
<div class="config-tile__label">Tenant</div>
|
||||||
|
<div class="config-tile__value">{{ data.configuration.tenant }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="config-tile">
|
||||||
|
<div class="config-tile__label">Bank Code (I4C root)</div>
|
||||||
|
<div class="config-tile__value">
|
||||||
|
<b>{{ data.configuration.bankCode || 'Not configured' }}</b>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="config-tile">
|
||||||
|
<div class="config-tile__label">Encryption Mode</div>
|
||||||
|
<div class="config-tile__value">
|
||||||
|
<span class="crypto-chip">{{ data.configuration.cryptoMode }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="config-tile config-tile--full">
|
||||||
|
<div class="config-tile__label">I4C Outbound API Base URL</div>
|
||||||
|
<div class="config-tile__value url-val">
|
||||||
|
<span>{{ data.configuration.outboundBaseUrl || 'Not configured' }}</span>
|
||||||
|
@if (data.configuration.outboundBaseUrl) {
|
||||||
|
<button class="copy-btn" (click)="copyText(data.configuration.outboundBaseUrl, 'url')">
|
||||||
|
{{ copiedKey === 'url' ? 'Copied!' : 'Copy' }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="config-tile">
|
||||||
|
<div class="config-tile__label">Review Window</div>
|
||||||
|
<div class="config-tile__value">{{ data.configuration.holdReviewDays }} Days (90-day cycle)</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="config-tile">
|
||||||
|
<div class="config-tile__label">Match Window</div>
|
||||||
|
<div class="config-tile__value">± {{ data.configuration.matchDateWindowDays }} Days</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- ========================================================
|
||||||
|
MODERN POPUP WINDOW: CONFIGURATION DETAILS MODAL
|
||||||
|
======================================================== -->
|
||||||
|
@if (showConfigModal && data?.configuration) {
|
||||||
|
<div class="i4c-modal-backdrop" (click)="showConfigModal = false">
|
||||||
|
<div class="i4c-modal-window i4c-modal-window--wide" (click)="$event.stopPropagation()">
|
||||||
|
<div class="i4c-modal-header">
|
||||||
|
<div class="i4c-modal-header__title">
|
||||||
|
<fa-icon icon="cog"></fa-icon>
|
||||||
|
<div>
|
||||||
|
<span>I4C-MHA Integration Configuration</span>
|
||||||
|
<div class="i4c-modal-header__sub">
|
||||||
|
NCRP-CFCFRMS Specification 2.0 Security & Connection Parameters
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="modal-close-btn" (click)="showConfigModal = false" aria-label="Close">
|
||||||
|
<fa-icon icon="times"></fa-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-modal-body">
|
||||||
|
<dl class="i4c-kv">
|
||||||
|
<dt>Integration Enabled</dt>
|
||||||
|
<dd>
|
||||||
|
<span class="i4c-badge" [class]="data.configuration.enabled ? 'i4c-badge--green' : 'i4c-badge--red'">
|
||||||
|
{{ data.configuration.enabled ? 'ACTIVE (Online)' : 'DISABLED' }}
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>Tenant ID</dt>
|
||||||
|
<dd>{{ data.configuration.tenant }}</dd>
|
||||||
|
|
||||||
|
<dt>Bank Code (I4C ID)</dt>
|
||||||
|
<dd>
|
||||||
|
<b>{{ data.configuration.bankCode }}</b>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>Inbound Credentials</dt>
|
||||||
|
<dd>
|
||||||
|
{{ data.configuration.inboundCredentialsConfigured ? 'Configured' : 'Not configured' }}
|
||||||
|
@if (data.configuration.inboundPasswordHashed) {
|
||||||
|
<span class="badge-mini">bcrypt hash active</span>
|
||||||
|
}
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>Allowed I4C IPs</dt>
|
||||||
|
<dd>{{ data.configuration.allowedIpCount || 'Any / All (UAT mode)' }}</dd>
|
||||||
|
|
||||||
|
<dt>Payload Encryption</dt>
|
||||||
|
<dd>
|
||||||
|
<span class="crypto-chip">{{ data.configuration.cryptoMode }}</span>
|
||||||
|
@if (data.configuration.cryptoPassphraseConfigured) {
|
||||||
|
<span class="badge-mini">passphrase active</span>
|
||||||
|
}
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>Outbound Base URL</dt>
|
||||||
|
<dd class="font-mono">{{ data.configuration.outboundBaseUrl || 'Not set' }}</dd>
|
||||||
|
|
||||||
|
<dt>Automatic Hold Review</dt>
|
||||||
|
<dd>{{ data.configuration.holdReviewDays }} days</dd>
|
||||||
|
|
||||||
|
<dt>Transaction Match Window</dt>
|
||||||
|
<dd>± {{ data.configuration.matchDateWindowDays }} days</dd>
|
||||||
|
|
||||||
|
<dt>Simulator Status</dt>
|
||||||
|
<dd>
|
||||||
|
<span
|
||||||
|
class="i4c-badge"
|
||||||
|
[class]="data.configuration.simulatorEnabled ? 'i4c-badge--amber' : 'i4c-badge--green'"
|
||||||
|
>
|
||||||
|
{{ data.configuration.simulatorEnabled ? 'SIMULATOR ACTIVE (UAT)' : 'PRODUCTION' }}
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div class="i4c-note">
|
||||||
|
<fa-icon icon="shield-alt"></fa-icon>
|
||||||
|
<span
|
||||||
|
>All passwords, encryption keys, and tokens are stored in the server keystore and never revealed on the
|
||||||
|
UI.</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-modal-footer">
|
||||||
|
<button class="footer-btn-close" (click)="showConfigModal = false">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,499 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@use '../i4c' as *;
|
||||||
|
|
||||||
|
@include i4c-skin;
|
||||||
|
|
||||||
|
/* Setup Banner */
|
||||||
|
.setup-banner {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid #fde68a;
|
||||||
|
background: linear-gradient(135deg, #fffbeb 0%, #fef3c7 100%);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 4px 10px rgb(245 158 11 / 8%);
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
font-size: 18px;
|
||||||
|
color: #d97706;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #92400e;
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__list {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
color: #92400e;
|
||||||
|
font-size: 11.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* KPI Groups */
|
||||||
|
.kpi-groups {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2fr 3fr 4fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 1280px) {
|
||||||
|
.kpi-groups {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
|
||||||
|
.kpi-group--action {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 768px) {
|
||||||
|
.kpi-groups {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
&__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: #64748b;
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__row {
|
||||||
|
display: grid;
|
||||||
|
grid-auto-flow: column;
|
||||||
|
grid-auto-columns: minmax(0, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 580px) {
|
||||||
|
.kpi-group__row {
|
||||||
|
grid-auto-flow: row;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modern Fintech KPI Tile */
|
||||||
|
.modern-kpi {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
box-shadow: 0 3px 10px rgb(15 23 42 / 3%);
|
||||||
|
transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: #93c5fd;
|
||||||
|
box-shadow: 0 8px 16px -3px rgb(37 99 235 / 12%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-icon-circle {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
&--blue {
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--green {
|
||||||
|
background: #ecfdf5;
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--amber {
|
||||||
|
background: #fffbeb;
|
||||||
|
color: #d97706;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--red {
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--purple {
|
||||||
|
background: #f5f3ff;
|
||||||
|
color: #7c3aed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-trend {
|
||||||
|
font-size: 9.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #64748b;
|
||||||
|
|
||||||
|
&--green {
|
||||||
|
background: #ecfdf5;
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--warn {
|
||||||
|
background: #fffbeb;
|
||||||
|
color: #d97706;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--alert {
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__value {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #0f172a;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__sub {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--warn {
|
||||||
|
border-color: #fde68a;
|
||||||
|
background: #fffdf5;
|
||||||
|
|
||||||
|
.modern-kpi__value {
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--alert {
|
||||||
|
border-color: #fecaca;
|
||||||
|
background: #fffbfb;
|
||||||
|
|
||||||
|
.modern-kpi__value {
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dashboard Grid */
|
||||||
|
.dashboard-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2fr 1fr;
|
||||||
|
gap: 18px;
|
||||||
|
|
||||||
|
&--equal {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 1024px) {
|
||||||
|
.dashboard-grid,
|
||||||
|
.dashboard-grid--equal {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* API Performance */
|
||||||
|
.rbi-badge {
|
||||||
|
margin-left: auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e40af;
|
||||||
|
background: #dbeafe;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.direction-chip {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 3px 9px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
|
||||||
|
&--in {
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--out {
|
||||||
|
background: #f0fdf4;
|
||||||
|
color: #15803d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-num {
|
||||||
|
color: #059669;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fail-num {
|
||||||
|
color: #dc2626;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modern-rate {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
|
||||||
|
&__bar {
|
||||||
|
width: 60px;
|
||||||
|
height: 7px;
|
||||||
|
background: #fee2e2;
|
||||||
|
border-radius: 9999px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__fill {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #10b981, #059669);
|
||||||
|
border-radius: 9999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__text {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.latency-pill {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-footer-info {
|
||||||
|
padding: 12px 20px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-top: 1px solid #e2e8f0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #475569;
|
||||||
|
|
||||||
|
.footer-stat {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-highlight {
|
||||||
|
color: #0f172a;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-note {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status Tile List */
|
||||||
|
.status-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tile {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #f1f5f9;
|
||||||
|
background: #f8fafc;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #fff;
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
box-shadow: 0 4px 10px rgb(15 23 42 / 5%);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__left {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__count {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #0f172a;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Configuration Grid */
|
||||||
|
.config-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-tile {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
&--full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__value {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #0f172a;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-val {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-popup-trigger {
|
||||||
|
margin-left: auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
color: #2563eb;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.crypto-chip {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #1e40af;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-btn {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #2563eb;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #1d4ed8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-mini {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.highlight-amt {
|
||||||
|
color: #0f172a;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
|
import { DecimalPipe } from '@angular/common';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||||
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
import { I4cService, i4cErrorMessage } from '../i4c.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integration health at a glance, and the API performance figures the RBI letter (05 Dec 2025, para 4) asks to be
|
||||||
|
* reviewed periodically: calls, failures and response times in each direction.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-i4c-dashboard',
|
||||||
|
templateUrl: './i4c-dashboard.component.html',
|
||||||
|
styleUrls: ['./i4c-dashboard.component.scss'],
|
||||||
|
imports: [
|
||||||
|
DecimalPipe,
|
||||||
|
RouterLink,
|
||||||
|
MatButtonModule,
|
||||||
|
MatProgressBarModule,
|
||||||
|
FaIconComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class I4cDashboardComponent implements OnInit {
|
||||||
|
private i4c = inject(I4cService);
|
||||||
|
|
||||||
|
data: any = null;
|
||||||
|
loading = false;
|
||||||
|
error = '';
|
||||||
|
showConfigModal = false;
|
||||||
|
copiedKey = '';
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
load(): void {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = '';
|
||||||
|
this.i4c.dashboard().subscribe({
|
||||||
|
next: (d) => {
|
||||||
|
this.data = d;
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.error = i4cErrorMessage(e);
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
copyText(text: string, key: string): void {
|
||||||
|
if (!text) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
this.copiedKey = key;
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.copiedKey === key) {
|
||||||
|
this.copiedKey = '';
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
statusClass(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'PROCESSED':
|
||||||
|
return 'i4c-badge--green';
|
||||||
|
case 'RETRY':
|
||||||
|
return 'i4c-badge--red';
|
||||||
|
case 'RECEIVED':
|
||||||
|
case 'PROCESSING':
|
||||||
|
return 'i4c-badge--amber';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Success rate of calls in one direction, as a percentage; null when there were none. */
|
||||||
|
successRate(row: any): number | null {
|
||||||
|
const calls = Number(row?.calls || 0);
|
||||||
|
return calls === 0 ? null : (Number(row?.succeeded || 0) * 100) / calls;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What still stands between this bank and live traffic, worked out from the configuration. */
|
||||||
|
get setupGaps(): string[] {
|
||||||
|
const c = this.data?.configuration;
|
||||||
|
if (!c) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const gaps: string[] = [];
|
||||||
|
if (!c.enabled) {
|
||||||
|
gaps.push('Integration is switched off (fineract.i4c.enabled=false): I4C calls are refused with 503.');
|
||||||
|
}
|
||||||
|
if (!c.inboundCredentialsConfigured) {
|
||||||
|
gaps.push('No inbound username/password set for I4C to obtain a token.');
|
||||||
|
} else if (!c.inboundPasswordHashed) {
|
||||||
|
gaps.push('Inbound password is configured in plain text; set the bcrypt hash for production.');
|
||||||
|
}
|
||||||
|
if (!c.allowedIpCount) {
|
||||||
|
gaps.push('No I4C source IP allowlist: any address may call (UAT only).');
|
||||||
|
}
|
||||||
|
if (c.cryptoMode !== 'NONE' && !c.cryptoPassphraseConfigured) {
|
||||||
|
gaps.push('Encryption mode is ' + c.cryptoMode + ' but no passphrase is configured.');
|
||||||
|
}
|
||||||
|
if (!c.outboundConfigured) {
|
||||||
|
gaps.push('I4C response URL is not configured: replies wait in the outbox.');
|
||||||
|
}
|
||||||
|
if (!c.bankCode) {
|
||||||
|
gaps.push("This bank's I4C bank code is not set (fineract.i4c.bank-code); root_bankid is sent as 0.");
|
||||||
|
}
|
||||||
|
if (c.simulatorEnabled) {
|
||||||
|
gaps.push('The local I4C simulator is enabled; switch it off in production.');
|
||||||
|
}
|
||||||
|
return gaps;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<!--
|
||||||
|
Copyright since 2025 Mifos Initiative
|
||||||
|
|
||||||
|
This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<div class="date-range" role="group" [attr.aria-label]="label + ' filter'">
|
||||||
|
<span class="date-range__label"><fa-icon icon="calendar-alt"></fa-icon> {{ label }}</span>
|
||||||
|
<div class="date-range__presets">
|
||||||
|
@for (p of presets; track p.value) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="date-chip"
|
||||||
|
[class.date-chip--active]="range.preset === p.value"
|
||||||
|
[attr.aria-pressed]="range.preset === p.value"
|
||||||
|
(click)="pick(p.value)"
|
||||||
|
>
|
||||||
|
{{ p.label }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (range.preset !== 'ALL') {
|
||||||
|
<div class="date-range__custom">
|
||||||
|
<label>
|
||||||
|
From
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
[max]="range.toDate || today"
|
||||||
|
[(ngModel)]="range.fromDate"
|
||||||
|
(change)="customChanged()"
|
||||||
|
[attr.aria-label]="label + ' from'"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
To
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
[min]="range.fromDate"
|
||||||
|
[max]="today"
|
||||||
|
[(ngModel)]="range.toDate"
|
||||||
|
(change)="customChanged()"
|
||||||
|
[attr.aria-label]="label + ' to'"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-range {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px 12px;
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #475569;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__presets {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 3px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__custom {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 8px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #0f172a;
|
||||||
|
background: #fff;
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid #2563eb;
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-chip {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #475569;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid #2563eb;
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--active {
|
||||||
|
background: #2563eb;
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
|
||||||
|
export type I4cDatePreset = 'ALL' | 'TODAY' | 'YESTERDAY' | 'WEEK' | 'MONTH' | 'CUSTOM';
|
||||||
|
|
||||||
|
/** A date range as the list APIs take it: yyyy-MM-dd, both days included, empty for "no limit". */
|
||||||
|
export interface I4cDateRange {
|
||||||
|
preset: I4cDatePreset;
|
||||||
|
fromDate: string;
|
||||||
|
toDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ALL_DATES: I4cDateRange = { preset: 'ALL', fromDate: '', toDate: '' };
|
||||||
|
|
||||||
|
/** yyyy-MM-dd in the browser's own time zone (toISOString would shift to UTC). */
|
||||||
|
function iso(d: Date): string {
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The days a preset stands for, worked out from today. */
|
||||||
|
export function presetRange(preset: I4cDatePreset): I4cDateRange {
|
||||||
|
const today = new Date();
|
||||||
|
const day = (offset: number) => new Date(today.getFullYear(), today.getMonth(), today.getDate() + offset);
|
||||||
|
switch (preset) {
|
||||||
|
case 'TODAY':
|
||||||
|
return { preset, fromDate: iso(today), toDate: iso(today) };
|
||||||
|
case 'YESTERDAY':
|
||||||
|
return { preset, fromDate: iso(day(-1)), toDate: iso(day(-1)) };
|
||||||
|
case 'WEEK': {
|
||||||
|
// The week starts on Monday.
|
||||||
|
const sinceMonday = (today.getDay() + 6) % 7;
|
||||||
|
return { preset, fromDate: iso(day(-sinceMonday)), toDate: iso(today) };
|
||||||
|
}
|
||||||
|
case 'MONTH':
|
||||||
|
return { preset, fromDate: iso(new Date(today.getFullYear(), today.getMonth(), 1)), toDate: iso(today) };
|
||||||
|
default:
|
||||||
|
return { ...ALL_DATES };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Date filter shared by the I4C lists: All, Today, Yesterday, This Week, This Month, or a custom From / To. Emits the
|
||||||
|
* new range whenever it changes; the page then reloads from page one.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-i4c-date-range',
|
||||||
|
templateUrl: './i4c-date-range.component.html',
|
||||||
|
styleUrls: ['./i4c-date-range.component.scss'],
|
||||||
|
imports: [
|
||||||
|
FormsModule,
|
||||||
|
FaIconComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class I4cDateRangeComponent {
|
||||||
|
@Input() label = 'Date';
|
||||||
|
@Input() range: I4cDateRange = { ...ALL_DATES };
|
||||||
|
@Output() rangeChange = new EventEmitter<I4cDateRange>();
|
||||||
|
|
||||||
|
readonly presets: { value: I4cDatePreset; label: string }[] = [
|
||||||
|
{ value: 'ALL', label: 'All' },
|
||||||
|
{ value: 'TODAY', label: 'Today' },
|
||||||
|
{ value: 'YESTERDAY', label: 'Yesterday' },
|
||||||
|
{ value: 'WEEK', label: 'This Week' },
|
||||||
|
{ value: 'MONTH', label: 'This Month' },
|
||||||
|
{ value: 'CUSTOM', label: 'Custom' }
|
||||||
|
];
|
||||||
|
|
||||||
|
readonly today = iso(new Date());
|
||||||
|
|
||||||
|
pick(preset: I4cDatePreset): void {
|
||||||
|
if (preset === 'CUSTOM') {
|
||||||
|
// Keep the days already shown so the officer can adjust them.
|
||||||
|
this.range = { ...this.range, preset };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.emit(presetRange(preset));
|
||||||
|
}
|
||||||
|
|
||||||
|
customChanged(): void {
|
||||||
|
const { fromDate, toDate } = this.range;
|
||||||
|
if (fromDate && toDate && toDate < fromDate) {
|
||||||
|
// A reversed range would match nothing; read it the way it was meant.
|
||||||
|
this.range = { preset: 'CUSTOM', fromDate: toDate, toDate: fromDate };
|
||||||
|
}
|
||||||
|
this.emit({ ...this.range, preset: 'CUSTOM' });
|
||||||
|
}
|
||||||
|
|
||||||
|
private emit(range: I4cDateRange): void {
|
||||||
|
this.range = range;
|
||||||
|
this.rangeChange.emit(range);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<!--
|
||||||
|
Copyright since 2025 Mifos Initiative
|
||||||
|
|
||||||
|
This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<div class="docs">
|
||||||
|
@if (missing.length) {
|
||||||
|
<div class="docs__missing">
|
||||||
|
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||||
|
Still needed:
|
||||||
|
@for (m of missing; track m; let last = $last) {
|
||||||
|
<b>{{ label(m) }}</b
|
||||||
|
>{{ last ? '' : ', ' }}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<table class="i4c-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>File</th>
|
||||||
|
<th>Remarks</th>
|
||||||
|
<th>Uploaded</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (d of rows; track d.id) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ label(d.doc_type) }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="docs__name">{{ d.file_name }}</span>
|
||||||
|
<div class="docs__sub" [title]="'SHA-256 ' + d.sha256">
|
||||||
|
{{ size(d.size_bytes) }} · {{ d.sha256.slice(0, 12) }}…
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>{{ d.remarks || '-' }}</td>
|
||||||
|
<td>
|
||||||
|
{{ d.uploaded_at | date: 'dd MMM yyyy, HH:mm' }}
|
||||||
|
<div class="docs__sub">{{ d.uploaded_by }}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button mat-stroked-button type="button" (click)="download(d)">
|
||||||
|
<fa-icon icon="download"></fa-icon> Download
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="i4c-empty">No documents attached yet.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
@if (canUpload) {
|
||||||
|
<div class="docs__upload">
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Document type</mat-label>
|
||||||
|
<mat-select [(ngModel)]="docType">
|
||||||
|
@for (t of types; track t.value) {
|
||||||
|
<mat-option [value]="t.value">{{ t.label }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="docs__remarks">
|
||||||
|
<mat-label>Remarks (order no., date…)</mat-label>
|
||||||
|
<input matInput [(ngModel)]="remarks" maxlength="500" />
|
||||||
|
</mat-form-field>
|
||||||
|
<label class="docs__file">
|
||||||
|
<input #fileInput type="file" accept="application/pdf,image/jpeg,image/png" (change)="pick($event)" />
|
||||||
|
</label>
|
||||||
|
<button mat-flat-button color="primary" type="button" (click)="upload(fileInput)" [disabled]="uploading || !file">
|
||||||
|
<fa-icon icon="upload"></fa-icon> {{ uploading ? 'Uploading…' : 'Attach' }}
|
||||||
|
</button>
|
||||||
|
<span class="docs__sub">PDF, JPEG or PNG, up to 5 MB. Stored with a SHA-256; never changed once attached.</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@use '../i4c' as *;
|
||||||
|
|
||||||
|
@include i4c-skin;
|
||||||
|
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
&__missing {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #fde68a;
|
||||||
|
background: #fffbeb;
|
||||||
|
color: #92400e;
|
||||||
|
font-size: 12.5px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__name {
|
||||||
|
font-weight: 600;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__upload {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid #f1f5f9;
|
||||||
|
|
||||||
|
mat-form-field {
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__remarks {
|
||||||
|
flex: 1 1 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__file input {
|
||||||
|
font-size: 12px;
|
||||||
|
max-width: 240px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, EventEmitter, Input, OnChanges, Output, inject } from '@angular/core';
|
||||||
|
import { DatePipe } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
|
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||||
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
import { DOCUMENT_TYPES, I4cDocument, I4cDocumentOwner, I4cService, i4cErrorMessage, saveBlob } from '../i4c.service';
|
||||||
|
|
||||||
|
const MAX_BYTES = 5 * 1024 * 1024;
|
||||||
|
const ACCEPTED = [
|
||||||
|
'application/pdf',
|
||||||
|
'image/jpeg',
|
||||||
|
'image/png'
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Documents behind a hold, grievance or restoration: list, download, and upload (PDF / JPEG / PNG up to 5 MB). Shows
|
||||||
|
* which of the {@link required} types are still missing.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-i4c-documents',
|
||||||
|
templateUrl: './i4c-documents.component.html',
|
||||||
|
styleUrls: ['./i4c-documents.component.scss'],
|
||||||
|
imports: [
|
||||||
|
DatePipe,
|
||||||
|
FormsModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatSelectModule,
|
||||||
|
FaIconComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class I4cDocumentsComponent implements OnChanges {
|
||||||
|
private i4c = inject(I4cService);
|
||||||
|
private snackBar = inject(MatSnackBar);
|
||||||
|
|
||||||
|
@Input({ required: true }) ownerType!: I4cDocumentOwner;
|
||||||
|
@Input({ required: true }) ownerId!: number;
|
||||||
|
/** Document types this record needs; missing ones are flagged. */
|
||||||
|
@Input() required: string[] = [];
|
||||||
|
/** Types offered first in the picker. */
|
||||||
|
@Input() suggested: string[] = [];
|
||||||
|
@Input() canUpload = true;
|
||||||
|
/** Documents already loaded with the record, to avoid a second call. */
|
||||||
|
@Input() documents: I4cDocument[] | null = null;
|
||||||
|
@Output() changed = new EventEmitter<void>();
|
||||||
|
|
||||||
|
rows: I4cDocument[] = [];
|
||||||
|
docType = '';
|
||||||
|
remarks = '';
|
||||||
|
file: File | null = null;
|
||||||
|
uploading = false;
|
||||||
|
|
||||||
|
ngOnChanges(): void {
|
||||||
|
if (this.documents) {
|
||||||
|
this.rows = this.documents;
|
||||||
|
} else {
|
||||||
|
this.reload();
|
||||||
|
}
|
||||||
|
if (!this.docType) {
|
||||||
|
this.docType = this.suggested[0] || this.missing[0] || 'OTHER';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get types(): { value: string; label: string }[] {
|
||||||
|
const first = DOCUMENT_TYPES.filter((t) => this.suggested.includes(t.value) || this.required.includes(t.value));
|
||||||
|
return [
|
||||||
|
...first,
|
||||||
|
...DOCUMENT_TYPES.filter((t) => !first.includes(t))
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
get missing(): string[] {
|
||||||
|
return this.required.filter((t) => !this.rows.some((d) => d.doc_type === t));
|
||||||
|
}
|
||||||
|
|
||||||
|
label(type: string): string {
|
||||||
|
return DOCUMENT_TYPES.find((t) => t.value === type)?.label || type;
|
||||||
|
}
|
||||||
|
|
||||||
|
reload(): void {
|
||||||
|
if (!this.ownerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.i4c.documents(this.ownerType, this.ownerId).subscribe({
|
||||||
|
next: (rows) => (this.rows = rows),
|
||||||
|
error: (e) => this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pick(event: Event): void {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const f = input.files?.[0] || null;
|
||||||
|
if (f && !ACCEPTED.includes(f.type)) {
|
||||||
|
this.snackBar.open('Only PDF, JPEG and PNG files can be attached.', 'Close', { duration: 5000 });
|
||||||
|
input.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (f && f.size > MAX_BYTES) {
|
||||||
|
this.snackBar.open('A document can be at most 5 MB.', 'Close', { duration: 5000 });
|
||||||
|
input.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.file = f;
|
||||||
|
}
|
||||||
|
|
||||||
|
upload(input: HTMLInputElement): void {
|
||||||
|
if (!this.file || !this.docType) {
|
||||||
|
this.snackBar.open('Choose the document type and the file.', 'Close', { duration: 4000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const file = this.file;
|
||||||
|
this.uploading = true;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
const dataUrl = String(reader.result || '');
|
||||||
|
const content = dataUrl.substring(dataUrl.indexOf(',') + 1);
|
||||||
|
this.i4c
|
||||||
|
.uploadDocument({
|
||||||
|
ownerType: this.ownerType,
|
||||||
|
ownerId: this.ownerId,
|
||||||
|
docType: this.docType,
|
||||||
|
fileName: file.name,
|
||||||
|
contentType: file.type,
|
||||||
|
content,
|
||||||
|
remarks: this.remarks.trim() || undefined
|
||||||
|
})
|
||||||
|
.subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.uploading = false;
|
||||||
|
this.file = null;
|
||||||
|
this.remarks = '';
|
||||||
|
input.value = '';
|
||||||
|
this.snackBar.open(this.label(this.docType) + ' attached.', 'OK', { duration: 3000 });
|
||||||
|
this.documents = null;
|
||||||
|
this.reload();
|
||||||
|
this.changed.emit();
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.uploading = false;
|
||||||
|
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
reader.onerror = () => {
|
||||||
|
this.uploading = false;
|
||||||
|
this.snackBar.open('The file could not be read.', 'Close', { duration: 5000 });
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
download(d: I4cDocument): void {
|
||||||
|
this.i4c.downloadDocument(d.id).subscribe({
|
||||||
|
next: (blob) => saveBlob(blob, d.file_name),
|
||||||
|
error: (e) => this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
size(bytes: number): string {
|
||||||
|
return bytes < 1024 * 1024 ? Math.ceil(bytes / 1024) + ' KB' : (bytes / 1024 / 1024).toFixed(1) + ' MB';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,558 @@
|
|||||||
|
<!--
|
||||||
|
Copyright since 2025 Mifos Initiative
|
||||||
|
|
||||||
|
This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<div class="i4c-page">
|
||||||
|
<div class="i4c-toolbar">
|
||||||
|
<div class="i4c-filter-chips">
|
||||||
|
<button class="filter-chip" [class.filter-chip--active]="stage === 'OPEN'" (click)="setStage('OPEN')">
|
||||||
|
<span>All open</span>
|
||||||
|
</button>
|
||||||
|
@for (s of stages; track s.value) {
|
||||||
|
<button class="filter-chip" [class.filter-chip--active]="stage === s.value" (click)="setStage(s.value)">
|
||||||
|
<span>{{ s.label }}</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
<button class="filter-chip" [class.filter-chip--active]="stage === ''" (click)="setStage('')">
|
||||||
|
<span>All</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span class="i4c-toolbar__spacer"></span>
|
||||||
|
<span class="count-tag">{{ total }} grievances</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-filter-bar">
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="search-input">
|
||||||
|
<mat-label>GR id / account / ack no. / customer</mat-label>
|
||||||
|
<input matInput [(ngModel)]="search" (keyup.enter)="load(true)" (blur)="load(true)" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Outcome</mat-label>
|
||||||
|
<mat-select [(ngModel)]="status" (selectionChange)="load(true)">
|
||||||
|
<mat-option value="">Any</mat-option>
|
||||||
|
<mat-option value="OPEN">Pending decision</mat-option>
|
||||||
|
<mat-option value="ALLOWED">Allowed (order given)</mat-option>
|
||||||
|
<mat-option value="REJECTED">Rejected</mat-option>
|
||||||
|
<mat-option value="COURT">Left to the court</mat-option>
|
||||||
|
<mat-option value="WITHDRAWN">Withdrawn</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-checkbox [(ngModel)]="overdueOnly" (change)="load(true)">Past deadline only</mat-checkbox>
|
||||||
|
<mifosx-i4c-date-range label="Raised" [range]="dates" (rangeChange)="onDates($event)"></mifosx-i4c-date-range>
|
||||||
|
<button mat-stroked-button class="i4c-filter-bar__clear" (click)="clearFilters()" [disabled]="!hasFilters">
|
||||||
|
<fa-icon icon="times"></fa-icon> Clear filters
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (loading) {
|
||||||
|
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
|
||||||
|
}
|
||||||
|
@if (error) {
|
||||||
|
<div class="i4c-error">
|
||||||
|
<fa-icon icon="exclamation-circle"></fa-icon> <span>{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head">
|
||||||
|
<fa-icon icon="balance-scale"></fa-icon>
|
||||||
|
<span>GRM Grievances (NCRP Grievance Redressal Module)</span>
|
||||||
|
<span class="head-hint"
|
||||||
|
>Raise a grievance from the hold (Holds Register → hold → GRM / MRM). Click a row to work on it.</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="i4c-table-wrap">
|
||||||
|
<table class="i4c-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Raised</th>
|
||||||
|
<th>GR id</th>
|
||||||
|
<th>Account / Customer</th>
|
||||||
|
<th>Customer asks</th>
|
||||||
|
<th class="num">Held (₹)</th>
|
||||||
|
<th>Stage</th>
|
||||||
|
<th>Deadline</th>
|
||||||
|
<th>Outcome</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (g of rows; track g.id) {
|
||||||
|
<tr class="clickable" (click)="open(g.id)">
|
||||||
|
<td>
|
||||||
|
{{ g.created_at | date: 'dd MMM yyyy' }}
|
||||||
|
<div class="sub">{{ g.created_by }}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<b>{{ g.grievance_no || 'Draft #' + g.id }}</b>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<b>{{ g.account_no }}</b>
|
||||||
|
<div class="sub">{{ g.client_name }} · Ack {{ g.acknowledgement_no }}</div>
|
||||||
|
</td>
|
||||||
|
<td>{{ requestLabel(g.customer_request) }}</td>
|
||||||
|
<td class="num">{{ g.amount_held | number: '1.2-2' }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="i4c-badge">{{ stageLabel(g.stage) }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if (g.compliance_due_at && g.stage !== 'CLOSED') {
|
||||||
|
<span class="deadline" [class.deadline--late]="g.compliance_overdue">
|
||||||
|
Carry out by {{ g.compliance_due_at | date: 'dd MMM, HH:mm' }}
|
||||||
|
</span>
|
||||||
|
} @else if (g.due_at) {
|
||||||
|
<span class="deadline" [class.deadline--late]="g.overdue">{{ g.due_at | date: 'dd MMM yyyy' }}</span>
|
||||||
|
} @else {
|
||||||
|
-
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
class="i4c-badge"
|
||||||
|
[class.i4c-badge--green]="g.status === 'ALLOWED'"
|
||||||
|
[class.i4c-badge--red]="g.status === 'REJECTED' || g.status === 'COURT'"
|
||||||
|
[class.i4c-badge--amber]="g.status === 'OPEN'"
|
||||||
|
>{{ g.status }}</span
|
||||||
|
>
|
||||||
|
@if (g.decision) {
|
||||||
|
<div class="sub">{{ g.decision_level }}: {{ decisionLabel(g.decision) }}</div>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="8" class="i4c-empty">No grievances match these filters.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<mat-paginator
|
||||||
|
class="i4c-paginator"
|
||||||
|
[length]="total"
|
||||||
|
[pageIndex]="pageIndex"
|
||||||
|
[pageSize]="pageSize"
|
||||||
|
[pageSizeOptions]="pageSizes"
|
||||||
|
(page)="onPage($event)"
|
||||||
|
showFirstLastButtons
|
||||||
|
aria-label="Grievance pages"
|
||||||
|
></mat-paginator>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (selected; as g) {
|
||||||
|
<div class="i4c-modal-backdrop" (click)="close()">
|
||||||
|
<div class="i4c-modal-window i4c-modal-window--wide" (click)="$event.stopPropagation()">
|
||||||
|
<div class="i4c-modal-header">
|
||||||
|
<div class="i4c-modal-header__title">
|
||||||
|
<fa-icon icon="balance-scale"></fa-icon>
|
||||||
|
<div>
|
||||||
|
<div class="modal-title-row">
|
||||||
|
<span>{{ g.grievance_no || 'Draft grievance #' + g.id }}</span>
|
||||||
|
<span class="i4c-badge">{{ stageLabel(g.stage) }}</span>
|
||||||
|
<span
|
||||||
|
class="i4c-badge"
|
||||||
|
[class.i4c-badge--green]="g.status === 'ALLOWED'"
|
||||||
|
[class.i4c-badge--red]="g.status === 'REJECTED' || g.status === 'COURT'"
|
||||||
|
>{{ g.status }}</span
|
||||||
|
>
|
||||||
|
@if (g.overdue || g.compliance_overdue) {
|
||||||
|
<span class="i4c-badge i4c-badge--red">PAST DEADLINE</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="i4c-modal-header__sub">
|
||||||
|
Account <b>{{ g.account_no }}</b> ({{ g.client_name }}) · Ack {{ g.acknowledgement_no }} · Held ₹{{
|
||||||
|
g.amount_held | number: '1.2-2'
|
||||||
|
}}{{ g.debit_frozen ? ' · debits frozen' : '' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="i4c-modal-header__actions">
|
||||||
|
<button class="modal-close-btn" (click)="close()" aria-label="Close">
|
||||||
|
<fa-icon icon="times"></fa-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-modal-tabs">
|
||||||
|
<button class="modal-tab" [class.modal-tab--active]="tab === 'workflow'" (click)="tab = 'workflow'">
|
||||||
|
<fa-icon icon="tasks"></fa-icon> Next step
|
||||||
|
</button>
|
||||||
|
<button class="modal-tab" [class.modal-tab--active]="tab === 'overview'" (click)="tab = 'overview'">
|
||||||
|
<fa-icon icon="info-circle"></fa-icon> Details & EDD
|
||||||
|
</button>
|
||||||
|
<button class="modal-tab" [class.modal-tab--active]="tab === 'documents'" (click)="tab = 'documents'">
|
||||||
|
<fa-icon icon="paperclip"></fa-icon> Documents ({{ g.documents?.length || 0 }})
|
||||||
|
</button>
|
||||||
|
<button class="modal-tab" [class.modal-tab--active]="tab === 'history'" (click)="tab = 'history'">
|
||||||
|
<fa-icon icon="history"></fa-icon> History ({{ g.events?.length || 0 }})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-modal-body">
|
||||||
|
<div class="flow">
|
||||||
|
@for (f of flow(g); track f.value; let last = $last) {
|
||||||
|
<span
|
||||||
|
class="flow__stage"
|
||||||
|
[class.flow__stage--done]="f.state === 'done'"
|
||||||
|
[class.flow__stage--now]="f.state === 'now'"
|
||||||
|
>{{ f.label }}</span
|
||||||
|
>
|
||||||
|
@if (!last) {
|
||||||
|
<span class="flow__arrow">›</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (tab === 'workflow') {
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__body">
|
||||||
|
@if (g.stage === 'DRAFT') {
|
||||||
|
@if (!g.edd_done) {
|
||||||
|
<div class="step">
|
||||||
|
<div class="step__title">1. Enhanced due diligence (before raising on GRM)</div>
|
||||||
|
<div class="step__row">
|
||||||
|
<mat-checkbox [(ngModel)]="edd.kycVerified">KYC re-verified</mat-checkbox>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Risk</mat-label>
|
||||||
|
<mat-select [(ngModel)]="edd.risk">
|
||||||
|
<mat-option value="LOW">Low</mat-option>
|
||||||
|
<mat-option value="MEDIUM">Medium</mat-option>
|
||||||
|
<mat-option value="HIGH">High</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Bank's view</mat-label>
|
||||||
|
<mat-select [(ngModel)]="edd.recommendation">
|
||||||
|
<mat-option value="SUPPORT">Supports the request</mat-option>
|
||||||
|
<mat-option value="NOT_SUPPORT">Does not support it</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
<mat-form-field appearance="outline" class="step__wide">
|
||||||
|
<mat-label>Source of the disputed credit, as the customer explains it *</mat-label>
|
||||||
|
<textarea matInput rows="2" [(ngModel)]="edd.sourceOfFunds" maxlength="2000"></textarea>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" class="step__wide">
|
||||||
|
<mat-label>Customer's statement / notes</mat-label>
|
||||||
|
<textarea matInput rows="2" [(ngModel)]="edd.customerStatement" maxlength="2000"></textarea>
|
||||||
|
</mat-form-field>
|
||||||
|
<div class="i4c-actions">
|
||||||
|
<button mat-flat-button color="primary" (click)="saveEdd()" [disabled]="busy">
|
||||||
|
<fa-icon icon="user-shield"></fa-icon> Save due diligence
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="step">
|
||||||
|
<div class="step__title">2. Raise it on GRM, then record the GR id</div>
|
||||||
|
<div class="sub">
|
||||||
|
GRM's entry form needs the KYC document, the customer's application, a photograph and the bank
|
||||||
|
statement. Attach them on the Documents tab (the statement can be downloaded from the hold).
|
||||||
|
</div>
|
||||||
|
@if (missingDocs(g).length) {
|
||||||
|
<div class="i4c-note">Still to attach: {{ missingDocs(g).join(', ') }}</div>
|
||||||
|
}
|
||||||
|
<div class="step__row">
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>GR id from GRM *</mat-label>
|
||||||
|
<input matInput [(ngModel)]="grievanceNo" maxlength="40" placeholder="GR003260316001" />
|
||||||
|
</mat-form-field>
|
||||||
|
<button
|
||||||
|
mat-flat-button
|
||||||
|
color="primary"
|
||||||
|
(click)="submit()"
|
||||||
|
[disabled]="busy || !grievanceNo.trim()"
|
||||||
|
>
|
||||||
|
<fa-icon icon="paper-plane"></fa-icon> Mark raised on GRM
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (g.stage === 'NODAL_REVIEW') {
|
||||||
|
<div class="step">
|
||||||
|
<div class="step__title">Bank GRM nodal officer's decision</div>
|
||||||
|
<mat-form-field appearance="outline" class="step__wide">
|
||||||
|
<mat-label>Remarks (required to reject)</mat-label>
|
||||||
|
<input matInput [(ngModel)]="remarks" maxlength="2000" />
|
||||||
|
</mat-form-field>
|
||||||
|
<div class="i4c-actions">
|
||||||
|
<button mat-flat-button color="primary" (click)="nodal(true)" [disabled]="busy">
|
||||||
|
<fa-icon icon="check"></fa-icon> Approved on GRM (to IO)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
mat-stroked-button
|
||||||
|
color="warn"
|
||||||
|
(click)="nodal(false)"
|
||||||
|
[disabled]="busy || !remarks.trim()"
|
||||||
|
>
|
||||||
|
<fa-icon icon="times"></fa-icon> Rejected by nodal
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (withOfficer && g.status === 'OPEN') {
|
||||||
|
<div class="step">
|
||||||
|
<div class="step__title">
|
||||||
|
Decision of the {{ level }}
|
||||||
|
@if (g.due_at) {
|
||||||
|
<span class="deadline" [class.deadline--late]="isLate(g.due_at)">
|
||||||
|
· due {{ g.due_at | date: 'dd MMM yyyy' }}</span
|
||||||
|
>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="step__row">
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="step__wide">
|
||||||
|
<mat-label>Decision on GRM</mat-label>
|
||||||
|
<mat-select [(ngModel)]="decision.decision">
|
||||||
|
@for (d of decisions; track d.value) {
|
||||||
|
<mat-option [value]="d.value">{{ d.label }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
@if (decision.decision === 'PARTIAL_HOLD') {
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Amount to keep on hold (₹)</mat-label>
|
||||||
|
<input matInput type="number" min="0" [(ngModel)]="decision.keepAmount" />
|
||||||
|
</mat-form-field>
|
||||||
|
}
|
||||||
|
@if (decision.decision !== 'SEEK_INFO' && decision.decision !== 'REJECTED_CONTINUE') {
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Order no. *</mat-label>
|
||||||
|
<input matInput [(ngModel)]="decision.orderRef" maxlength="200" />
|
||||||
|
</mat-form-field>
|
||||||
|
}
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Order / decision date</mat-label>
|
||||||
|
<input matInput type="date" [(ngModel)]="decision.orderDate" />
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
<mat-form-field appearance="outline" class="step__wide">
|
||||||
|
<mat-label
|
||||||
|
>Remarks{{ decision.decision === 'REJECTED_CONTINUE' ? ' (reason given) *' : '' }}</mat-label
|
||||||
|
>
|
||||||
|
<input matInput [(ngModel)]="decision.remarks" maxlength="2000" />
|
||||||
|
</mat-form-field>
|
||||||
|
@if (decision.decision !== 'SEEK_INFO' && decision.decision !== 'REJECTED_CONTINUE') {
|
||||||
|
<div class="sub">
|
||||||
|
Attach the signed and stamped order copy (GRM order or IO order) on the Documents tab first.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="i4c-actions">
|
||||||
|
<button mat-flat-button color="primary" (click)="decide()" [disabled]="busy">
|
||||||
|
<fa-icon icon="gavel"></fa-icon> Record decision
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="step">
|
||||||
|
<div class="step__title">Something else happened on GRM</div>
|
||||||
|
<div class="step__row">
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Event</mat-label>
|
||||||
|
<mat-select [(ngModel)]="noteType">
|
||||||
|
@for (n of noteEvents; track n.value) {
|
||||||
|
<mat-option [value]="n.value">{{ n.label }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="step__wide">
|
||||||
|
<mat-label>Remarks</mat-label>
|
||||||
|
<input matInput [(ngModel)]="remarks" maxlength="2000" />
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
<div class="i4c-actions">
|
||||||
|
<button mat-stroked-button (click)="note()" [disabled]="busy">
|
||||||
|
<fa-icon icon="plus"></fa-icon> Add to history
|
||||||
|
</button>
|
||||||
|
@if (g.stage === 'WITH_IO') {
|
||||||
|
<button mat-stroked-button (click)="investigation()" [disabled]="busy">
|
||||||
|
<fa-icon icon="search"></fa-icon> IO is investigating (+15 days)
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (g.status === 'REJECTED' && g.stage !== 'CLOSED') {
|
||||||
|
<div class="step">
|
||||||
|
<div class="step__title">Rejected by the {{ g.decision_level }}: {{ g.decision_remarks }}</div>
|
||||||
|
<div class="sub">
|
||||||
|
If the customer is not satisfied, the branch raises a review: IO → District GRO → State GRO (15
|
||||||
|
days each); after the State GRO the customer may approach the jurisdictional court.
|
||||||
|
</div>
|
||||||
|
<mat-form-field appearance="outline" class="step__wide">
|
||||||
|
<mat-label>Remarks</mat-label>
|
||||||
|
<input matInput [(ngModel)]="remarks" maxlength="2000" />
|
||||||
|
</mat-form-field>
|
||||||
|
<div class="i4c-actions">
|
||||||
|
<button mat-flat-button color="primary" (click)="escalate()" [disabled]="busy">
|
||||||
|
<fa-icon icon="arrow-up"></fa-icon> {{ escalateLabel }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (g.status === 'ALLOWED' && g.stage !== 'CLOSED') {
|
||||||
|
<div class="step">
|
||||||
|
<div class="step__title">
|
||||||
|
Order: {{ decisionLabel(g.decision) }}
|
||||||
|
@if (g.decision_keep_amount !== null) {
|
||||||
|
(keep ₹{{ g.decision_keep_amount | number: '1.2-2' }})
|
||||||
|
}
|
||||||
|
· {{ g.decision_order_ref }}
|
||||||
|
</div>
|
||||||
|
<div class="sub">
|
||||||
|
Carry it out on the account by
|
||||||
|
<span class="deadline" [class.deadline--late]="g.compliance_overdue">{{
|
||||||
|
g.compliance_due_at | date: 'dd MMM yyyy, HH:mm'
|
||||||
|
}}</span
|
||||||
|
>. The change goes to a second officer for approval; the grievance closes when it is approved.
|
||||||
|
</div>
|
||||||
|
@if (g.actionRequest?.length) {
|
||||||
|
<div class="sub">
|
||||||
|
Request #{{ g.actionRequest[0].id }} ({{ g.actionRequest[0].action }}) is
|
||||||
|
<b>{{ g.actionRequest[0].status }}</b>
|
||||||
|
{{ g.actionRequest[0].failure_message || g.actionRequest[0].rejection_reason || '' }}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (
|
||||||
|
!g.actionRequest?.length ||
|
||||||
|
g.actionRequest[0].status === 'REJECTED' ||
|
||||||
|
g.actionRequest[0].status === 'FAILED'
|
||||||
|
) {
|
||||||
|
<div class="i4c-actions">
|
||||||
|
<button mat-flat-button color="warn" (click)="raiseAction()" [disabled]="busy">
|
||||||
|
<fa-icon icon="hand-paper"></fa-icon> Raise the account change for approval
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (g.stage === 'CLOSED') {
|
||||||
|
<div class="i4c-note">This grievance is closed ({{ g.status }}).</div>
|
||||||
|
} @else {
|
||||||
|
<div class="step">
|
||||||
|
<div class="i4c-actions">
|
||||||
|
<button mat-button color="warn" (click)="withdraw()" [disabled]="busy">
|
||||||
|
Customer withdrew the grievance
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (tab === 'overview') {
|
||||||
|
<div class="grid-2">
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head"><fa-icon icon="user"></fa-icon> Customer's request</div>
|
||||||
|
<div class="i4c-card__body">
|
||||||
|
<dl class="i4c-kv">
|
||||||
|
<dt>Asks to</dt>
|
||||||
|
<dd>{{ requestLabel(g.customer_request) }}</dd>
|
||||||
|
<dt>Reason given</dt>
|
||||||
|
<dd class="wrap">{{ g.customer_reason || '-' }}</dd>
|
||||||
|
<dt>Opened</dt>
|
||||||
|
<dd>{{ g.created_at | date: 'dd MMM yyyy, HH:mm' }} by {{ g.created_by }}</dd>
|
||||||
|
<dt>Decision</dt>
|
||||||
|
<dd>
|
||||||
|
{{ g.decision ? g.decision_level + ': ' + decisionLabel(g.decision) : '-' }}
|
||||||
|
@if (g.decision_order_ref) {
|
||||||
|
<div class="sub">
|
||||||
|
Order {{ g.decision_order_ref }} {{ g.decision_date | date: 'dd MMM yyyy' }}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head"><fa-icon icon="user-shield"></fa-icon> Enhanced due diligence</div>
|
||||||
|
<div class="i4c-card__body">
|
||||||
|
@if (g.edd_done) {
|
||||||
|
<dl class="i4c-kv">
|
||||||
|
<dt>KYC re-verified</dt>
|
||||||
|
<dd>{{ g.edd_kyc_verified ? 'Yes' : 'No' }}</dd>
|
||||||
|
<dt>Source of funds</dt>
|
||||||
|
<dd class="wrap">{{ g.edd_source_of_funds }}</dd>
|
||||||
|
<dt>Statement</dt>
|
||||||
|
<dd class="wrap">{{ g.edd_customer_statement || '-' }}</dd>
|
||||||
|
<dt>Risk / view</dt>
|
||||||
|
<dd>
|
||||||
|
{{ g.edd_risk }} ·
|
||||||
|
{{ g.edd_recommendation === 'SUPPORT' ? 'Bank supports' : 'Bank does not support' }}
|
||||||
|
</dd>
|
||||||
|
<dt>By</dt>
|
||||||
|
<dd>{{ g.edd_by }}, {{ g.edd_at | date: 'dd MMM yyyy, HH:mm' }}</dd>
|
||||||
|
</dl>
|
||||||
|
} @else {
|
||||||
|
<div class="i4c-note">Not done yet.</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (tab === 'documents') {
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__body">
|
||||||
|
<mifosx-i4c-documents
|
||||||
|
ownerType="GRIEVANCE"
|
||||||
|
[ownerId]="g.id"
|
||||||
|
[required]="g.requiredDocuments || []"
|
||||||
|
[suggested]="['GRM_ORDER', 'IO_ORDER', 'EDD_REPORT']"
|
||||||
|
[documents]="g.documents"
|
||||||
|
[canUpload]="g.stage !== 'CLOSED'"
|
||||||
|
(changed)="open(g.id)"
|
||||||
|
></mifosx-i4c-documents>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (tab === 'history') {
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-table-wrap">
|
||||||
|
<table class="i4c-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>When</th>
|
||||||
|
<th>Event</th>
|
||||||
|
<th>Stage</th>
|
||||||
|
<th>Remarks</th>
|
||||||
|
<th>By</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (e of g.events; track e.id) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ e.created_at | date: 'dd MMM yyyy, HH:mm' }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="i4c-badge">{{ e.event_type }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ stageLabel(e.stage) }}</td>
|
||||||
|
<td class="wrap">{{ e.remarks || '-' }}</td>
|
||||||
|
<td>{{ e.actor }}</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-modal-footer">
|
||||||
|
<button class="footer-btn-close" (click)="close()">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@use '../i4c' as *;
|
||||||
|
@use '../i4c-cases' as *;
|
||||||
|
|
||||||
|
@include i4c-skin;
|
||||||
|
@include i4c-case-page;
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
|
import { DatePipe, DecimalPipe } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { ActivatedRoute } from '@angular/router';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
|
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||||
|
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||||
|
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||||
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
import { I4cService, i4cErrorMessage } from '../i4c.service';
|
||||||
|
import { ALL_DATES, I4cDateRange, I4cDateRangeComponent } from '../i4c-date-range/i4c-date-range.component';
|
||||||
|
import { I4cDocumentsComponent } from '../i4c-documents/i4c-documents.component';
|
||||||
|
|
||||||
|
export const GRM_STAGES: { value: string; label: string }[] = [
|
||||||
|
{ value: 'DRAFT', label: 'Draft (EDD)' },
|
||||||
|
{ value: 'NODAL_REVIEW', label: 'Bank nodal review' },
|
||||||
|
{ value: 'WITH_IO', label: 'With IO (7 days)' },
|
||||||
|
{ value: 'IO_INVESTIGATION', label: 'IO investigating (15 days)' },
|
||||||
|
{ value: 'WITH_DGRO', label: 'District GRO review (15 days)' },
|
||||||
|
{ value: 'WITH_SGRO', label: 'State GRO review (15 days)' },
|
||||||
|
{ value: 'CLOSED', label: 'Closed' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const GRM_DECISIONS: { value: string; label: string }[] = [
|
||||||
|
{ value: 'REMOVE_HOLD', label: 'Remove put on hold' },
|
||||||
|
{ value: 'UNFREEZE_HOLD_CONTINUES', label: 'Unfreeze the account, hold continues' },
|
||||||
|
{ value: 'UNFREEZE_AND_REMOVE_HOLD', label: 'Unfreeze and remove put on hold' },
|
||||||
|
{ value: 'PARTIAL_HOLD', label: 'Keep a smaller amount on hold' },
|
||||||
|
{ value: 'REJECTED_CONTINUE', label: 'Request rejected, hold / freeze continues' },
|
||||||
|
{ value: 'SEEK_INFO', label: 'Seek additional information' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const REQUESTS: Record<string, string> = {
|
||||||
|
REMOVE_HOLD: 'Remove the hold',
|
||||||
|
UNFREEZE: 'Unfreeze the account',
|
||||||
|
RESTORE_DIGITAL_SERVICES: 'Restore digital services'
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GRM grievances raised by the bank for account holders who contest an I4C hold: EDD, raising on GRM, the IO / DGRO /
|
||||||
|
* SGRO decision with its deadlines, escalation, and carrying an allowing order out on the account.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-i4c-grievances',
|
||||||
|
templateUrl: './i4c-grievances.component.html',
|
||||||
|
styleUrls: ['./i4c-grievances.component.scss'],
|
||||||
|
imports: [
|
||||||
|
DatePipe,
|
||||||
|
DecimalPipe,
|
||||||
|
FormsModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatCheckboxModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatSelectModule,
|
||||||
|
MatProgressBarModule,
|
||||||
|
MatPaginatorModule,
|
||||||
|
FaIconComponent,
|
||||||
|
I4cDateRangeComponent,
|
||||||
|
I4cDocumentsComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class I4cGrievancesComponent implements OnInit {
|
||||||
|
private i4c = inject(I4cService);
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
|
private snackBar = inject(MatSnackBar);
|
||||||
|
|
||||||
|
readonly stages = GRM_STAGES;
|
||||||
|
readonly decisions = GRM_DECISIONS;
|
||||||
|
readonly noteEvents = [
|
||||||
|
{ value: 'VC_REQUESTED', label: 'IO requested a VC' },
|
||||||
|
{ value: 'VC_DONE', label: 'VC held' },
|
||||||
|
{ value: 'VC_NOT_REQUIRED', label: 'VC not required' },
|
||||||
|
{ value: 'INFO_SOUGHT', label: 'Additional information sought' },
|
||||||
|
{ value: 'INFO_SENT', label: 'Information sent' },
|
||||||
|
{ value: 'NOTE', label: 'Note' }
|
||||||
|
];
|
||||||
|
readonly pageSizes = [
|
||||||
|
10,
|
||||||
|
20,
|
||||||
|
50,
|
||||||
|
100
|
||||||
|
];
|
||||||
|
|
||||||
|
stage = 'OPEN';
|
||||||
|
status = '';
|
||||||
|
overdueOnly = false;
|
||||||
|
search = '';
|
||||||
|
dates: I4cDateRange = { ...ALL_DATES };
|
||||||
|
rows: any[] = [];
|
||||||
|
total = 0;
|
||||||
|
pageIndex = 0;
|
||||||
|
pageSize = 20;
|
||||||
|
loading = false;
|
||||||
|
error = '';
|
||||||
|
|
||||||
|
selected: any = null;
|
||||||
|
busy = false;
|
||||||
|
tab: 'overview' | 'workflow' | 'documents' | 'history' = 'workflow';
|
||||||
|
edd = { kycVerified: true, sourceOfFunds: '', customerStatement: '', risk: 'LOW', recommendation: 'SUPPORT' };
|
||||||
|
grievanceNo = '';
|
||||||
|
remarks = '';
|
||||||
|
noteType = 'VC_REQUESTED';
|
||||||
|
decision = { decision: 'REMOVE_HOLD', keepAmount: null as number | null, orderRef: '', orderDate: '', remarks: '' };
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
const open = Number(this.route.snapshot.queryParamMap.get('open'));
|
||||||
|
this.load();
|
||||||
|
if (open) {
|
||||||
|
this.open(open);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
load(fromFirstPage = false): void {
|
||||||
|
if (fromFirstPage) {
|
||||||
|
this.pageIndex = 0;
|
||||||
|
}
|
||||||
|
this.loading = true;
|
||||||
|
this.error = '';
|
||||||
|
this.i4c
|
||||||
|
.grievances({
|
||||||
|
stage: this.stage,
|
||||||
|
status: this.status,
|
||||||
|
overdue: this.overdueOnly || null,
|
||||||
|
search: this.search.trim(),
|
||||||
|
fromDate: this.dates.fromDate,
|
||||||
|
toDate: this.dates.toDate,
|
||||||
|
offset: this.pageIndex * this.pageSize,
|
||||||
|
limit: this.pageSize
|
||||||
|
})
|
||||||
|
.subscribe({
|
||||||
|
next: (page) => {
|
||||||
|
this.rows = page.pageItems;
|
||||||
|
this.total = page.totalFilteredRecords;
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.error = i4cErrorMessage(e);
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setStage(s: string): void {
|
||||||
|
this.stage = s;
|
||||||
|
this.load(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
onPage(e: PageEvent): void {
|
||||||
|
this.pageIndex = e.pageIndex;
|
||||||
|
this.pageSize = e.pageSize;
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
onDates(range: I4cDateRange): void {
|
||||||
|
this.dates = range;
|
||||||
|
this.load(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasFilters(): boolean {
|
||||||
|
return !!(this.status || this.overdueOnly || this.search || this.dates.preset !== 'ALL' || this.stage !== 'OPEN');
|
||||||
|
}
|
||||||
|
|
||||||
|
clearFilters(): void {
|
||||||
|
this.stage = 'OPEN';
|
||||||
|
this.status = '';
|
||||||
|
this.overdueOnly = false;
|
||||||
|
this.search = '';
|
||||||
|
this.dates = { ...ALL_DATES };
|
||||||
|
this.load(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
open(id: number): void {
|
||||||
|
this.i4c.grievance(id).subscribe({
|
||||||
|
next: (g) => {
|
||||||
|
const same = this.selected?.id === g.id;
|
||||||
|
this.selected = g;
|
||||||
|
if (!same) {
|
||||||
|
this.tab = 'workflow';
|
||||||
|
this.remarks = '';
|
||||||
|
this.grievanceNo = g.grievance_no || '';
|
||||||
|
this.decision = { decision: 'REMOVE_HOLD', keepAmount: null, orderRef: '', orderDate: '', remarks: '' };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (e) => this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.selected = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
stageLabel(s: string): string {
|
||||||
|
return GRM_STAGES.find((x) => x.value === s)?.label || s;
|
||||||
|
}
|
||||||
|
|
||||||
|
decisionLabel(d: string): string {
|
||||||
|
return GRM_DECISIONS.find((x) => x.value === d)?.label || d;
|
||||||
|
}
|
||||||
|
|
||||||
|
requestLabel(r: string): string {
|
||||||
|
return REQUESTS[r] || r;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The stages on the way, for the little progress line; escalation stages show only once reached. */
|
||||||
|
flow(g: any): { value: string; label: string; state: 'done' | 'now' | 'todo' }[] {
|
||||||
|
const path = [
|
||||||
|
'DRAFT',
|
||||||
|
'NODAL_REVIEW',
|
||||||
|
'WITH_IO',
|
||||||
|
'IO_INVESTIGATION',
|
||||||
|
'WITH_DGRO',
|
||||||
|
'WITH_SGRO',
|
||||||
|
'CLOSED'
|
||||||
|
];
|
||||||
|
const at = path.indexOf(g.stage);
|
||||||
|
const visible = path.filter((s, i) => i <= Math.max(at, 3) || s === 'CLOSED' || i <= at);
|
||||||
|
return visible.map((s) => ({
|
||||||
|
value: s,
|
||||||
|
label: (GRM_STAGES.find((x) => x.value === s)?.label || s).replace(/ \(.*\)/, ''),
|
||||||
|
state: path.indexOf(s) < at ? 'done' : s === g.stage ? 'now' : 'todo'
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
get level(): string {
|
||||||
|
const s = this.selected?.stage;
|
||||||
|
return s === 'WITH_DGRO' ? 'DGRO' : s === 'WITH_SGRO' ? 'SGRO' : 'IO';
|
||||||
|
}
|
||||||
|
|
||||||
|
get withOfficer(): boolean {
|
||||||
|
return [
|
||||||
|
'WITH_IO',
|
||||||
|
'IO_INVESTIGATION',
|
||||||
|
'WITH_DGRO',
|
||||||
|
'WITH_SGRO'
|
||||||
|
].includes(this.selected?.stage);
|
||||||
|
}
|
||||||
|
|
||||||
|
isLate(ts: string | null): boolean {
|
||||||
|
return !!ts && new Date(ts).getTime() < Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
step(step: string, body: Record<string, any>, done: string): void {
|
||||||
|
if (!this.selected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.busy = true;
|
||||||
|
this.i4c.grievanceStep(this.selected.id, step, body).subscribe({
|
||||||
|
next: (g) => {
|
||||||
|
this.busy = false;
|
||||||
|
this.selected = g;
|
||||||
|
this.remarks = '';
|
||||||
|
this.snackBar.open(done, 'OK', { duration: 4000 });
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.busy = false;
|
||||||
|
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 7000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
saveEdd(): void {
|
||||||
|
this.step('edd', this.edd, 'Due diligence recorded.');
|
||||||
|
}
|
||||||
|
|
||||||
|
submit(): void {
|
||||||
|
this.step('submit', { grievanceNo: this.grievanceNo.trim() }, 'Grievance marked as raised on GRM.');
|
||||||
|
}
|
||||||
|
|
||||||
|
nodal(approve: boolean): void {
|
||||||
|
this.step(
|
||||||
|
'nodal',
|
||||||
|
{ approve, remarks: this.remarks },
|
||||||
|
approve ? 'Approved; now with the IO (7 days).' : 'Grievance rejected by the nodal officer.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
note(): void {
|
||||||
|
this.step('events', { eventType: this.noteType, remarks: this.remarks }, 'Noted.');
|
||||||
|
}
|
||||||
|
|
||||||
|
investigation(): void {
|
||||||
|
this.step('investigation', { remarks: this.remarks }, 'IO investigation recorded (15 days).');
|
||||||
|
}
|
||||||
|
|
||||||
|
decide(): void {
|
||||||
|
this.step(
|
||||||
|
'decision',
|
||||||
|
{
|
||||||
|
level: this.level,
|
||||||
|
decision: this.decision.decision,
|
||||||
|
keepAmount: this.decision.decision === 'PARTIAL_HOLD' ? this.decision.keepAmount : null,
|
||||||
|
orderRef: this.decision.orderRef,
|
||||||
|
orderDate: this.decision.orderDate || null,
|
||||||
|
remarks: this.decision.remarks
|
||||||
|
},
|
||||||
|
'Decision recorded.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
escalate(): void {
|
||||||
|
this.step('escalate', { remarks: this.remarks }, 'Escalated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
withdraw(): void {
|
||||||
|
this.step('withdraw', { remarks: this.remarks }, 'Grievance withdrawn.');
|
||||||
|
}
|
||||||
|
|
||||||
|
raiseAction(): void {
|
||||||
|
if (!this.selected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.busy = true;
|
||||||
|
this.i4c.raiseGrievanceAction(this.selected.id).subscribe({
|
||||||
|
next: (r) => {
|
||||||
|
this.busy = false;
|
||||||
|
this.snackBar.open(
|
||||||
|
r.actionRequestId
|
||||||
|
? 'Request ' + r.actionRequestId + ' raised. Another officer approves it on the Approvals page.'
|
||||||
|
: 'Nothing to change on the account; grievance closed.',
|
||||||
|
'OK',
|
||||||
|
{ duration: 6000 }
|
||||||
|
);
|
||||||
|
this.open(this.selected.id);
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.busy = false;
|
||||||
|
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 7000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
missingDocs(g: any): string[] {
|
||||||
|
return (g.requiredDocuments || []).filter((t: string) => !(g.documents || []).some((d: any) => d.doc_type === t));
|
||||||
|
}
|
||||||
|
|
||||||
|
get escalateLabel(): string {
|
||||||
|
const s = this.selected?.stage;
|
||||||
|
return s === 'WITH_SGRO'
|
||||||
|
? 'Close: customer may approach the court'
|
||||||
|
: s === 'WITH_DGRO'
|
||||||
|
? 'Escalate to State GRO'
|
||||||
|
: 'Escalate to District GRO';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,794 @@
|
|||||||
|
<!--
|
||||||
|
Copyright since 2025 Mifos Initiative
|
||||||
|
|
||||||
|
This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<div class="i4c-page">
|
||||||
|
<!-- Modern Toolbar & Filter Chips -->
|
||||||
|
<div class="i4c-toolbar">
|
||||||
|
<div class="i4c-filter-chips">
|
||||||
|
@for (v of views; track v.value) {
|
||||||
|
<button class="filter-chip" [class.filter-chip--active]="view === v.value" (click)="setView(v.value)">
|
||||||
|
<span>{{ v.label }}</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="i4c-toolbar__spacer"></span>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="search-input">
|
||||||
|
<mat-label>Account / acknowledgement / customer</mat-label>
|
||||||
|
<input matInput [(ngModel)]="search" (keyup.enter)="load(true)" placeholder="Type & hit enter..." />
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<button mat-flat-button color="primary" class="search-btn" (click)="load(true)">
|
||||||
|
<fa-icon icon="search"></fa-icon> Search
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span class="count-tag">{{ total }} holds</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-filter-bar">
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Account type</mat-label>
|
||||||
|
<mat-select [(ngModel)]="accountType" (selectionChange)="load(true)">
|
||||||
|
<mat-option value="">All accounts</mat-option>
|
||||||
|
<mat-option value="SAVINGS">Savings</mat-option>
|
||||||
|
<mat-option value="CURRENT">Current</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Customer intimation</mat-label>
|
||||||
|
<mat-select [(ngModel)]="intimationFilter" (selectionChange)="load(true)">
|
||||||
|
<mat-option value="">Any</mat-option>
|
||||||
|
<mat-option value="DONE">Customer told</mat-option>
|
||||||
|
<mat-option value="PENDING">Not yet told</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Layer</mat-label>
|
||||||
|
<mat-select [(ngModel)]="layer" (selectionChange)="load(true)">
|
||||||
|
<mat-option value="">All layers</mat-option>
|
||||||
|
<mat-option value="0">Layer 0</mat-option>
|
||||||
|
<mat-option value="1">Layer 1+</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-checkbox [(ngModel)]="frozenOnly" (change)="load(true)">Debits frozen only</mat-checkbox>
|
||||||
|
<mifosx-i4c-date-range label="Held on" [range]="dates" (rangeChange)="onDates($event)"></mifosx-i4c-date-range>
|
||||||
|
<button mat-stroked-button class="i4c-filter-bar__clear" (click)="clearFilters()" [disabled]="!hasFilters">
|
||||||
|
<fa-icon icon="times"></fa-icon> Clear filters
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (loading || busy) {
|
||||||
|
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
|
||||||
|
}
|
||||||
|
@if (error) {
|
||||||
|
<div class="i4c-error">
|
||||||
|
<fa-icon icon="exclamation-circle"></fa-icon>
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Holds Register Table -->
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head">
|
||||||
|
<fa-icon icon="lock"></fa-icon>
|
||||||
|
<span>I4C Account Holds Register</span>
|
||||||
|
<span class="head-hint">Only disputed amounts are restricted; remaining balance stays usable by customer.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-table-wrap">
|
||||||
|
<table class="i4c-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Customer Account</th>
|
||||||
|
<th>Account Holder / Branch</th>
|
||||||
|
<th>Ack No.</th>
|
||||||
|
<th class="num">Layer</th>
|
||||||
|
<th class="num">Disputed (₹)</th>
|
||||||
|
<th class="num">Held (₹)</th>
|
||||||
|
<th class="num">Short (₹)</th>
|
||||||
|
<th>Hold Status</th>
|
||||||
|
<th>Customer Intimation</th>
|
||||||
|
<th>90-Day Review</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (h of rows; track h.id) {
|
||||||
|
<tr class="clickable" (click)="select(h.id)">
|
||||||
|
<td>
|
||||||
|
<div class="acc-cell">
|
||||||
|
<b>{{ h.account_no }}</b>
|
||||||
|
<span class="type-tag">{{ h.account_type }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="client-name">{{ h.client_name || 'N/A' }}</div>
|
||||||
|
<div class="sub">{{ h.office_name }}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="ack-cell">
|
||||||
|
<span class="ack-no">{{ h.acknowledgement_no }}</span>
|
||||||
|
<button
|
||||||
|
class="copy-chip"
|
||||||
|
(click)="copyText(h.acknowledgement_no, 'ack-' + h.id, $event)"
|
||||||
|
title="Copy Ack No."
|
||||||
|
>
|
||||||
|
<fa-icon icon="copy"></fa-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="num font-semibold">{{ h.layer }}</td>
|
||||||
|
<td class="num">₹{{ h.disputed_amount | number: '1.2-2' }}</td>
|
||||||
|
<td class="num font-bold text-accent">₹{{ h.amount_held | number: '1.2-2' }}</td>
|
||||||
|
<td class="num">
|
||||||
|
@if (h.shortfall > 0) {
|
||||||
|
<span class="short-pill">₹{{ h.shortfall | number: '1.2-2' }}</span>
|
||||||
|
} @else {
|
||||||
|
<span class="text-muted">-</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="i4c-badge" [class]="statusClass(h)">{{ h.status }}</span>
|
||||||
|
@if (h.debit_frozen) {
|
||||||
|
<span class="i4c-badge i4c-badge--red">DEBIT FROZEN</span>
|
||||||
|
}
|
||||||
|
@if (h.pending_actions) {
|
||||||
|
<span class="i4c-badge i4c-badge--purple">APPROVAL PENDING</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if (h.customer_intimated_at) {
|
||||||
|
<span class="intimation-ok">
|
||||||
|
<fa-icon icon="check-circle"></fa-icon>
|
||||||
|
<span>{{ h.customer_intimated_at | date: 'dd MMM' }} ({{ h.intimation_mode }})</span>
|
||||||
|
</span>
|
||||||
|
} @else if (isOverdue(h)) {
|
||||||
|
<span class="i4c-badge i4c-badge--red">OVERDUE</span>
|
||||||
|
} @else if (h.amount_held > 0 && h.status === 'ACTIVE') {
|
||||||
|
<span class="i4c-badge i4c-badge--amber">INTIMATION DUE</span>
|
||||||
|
} @else {
|
||||||
|
<span class="text-muted">-</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span [class.due-alert]="isReviewDue(h)" class="review-date">
|
||||||
|
@if (isReviewDue(h)) {
|
||||||
|
<fa-icon icon="exclamation-circle"></fa-icon>
|
||||||
|
}
|
||||||
|
{{ h.review_due_on }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="10" class="i4c-empty">
|
||||||
|
<div class="empty-icon"><fa-icon icon="lock"></fa-icon></div>
|
||||||
|
<div>No holds found in this view.</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<mat-paginator
|
||||||
|
class="i4c-paginator"
|
||||||
|
[length]="total"
|
||||||
|
[pageIndex]="pageIndex"
|
||||||
|
[pageSize]="pageSize"
|
||||||
|
[pageSizeOptions]="pageSizes"
|
||||||
|
(page)="onPage($event)"
|
||||||
|
showFirstLastButtons
|
||||||
|
aria-label="Holds pages"
|
||||||
|
></mat-paginator>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ========================================================
|
||||||
|
MODERN POPUP WINDOW: HOLD DETAILS & MAKER ACTIONS MODAL
|
||||||
|
======================================================== -->
|
||||||
|
@if (selected; as h) {
|
||||||
|
<div class="i4c-modal-backdrop" (click)="close()">
|
||||||
|
<div class="i4c-modal-window i4c-modal-window--wide" (click)="$event.stopPropagation()">
|
||||||
|
<!-- Modal Header -->
|
||||||
|
<div class="i4c-modal-header">
|
||||||
|
<div class="i4c-modal-header__title">
|
||||||
|
<fa-icon icon="lock"></fa-icon>
|
||||||
|
<div>
|
||||||
|
<div class="modal-title-row">
|
||||||
|
<span>Hold #{{ h.id }} on Account {{ h.account_no }}</span>
|
||||||
|
<span class="i4c-badge" [class]="statusClass(h)">{{ h.status }}</span>
|
||||||
|
@if (h.debit_frozen) {
|
||||||
|
<span class="i4c-badge i4c-badge--red">DEBIT FROZEN</span>
|
||||||
|
}
|
||||||
|
@if (hasPendingAction) {
|
||||||
|
<span class="i4c-badge i4c-badge--purple">APPROVAL PENDING</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="i4c-modal-header__sub">
|
||||||
|
Customer: <b>{{ h.client_name || 'N/A' }}</b> ({{ h.office_name }}) • Ack: {{ h.acknowledgement_no }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="i4c-modal-header__actions">
|
||||||
|
<button class="modal-close-btn" (click)="close()" aria-label="Close">
|
||||||
|
<fa-icon icon="times"></fa-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Navigation Tabs inside the Modal -->
|
||||||
|
<div class="i4c-modal-tabs">
|
||||||
|
<button class="modal-tab" [class.modal-tab--active]="modalTab === 'overview'" (click)="modalTab = 'overview'">
|
||||||
|
<fa-icon icon="info-circle"></fa-icon> Overview & Compliance
|
||||||
|
</button>
|
||||||
|
<button class="modal-tab" [class.modal-tab--active]="modalTab === 'txns'" (click)="modalTab = 'txns'">
|
||||||
|
<fa-icon icon="table"></fa-icon> Hold Transactions ({{ h.transactions?.length || 0 }})
|
||||||
|
</button>
|
||||||
|
@if ((h.status === 'ACTIVE' && h.amount_held > 0) || h.intimationLog?.length) {
|
||||||
|
<button
|
||||||
|
class="modal-tab"
|
||||||
|
[class.modal-tab--active]="modalTab === 'intimation'"
|
||||||
|
(click)="modalTab = 'intimation'"
|
||||||
|
>
|
||||||
|
<fa-icon icon="bell"></fa-icon> Customer Intimation
|
||||||
|
@if (!h.customer_intimated_at && h.status === 'ACTIVE' && h.amount_held > 0) {
|
||||||
|
<span class="i4c-badge i4c-badge--amber">Pending</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@if (allowedActions.length > 0) {
|
||||||
|
<button class="modal-tab" [class.modal-tab--active]="modalTab === 'action'" (click)="modalTab = 'action'">
|
||||||
|
<fa-icon icon="hand-paper"></fa-icon> Request Action (Maker)
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
<button class="modal-tab" [class.modal-tab--active]="modalTab === 'cases'" (click)="modalTab = 'cases'">
|
||||||
|
<fa-icon icon="balance-scale"></fa-icon> GRM / MRM & Documents
|
||||||
|
@if (openGrievance || openRestoration) {
|
||||||
|
<span class="i4c-badge i4c-badge--amber">Open</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
@if (h.actions?.length) {
|
||||||
|
<button class="modal-tab" [class.modal-tab--active]="modalTab === 'history'" (click)="modalTab = 'history'">
|
||||||
|
<fa-icon icon="history"></fa-icon> Request Audit ({{ h.actions.length }})
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal Body -->
|
||||||
|
<div class="i4c-modal-body">
|
||||||
|
<!-- TAB 1: OVERVIEW -->
|
||||||
|
@if (modalTab === 'overview') {
|
||||||
|
<div class="i4c-grid-2">
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head"><fa-icon icon="shield-alt"></fa-icon> Hold Balances & Account</div>
|
||||||
|
<div class="i4c-card__body">
|
||||||
|
<dl class="i4c-kv">
|
||||||
|
<dt>Customer Name</dt>
|
||||||
|
<dd>{{ h.client_name || '-' }} ({{ h.office_name }})</dd>
|
||||||
|
|
||||||
|
<dt>Account Number</dt>
|
||||||
|
<dd>
|
||||||
|
<b>{{ h.account_no }}</b> ({{ h.account_type }})
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>I4C Complaint Ack</dt>
|
||||||
|
<dd>
|
||||||
|
<a [routerLink]="['../requests', h.request_id]" (click)="close()">{{ h.acknowledgement_no }}</a>
|
||||||
|
(Layer {{ h.layer }})
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>Disputed Amount</dt>
|
||||||
|
<dd>₹{{ h.disputed_amount | number: '1.2-2' }}</dd>
|
||||||
|
|
||||||
|
<dt>Amount Held</dt>
|
||||||
|
<dd>
|
||||||
|
<b class="amount-held-text">₹{{ h.amount_held | number: '1.2-2' }}</b>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>Shortfall</dt>
|
||||||
|
<dd>
|
||||||
|
@if (h.shortfall > 0) {
|
||||||
|
<span class="err-text">₹{{ h.shortfall | number: '1.2-2' }} (Prospective hold)</span>
|
||||||
|
} @else {
|
||||||
|
<span>Fully covered (₹0.00)</span>
|
||||||
|
}
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head"><fa-icon icon="calendar-check"></fa-icon> Compliance & Directions</div>
|
||||||
|
<div class="i4c-card__body">
|
||||||
|
<dl class="i4c-kv">
|
||||||
|
<dt>Hold Placed On</dt>
|
||||||
|
<dd>{{ h.held_at | date: 'dd MMM yyyy, HH:mm:ss' }} (System automated)</dd>
|
||||||
|
|
||||||
|
<dt>90-Day Review Due</dt>
|
||||||
|
<dd>
|
||||||
|
<span [class.due-alert]="isReviewDue(h)">{{ h.review_due_on }}</span>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>Debit Freeze Status</dt>
|
||||||
|
<dd>
|
||||||
|
@if (h.debit_frozen) {
|
||||||
|
<span class="i4c-badge i4c-badge--red">FROZEN</span>
|
||||||
|
} @else {
|
||||||
|
<span>Active (Debit Not Frozen)</span>
|
||||||
|
}
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>Customer Intimation</dt>
|
||||||
|
<dd>
|
||||||
|
@if (h.customer_intimated_at) {
|
||||||
|
<span class="intimation-ok">
|
||||||
|
{{ h.customer_intimated_at | date: 'dd MMM yyyy, HH:mm' }} by {{ h.intimated_by }} ({{
|
||||||
|
h.intimation_mode
|
||||||
|
}}{{ h.intimation_reference ? ', ' + h.intimation_reference : '' }})
|
||||||
|
</span>
|
||||||
|
} @else {
|
||||||
|
<span class="err-text">
|
||||||
|
Not told yet{{
|
||||||
|
h.intimation_due_by ? ' - due by ' + (h.intimation_due_by | date: 'dd MMM yyyy, HH:mm') : ''
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
@if (h.status === 'RELEASED') {
|
||||||
|
<dt>Released On</dt>
|
||||||
|
<dd>
|
||||||
|
{{ h.released_at | date: 'dd MMM yyyy, HH:mm' }} -
|
||||||
|
{{ reasonLabel(h.release_reason_type || '') }}
|
||||||
|
{{ h.release_reference }}
|
||||||
|
</dd>
|
||||||
|
}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- TAB 2: TRANSACTIONS -->
|
||||||
|
@if (modalTab === 'txns') {
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head"><fa-icon icon="table"></fa-icon> Account Hold Transactions</div>
|
||||||
|
<div class="i4c-card__body i4c-table-wrap">
|
||||||
|
<table class="i4c-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Account Txn ID</th>
|
||||||
|
<th class="num">Held Amount (₹)</th>
|
||||||
|
<th>Placed Timestamp</th>
|
||||||
|
<th>Released Timestamp</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (t of h.transactions; track t.id) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<b>{{ t.account_txn_id }}</b>
|
||||||
|
</td>
|
||||||
|
<td class="num">₹{{ t.amount | number: '1.2-2' }}</td>
|
||||||
|
<td>{{ t.placed_at | date: 'dd MMM yyyy, HH:mm' }}</td>
|
||||||
|
<td>{{ t.released_at ? (t.released_at | date: 'dd MMM yyyy, HH:mm') : '-' }}</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="i4c-empty">
|
||||||
|
No money held yet (prospective hold waiting for funds deposit).
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- TAB 3: CUSTOMER INTIMATION (automatic channels, manual record, history) -->
|
||||||
|
@if (modalTab === 'intimation') {
|
||||||
|
@if (h.customer_intimated_at) {
|
||||||
|
<div class="intim-status intim-status--done">
|
||||||
|
<fa-icon icon="check"></fa-icon>
|
||||||
|
<div>
|
||||||
|
<b>Customer told</b> on {{ h.customer_intimated_at | date: 'dd MMM yyyy, HH:mm' }} by
|
||||||
|
{{ channelLabel(h.intimation_mode || '')
|
||||||
|
}}{{ h.intimation_reference ? ' - ' + h.intimation_reference : '' }} ({{ h.intimated_by }})
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @else if (h.automaticChannels?.length) {
|
||||||
|
<div class="intim-status intim-status--auto">
|
||||||
|
<fa-icon icon="paper-plane"></fa-icon>
|
||||||
|
<div>
|
||||||
|
<b>Automatic intimation is on</b> ({{ autoChannelsText(h) }}).
|
||||||
|
@if (h.intimationLog?.length) {
|
||||||
|
The last automatic attempt did not reach the customer; the system retries up to 3 times. You can
|
||||||
|
send again now, or call the customer and record it below.
|
||||||
|
} @else {
|
||||||
|
The notice is sent as soon as the hold is placed. If it has not gone yet, send it now.
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<button mat-flat-button color="primary" (click)="sendIntimationNow()" [disabled]="busy">
|
||||||
|
<fa-icon icon="paper-plane"></fa-icon> Send now
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="intim-status intim-status--manual">
|
||||||
|
<fa-icon icon="phone"></fa-icon>
|
||||||
|
<div>
|
||||||
|
<b>No automatic channel is configured.</b> Call the customer (or send a letter / meet in person), then
|
||||||
|
record it below. Channels are switched on under Intimation Settings.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!h.customer_intimated_at && h.status === 'ACTIVE' && h.amount_held > 0) {
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head"><fa-icon icon="bell"></fa-icon> Record Customer Intimation (manual)</div>
|
||||||
|
<div class="i4c-card__body">
|
||||||
|
<div class="i4c-note">
|
||||||
|
RBI draft directions mandate that the account holder be notified of an account hold by the end of
|
||||||
|
the next day, including reason, restoration options, and Grievance Officer contacts.
|
||||||
|
</div>
|
||||||
|
<div class="i4c-form">
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>How the customer was told</mat-label>
|
||||||
|
<mat-select [(ngModel)]="intimation.mode">
|
||||||
|
@for (m of intimationModes; track m) {
|
||||||
|
<mat-option [value]="m">{{ channelLabel(m) }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Reference (call note, letter no.)</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
[(ngModel)]="intimation.reference"
|
||||||
|
maxlength="200"
|
||||||
|
placeholder="e.g. Called 98xxxx1234, customer informed"
|
||||||
|
/>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
<button mat-flat-button color="primary" (click)="submitIntimation()" [disabled]="busy">
|
||||||
|
<fa-icon icon="bell"></fa-icon> Record Customer Intimation
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head">
|
||||||
|
<fa-icon icon="history"></fa-icon> Intimation History ({{ h.intimationLog?.length || 0 }})
|
||||||
|
</div>
|
||||||
|
<div class="i4c-card__body">
|
||||||
|
<table class="i4c-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>When</th>
|
||||||
|
<th>Channel</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Sent to</th>
|
||||||
|
<th>Reference / Reason</th>
|
||||||
|
<th>By</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (l of h.intimationLog || []; track l.id) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ l.created_at | date: 'dd MMM yyyy, HH:mm' }}</td>
|
||||||
|
<td>{{ channelLabel(l.channel) }}</td>
|
||||||
|
<td>
|
||||||
|
{{ l.mode === 'AUTO' ? 'Automatic' : l.mode === 'RESEND' ? 'Sent by officer' : 'Recorded' }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
class="i4c-badge"
|
||||||
|
[class.i4c-badge--green]="
|
||||||
|
l.status === 'SENT' || l.status === 'DELIVERED' || l.status === 'RECORDED'
|
||||||
|
"
|
||||||
|
[class.i4c-badge--red]="l.status === 'FAILED'"
|
||||||
|
[class.i4c-badge--amber]="l.status === 'SKIPPED_NO_DESTINATION'"
|
||||||
|
>{{ l.status }}</span
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
<td>{{ l.destination_masked || '-' }}</td>
|
||||||
|
<td class="wrap">{{ l.failure_reason || l.provider_reference || '-' }}</td>
|
||||||
|
<td>{{ l.actor || '-' }}</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="i4c-empty">No notice sent or recorded yet.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- TAB 4: MAKER CHANGE REQUEST FORM -->
|
||||||
|
@if (modalTab === 'action') {
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head"><fa-icon icon="hand-paper"></fa-icon> Raise Request on Hold (Maker)</div>
|
||||||
|
<div class="i4c-card__body">
|
||||||
|
@if (hasPendingAction) {
|
||||||
|
<div class="i4c-note">
|
||||||
|
A request on this hold is already waiting for checker approval on the Approvals page.
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="i4c-form">
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Requested Action</mat-label>
|
||||||
|
<mat-select [(ngModel)]="action.action">
|
||||||
|
@for (a of allowedActions; track a.value) {
|
||||||
|
<mat-option [value]="a.value">{{ a.label }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
@if (action.action === 'RELEASE') {
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Amount to release (₹, empty = all)</mat-label>
|
||||||
|
<input matInput type="number" min="0" [max]="h.amount_held" [(ngModel)]="action.amount" />
|
||||||
|
@if (remainingAfterRelease !== null) {
|
||||||
|
<mat-hint
|
||||||
|
>₹{{ remainingAfterRelease | number: '1.2-2' }} stays on hold (order names the amount to
|
||||||
|
keep)</mat-hint
|
||||||
|
>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
}
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Reason</mat-label>
|
||||||
|
<mat-select [(ngModel)]="action.reasonType">
|
||||||
|
@for (r of reasonTypes; track r.value) {
|
||||||
|
<mat-option [value]="r.value">{{ r.label }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Order / Reference No.{{ orderRequired ? ' *' : '' }}</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
[(ngModel)]="action.orderReference"
|
||||||
|
maxlength="200"
|
||||||
|
placeholder="IO / Court reference..."
|
||||||
|
/>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Order Date</mat-label>
|
||||||
|
<input matInput type="date" [(ngModel)]="action.orderDate" />
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Issuing Authority (IO, Court, DGRO...)</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
[(ngModel)]="action.authority"
|
||||||
|
maxlength="200"
|
||||||
|
placeholder="e.g. Cyber Cell IO..."
|
||||||
|
/>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Maker Remarks</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
[(ngModel)]="action.remarks"
|
||||||
|
maxlength="1000"
|
||||||
|
placeholder="Notes for checker..."
|
||||||
|
/>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
<button mat-flat-button color="warn" (click)="submitAction()" [disabled]="busy">
|
||||||
|
<fa-icon icon="hand-paper"></fa-icon> Submit for Checker Approval
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- TAB: GRM grievance, MRM restoration, statement and documents of this hold -->
|
||||||
|
@if (modalTab === 'cases') {
|
||||||
|
<div class="cases-grid">
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head">
|
||||||
|
<fa-icon icon="balance-scale"></fa-icon> GRM grievance (customer contests the hold)
|
||||||
|
</div>
|
||||||
|
<div class="i4c-card__body">
|
||||||
|
@for (g of h.grievances || []; track g.id) {
|
||||||
|
<div
|
||||||
|
class="case-row"
|
||||||
|
(click)="goTo('grievances', g.id)"
|
||||||
|
(keyup.enter)="goTo('grievances', g.id)"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<b>{{ g.grievance_no || 'Draft #' + g.id }}</b>
|
||||||
|
<span class="i4c-badge">{{ g.stage }}</span>
|
||||||
|
<span
|
||||||
|
class="i4c-badge"
|
||||||
|
[class.i4c-badge--green]="g.status === 'ALLOWED'"
|
||||||
|
[class.i4c-badge--red]="g.status === 'REJECTED'"
|
||||||
|
>{{ g.status }}</span
|
||||||
|
>
|
||||||
|
<span class="sub">{{ g.created_at | date: 'dd MMM yyyy' }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (h.status === 'ACTIVE' && !openGrievance) {
|
||||||
|
<div class="i4c-form">
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Customer asks to</mat-label>
|
||||||
|
<mat-select [(ngModel)]="grievanceForm.customerRequest">
|
||||||
|
<mat-option value="REMOVE_HOLD">Remove the hold</mat-option>
|
||||||
|
<mat-option value="UNFREEZE">Unfreeze the account</mat-option>
|
||||||
|
<mat-option value="RESTORE_DIGITAL_SERVICES">Restore digital services</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Customer's reason</mat-label>
|
||||||
|
<input matInput [(ngModel)]="grievanceForm.customerReason" maxlength="2000" />
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
<button mat-flat-button color="primary" (click)="raiseGrievance()" [disabled]="busy">
|
||||||
|
<fa-icon icon="plus"></fa-icon> Open grievance (EDD next)
|
||||||
|
</button>
|
||||||
|
} @else if (!h.grievances?.length) {
|
||||||
|
<div class="sub">No grievance on this hold.</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head"><fa-icon icon="hand-holding-usd"></fa-icon> MRM money restoration</div>
|
||||||
|
<div class="i4c-card__body">
|
||||||
|
@for (r of h.restorations || []; track r.id) {
|
||||||
|
<div
|
||||||
|
class="case-row"
|
||||||
|
(click)="goTo('restorations', r.id)"
|
||||||
|
(keyup.enter)="goTo('restorations', r.id)"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<b>{{ r.mrm_request_no || 'Restoration #' + r.id }}</b>
|
||||||
|
<span class="i4c-badge">{{ r.status }}</span>
|
||||||
|
<span class="sub"
|
||||||
|
>{{ r.basis }}
|
||||||
|
{{ r.restorable_amount ? '₹' + (r.restorable_amount | number: '1.2-2') : '' }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (h.status === 'ACTIVE' && !openRestoration) {
|
||||||
|
<div class="i4c-form">
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Restoration</mat-label>
|
||||||
|
<mat-select [(ngModel)]="assessForm.basis">
|
||||||
|
@for (b of mrmBases; track b.value) {
|
||||||
|
<mat-option [value]="b.value">{{ b.label }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
@if (assessForm.basis === 'ATTRIBUTION' || assessForm.basis === 'PRO_RATA') {
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Restorable amount (₹, at most {{ h.amount_held | number: '1.2-2' }})</mat-label>
|
||||||
|
<input matInput type="number" min="0" [(ngModel)]="assessForm.restorableAmount" />
|
||||||
|
</mat-form-field>
|
||||||
|
}
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Remarks</mat-label>
|
||||||
|
<input matInput [(ngModel)]="assessForm.remarks" maxlength="1000" />
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
<button mat-flat-button color="primary" (click)="assessForMrm()" [disabled]="busy">
|
||||||
|
<fa-icon icon="check"></fa-icon> Assess for MRM
|
||||||
|
</button>
|
||||||
|
} @else if (!h.restorations?.length) {
|
||||||
|
<div class="sub">No restoration on this hold.</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head">
|
||||||
|
<fa-icon icon="file-csv"></fa-icon> Bank statement (GRM / MRM ask for it)
|
||||||
|
</div>
|
||||||
|
<div class="i4c-card__body">
|
||||||
|
<div class="i4c-form">
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>From (default 90 days back)</mat-label>
|
||||||
|
<input matInput type="date" [(ngModel)]="statement.fromDate" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>To (default today)</mat-label>
|
||||||
|
<input matInput type="date" [(ngModel)]="statement.toDate" />
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
<button mat-stroked-button (click)="downloadStatement()">
|
||||||
|
<fa-icon icon="download"></fa-icon> Download statement (CSV)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head">
|
||||||
|
<fa-icon icon="paperclip"></fa-icon> Documents on this hold (IO / court orders...)
|
||||||
|
</div>
|
||||||
|
<div class="i4c-card__body">
|
||||||
|
<mifosx-i4c-documents
|
||||||
|
ownerType="HOLD"
|
||||||
|
[ownerId]="h.id"
|
||||||
|
[suggested]="['IO_ORDER', 'COURT_ORDER', 'OTHER']"
|
||||||
|
[documents]="h.documents || null"
|
||||||
|
(changed)="select(h.id)"
|
||||||
|
></mifosx-i4c-documents>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- TAB 5: REQUEST AUDIT HISTORY -->
|
||||||
|
@if (modalTab === 'history') {
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head"><fa-icon icon="history"></fa-icon> Requests Audit History on this Hold</div>
|
||||||
|
<div class="i4c-card__body i4c-table-wrap">
|
||||||
|
<table class="i4c-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Action</th>
|
||||||
|
<th>Reason / Order</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Maker Details</th>
|
||||||
|
<th>Checker Details</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (a of h.actions; track a.id) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<b>{{ a.action }}</b>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ reasonLabel(a.reason_type) }}
|
||||||
|
<div class="sub">{{ a.order_reference }} {{ a.authority ? '- ' + a.authority : '' }}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="i4c-badge">{{ a.status }}</span>
|
||||||
|
@if (a.rejection_reason || a.failure_message) {
|
||||||
|
<div class="sub err-text">{{ a.rejection_reason || a.failure_message }}</div>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ a.maker_name }}
|
||||||
|
<div class="sub">{{ a.maker_at | date: 'dd MMM, HH:mm' }}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ a.checker_name || '-' }}
|
||||||
|
@if (a.checker_at) {
|
||||||
|
<div class="sub">{{ a.checker_at | date: 'dd MMM, HH:mm' }}</div>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal Footer -->
|
||||||
|
<div class="i4c-modal-footer">
|
||||||
|
<button class="footer-btn-close" (click)="close()">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@use '../i4c' as *;
|
||||||
|
|
||||||
|
@include i4c-skin;
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-btn {
|
||||||
|
height: 38px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 11.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count-tag {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #64748b;
|
||||||
|
background: #f1f5f9;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head-hint {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.acc-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
b {
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-tag {
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ack-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
.ack-no {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #334155;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.short-pill {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #dc2626;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intimation-ok {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
color: #059669;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-date {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #475569;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.due-alert {
|
||||||
|
color: #d97706;
|
||||||
|
font-weight: 700;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.err-text {
|
||||||
|
color: #dc2626;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-accent {
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount-held-text {
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 13.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-muted {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-btn-close {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #334155;
|
||||||
|
border: none;
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #cbd5e1;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.intim-status {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
border: 1px solid;
|
||||||
|
|
||||||
|
> div {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--done {
|
||||||
|
border-color: #bbf7d0;
|
||||||
|
background: #f0fdf4;
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--auto {
|
||||||
|
border-color: #bfdbfe;
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #1e3a8a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--manual {
|
||||||
|
border-color: #fde68a;
|
||||||
|
background: #fffbeb;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrap {
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cases-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 900px) {
|
||||||
|
.cases-grid {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus-visible {
|
||||||
|
border-color: #2563eb;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,455 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
|
import { DatePipe, DecimalPipe } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
|
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||||
|
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||||
|
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||||
|
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||||
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
import { HOLD_REASON_TYPES, HoldActionRequest, I4cHold, I4cService, i4cErrorMessage, saveBlob } from '../i4c.service';
|
||||||
|
import { I4cDocumentsComponent } from '../i4c-documents/i4c-documents.component';
|
||||||
|
import { MRM_BASES } from '../i4c-restorations/i4c-restorations.component';
|
||||||
|
import { ALL_DATES, I4cDateRange, I4cDateRangeComponent } from '../i4c-date-range/i4c-date-range.component';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The register of every hold placed for I4C. Placing is automatic; from here an officer asks for a release or a
|
||||||
|
* debit freeze/unfreeze (approved by someone else on the Approvals page), and records that the customer was told
|
||||||
|
* about the hold.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-i4c-holds',
|
||||||
|
templateUrl: './i4c-holds.component.html',
|
||||||
|
styleUrls: ['./i4c-holds.component.scss'],
|
||||||
|
imports: [
|
||||||
|
DatePipe,
|
||||||
|
DecimalPipe,
|
||||||
|
FormsModule,
|
||||||
|
RouterLink,
|
||||||
|
MatButtonModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatSelectModule,
|
||||||
|
MatCheckboxModule,
|
||||||
|
MatProgressBarModule,
|
||||||
|
MatPaginatorModule,
|
||||||
|
I4cDateRangeComponent,
|
||||||
|
I4cDocumentsComponent,
|
||||||
|
FaIconComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class I4cHoldsComponent implements OnInit {
|
||||||
|
private i4c = inject(I4cService);
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
|
private router = inject(Router);
|
||||||
|
private snackBar = inject(MatSnackBar);
|
||||||
|
|
||||||
|
readonly views = [
|
||||||
|
{ value: 'ACTIVE', label: 'Active' },
|
||||||
|
{ value: 'PROSPECTIVE', label: 'Prospective (still short)' },
|
||||||
|
{ value: 'INTIMATION_DUE', label: 'Customer not yet told' },
|
||||||
|
{ value: 'REVIEW_DUE', label: '90-day review due' },
|
||||||
|
{ value: 'NOT_HOLDABLE', label: 'Not holdable' },
|
||||||
|
{ value: 'RELEASED', label: 'Released' },
|
||||||
|
{ value: '', label: 'All' }
|
||||||
|
];
|
||||||
|
readonly reasonTypes = HOLD_REASON_TYPES;
|
||||||
|
/** Ways an officer can tell the customer and record it by hand; phone call comes first. */
|
||||||
|
readonly intimationModes = [
|
||||||
|
'PHONE',
|
||||||
|
'LETTER',
|
||||||
|
'IN_PERSON',
|
||||||
|
'SMS',
|
||||||
|
'EMAIL'
|
||||||
|
];
|
||||||
|
|
||||||
|
view = 'ACTIVE';
|
||||||
|
search = '';
|
||||||
|
accountType = '';
|
||||||
|
intimationFilter = '';
|
||||||
|
layer = '';
|
||||||
|
frozenOnly = false;
|
||||||
|
dates: I4cDateRange = { ...ALL_DATES };
|
||||||
|
total = 0;
|
||||||
|
pageIndex = 0;
|
||||||
|
pageSize = 20;
|
||||||
|
readonly pageSizes = [
|
||||||
|
10,
|
||||||
|
20,
|
||||||
|
50,
|
||||||
|
100
|
||||||
|
];
|
||||||
|
rows: I4cHold[] = [];
|
||||||
|
selected: I4cHold | null = null;
|
||||||
|
loading = false;
|
||||||
|
busy = false;
|
||||||
|
error = '';
|
||||||
|
modalTab: 'overview' | 'txns' | 'intimation' | 'action' | 'cases' | 'history' = 'overview';
|
||||||
|
readonly mrmBases = MRM_BASES;
|
||||||
|
grievanceForm = { customerRequest: 'REMOVE_HOLD', customerReason: '' };
|
||||||
|
assessForm = { basis: 'ATTRIBUTION', restorableAmount: null as number | null, remarks: '' };
|
||||||
|
statement = { fromDate: '', toDate: '' };
|
||||||
|
copiedKey = '';
|
||||||
|
|
||||||
|
action: HoldActionRequest = this.emptyAction();
|
||||||
|
intimation = { mode: 'PHONE', reference: '' };
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
const view = this.route.snapshot.queryParamMap.get('view');
|
||||||
|
if (view !== null) {
|
||||||
|
this.view = view;
|
||||||
|
}
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
setView(v: string): void {
|
||||||
|
this.view = v;
|
||||||
|
this.load(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
onPage(e: PageEvent): void {
|
||||||
|
this.pageIndex = e.pageIndex;
|
||||||
|
this.pageSize = e.pageSize;
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
onDates(range: I4cDateRange): void {
|
||||||
|
this.dates = range;
|
||||||
|
this.load(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasFilters(): boolean {
|
||||||
|
return !!(
|
||||||
|
this.search ||
|
||||||
|
this.accountType ||
|
||||||
|
this.intimationFilter ||
|
||||||
|
this.layer ||
|
||||||
|
this.frozenOnly ||
|
||||||
|
this.dates.preset !== 'ALL'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clears the extra filters; the view chip (Active, Released, ...) stays as chosen. */
|
||||||
|
clearFilters(): void {
|
||||||
|
this.search = '';
|
||||||
|
this.accountType = '';
|
||||||
|
this.intimationFilter = '';
|
||||||
|
this.layer = '';
|
||||||
|
this.frozenOnly = false;
|
||||||
|
this.dates = { ...ALL_DATES };
|
||||||
|
this.load(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reload the current page; a changed filter passes `fromFirstPage` so the officer lands on page one. */
|
||||||
|
load(fromFirstPage = false): void {
|
||||||
|
if (fromFirstPage) {
|
||||||
|
this.pageIndex = 0;
|
||||||
|
}
|
||||||
|
this.loading = true;
|
||||||
|
this.error = '';
|
||||||
|
this.i4c
|
||||||
|
.holds({
|
||||||
|
view: this.view,
|
||||||
|
search: this.search.trim(),
|
||||||
|
accountType: this.accountType,
|
||||||
|
intimation: this.intimationFilter,
|
||||||
|
layer: this.layer,
|
||||||
|
frozen: this.frozenOnly || null,
|
||||||
|
fromDate: this.dates.fromDate,
|
||||||
|
toDate: this.dates.toDate,
|
||||||
|
offset: this.pageIndex * this.pageSize,
|
||||||
|
limit: this.pageSize
|
||||||
|
})
|
||||||
|
.subscribe({
|
||||||
|
next: (page) => {
|
||||||
|
this.rows = page.pageItems;
|
||||||
|
this.total = page.totalFilteredRecords;
|
||||||
|
this.loading = false;
|
||||||
|
if (this.selected) {
|
||||||
|
this.select(this.selected.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.error = i4cErrorMessage(e);
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
select(id: number): void {
|
||||||
|
this.i4c.hold(id).subscribe({
|
||||||
|
next: (hold) => {
|
||||||
|
this.selected = hold;
|
||||||
|
this.action = this.emptyAction();
|
||||||
|
this.intimation = { mode: 'PHONE', reference: '' };
|
||||||
|
this.modalTab = 'overview';
|
||||||
|
},
|
||||||
|
error: (e) => this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
copyText(text: string, key: string, event?: Event): void {
|
||||||
|
if (event) {
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
if (!text) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
this.copiedKey = key;
|
||||||
|
this.snackBar.open(`Copied: ${text}`, 'OK', { duration: 2500 });
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.copiedKey === key) {
|
||||||
|
this.copiedKey = '';
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.selected = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a maker may ask for on this hold, given its state. */
|
||||||
|
get allowedActions(): { value: string; label: string }[] {
|
||||||
|
const h = this.selected;
|
||||||
|
if (!h || h.status === 'RELEASED') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const list = [{ value: 'RELEASE', label: 'Release hold' }];
|
||||||
|
if (h.account_type === 'SAVINGS') {
|
||||||
|
list.push(
|
||||||
|
h.debit_frozen
|
||||||
|
? { value: 'UNFREEZE', label: 'Unfreeze debits, hold continues' }
|
||||||
|
: { value: 'FREEZE', label: 'Freeze all debits (on explicit LEA / court direction only)' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (h.debit_frozen) {
|
||||||
|
list.push({ value: 'UNFREEZE_AND_RELEASE', label: 'Unfreeze and remove the hold' });
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
get orderRequired(): boolean {
|
||||||
|
return (
|
||||||
|
(this.action.action === 'RELEASE' || this.action.action === 'UNFREEZE_AND_RELEASE') &&
|
||||||
|
this.action.reasonType !== 'REVIEW_90_DAYS'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What stays on hold after a partial release; null when the whole hold is released. */
|
||||||
|
get remainingAfterRelease(): number | null {
|
||||||
|
const h = this.selected;
|
||||||
|
const amount = Number(this.action.amount);
|
||||||
|
if (!h || this.action.action !== 'RELEASE' || !amount) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Math.max(0, Number(h.amount_held) - amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
get openGrievance(): any {
|
||||||
|
return (this.selected?.grievances || []).find((g: any) => g.stage !== 'CLOSED');
|
||||||
|
}
|
||||||
|
|
||||||
|
get openRestoration(): any {
|
||||||
|
return (this.selected?.restorations || []).find((r: any) => r.status !== 'CLOSED');
|
||||||
|
}
|
||||||
|
|
||||||
|
raiseGrievance(): void {
|
||||||
|
if (!this.selected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.busy = true;
|
||||||
|
this.i4c.createGrievance(this.selected.id, this.grievanceForm).subscribe({
|
||||||
|
next: (r) => {
|
||||||
|
this.busy = false;
|
||||||
|
this.router.navigate(['/cfcfrms/i4c/grievances'], { queryParams: { open: r.resourceId } });
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.busy = false;
|
||||||
|
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 7000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
assessForMrm(): void {
|
||||||
|
if (!this.selected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const possible = this.assessForm.basis === 'ATTRIBUTION' || this.assessForm.basis === 'PRO_RATA';
|
||||||
|
this.busy = true;
|
||||||
|
this.i4c
|
||||||
|
.assessRestoration(this.selected.id, {
|
||||||
|
basis: this.assessForm.basis,
|
||||||
|
restorableAmount: possible ? this.assessForm.restorableAmount : null,
|
||||||
|
remarks: this.assessForm.remarks
|
||||||
|
})
|
||||||
|
.subscribe({
|
||||||
|
next: (r) => {
|
||||||
|
this.busy = false;
|
||||||
|
this.router.navigate(['/cfcfrms/i4c/restorations'], { queryParams: { open: r.resourceId } });
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.busy = false;
|
||||||
|
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 7000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
goTo(page: 'grievances' | 'restorations', id: number): void {
|
||||||
|
this.router.navigate(['/cfcfrms/i4c/' + page], { queryParams: { open: id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadStatement(): void {
|
||||||
|
if (!this.selected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const h = this.selected;
|
||||||
|
this.i4c.statementCsv(h.id, this.statement.fromDate || undefined, this.statement.toDate || undefined).subscribe({
|
||||||
|
next: (blob) => saveBlob(blob, 'statement-' + h.account_no + '.csv'),
|
||||||
|
error: (e) => this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasPendingAction(): boolean {
|
||||||
|
return (this.selected?.actions || []).some((a: any) => a.status === 'PENDING');
|
||||||
|
}
|
||||||
|
|
||||||
|
submitAction(): void {
|
||||||
|
if (!this.selected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.action.reasonType) {
|
||||||
|
this.snackBar.open('Choose the reason.', 'Close', { duration: 4000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.orderRequired && !this.action.orderReference?.trim()) {
|
||||||
|
this.snackBar.open('An order / reference number is required to release a hold.', 'Close', { duration: 5000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.busy = true;
|
||||||
|
this.i4c.requestHoldAction(this.selected.id, this.action).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.busy = false;
|
||||||
|
this.snackBar.open('Request raised. Another officer must approve it on the Approvals page.', 'OK', {
|
||||||
|
duration: 5000
|
||||||
|
});
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.busy = false;
|
||||||
|
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
submitIntimation(): void {
|
||||||
|
if (!this.selected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.busy = true;
|
||||||
|
this.i4c.recordIntimation(this.selected.id, this.intimation.mode, this.intimation.reference).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.busy = false;
|
||||||
|
this.snackBar.open('Customer intimation recorded.', 'OK', { duration: 3000 });
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.busy = false;
|
||||||
|
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send the hold notice now on the switched-on automatic channels (Email today). */
|
||||||
|
sendIntimationNow(): void {
|
||||||
|
if (!this.selected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.busy = true;
|
||||||
|
this.i4c.sendIntimation(this.selected.id).subscribe({
|
||||||
|
next: (r) => {
|
||||||
|
this.busy = false;
|
||||||
|
this.snackBar.open(r.message, 'OK', { duration: 6000 });
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.busy = false;
|
||||||
|
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
channelLabel(channel: string): string {
|
||||||
|
return (
|
||||||
|
(
|
||||||
|
{
|
||||||
|
EMAIL: 'Email',
|
||||||
|
SMS: 'SMS',
|
||||||
|
WHATSAPP: 'WhatsApp',
|
||||||
|
PHONE: 'Phone call',
|
||||||
|
LETTER: 'Letter',
|
||||||
|
IN_PERSON: 'In person'
|
||||||
|
} as Record<string, string>
|
||||||
|
)[channel] ?? channel
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
autoChannelsText(hold: I4cHold): string {
|
||||||
|
return (hold.automaticChannels || []).map((c) => this.channelLabel(c)).join(', then ');
|
||||||
|
}
|
||||||
|
|
||||||
|
isOverdue(hold: I4cHold): boolean {
|
||||||
|
return (
|
||||||
|
hold.status === 'ACTIVE' &&
|
||||||
|
!hold.customer_intimated_at &&
|
||||||
|
!!hold.intimation_due_by &&
|
||||||
|
new Date(hold.intimation_due_by).getTime() < Date.now()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
isReviewDue(hold: I4cHold): boolean {
|
||||||
|
if (hold.status !== 'ACTIVE' || !hold.review_due_on) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const due = new Date(hold.review_due_on + 'T00:00:00').getTime();
|
||||||
|
return due - Date.now() <= 15 * 24 * 3600 * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
statusClass(hold: I4cHold): string {
|
||||||
|
if (hold.status === 'RELEASED') {
|
||||||
|
return 'i4c-badge--blue';
|
||||||
|
}
|
||||||
|
if (hold.status === 'NOT_HOLDABLE') {
|
||||||
|
return 'i4c-badge--red';
|
||||||
|
}
|
||||||
|
return hold.shortfall > 0 ? 'i4c-badge--amber' : 'i4c-badge--green';
|
||||||
|
}
|
||||||
|
|
||||||
|
reasonLabel(value: string): string {
|
||||||
|
return this.reasonTypes.find((r) => r.value === value)?.label || value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private emptyAction(): HoldActionRequest {
|
||||||
|
return {
|
||||||
|
action: 'RELEASE',
|
||||||
|
amount: null,
|
||||||
|
reasonType: '',
|
||||||
|
orderReference: '',
|
||||||
|
orderDate: '',
|
||||||
|
authority: '',
|
||||||
|
remarks: ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
<!--
|
||||||
|
Copyright since 2025 Mifos Initiative
|
||||||
|
|
||||||
|
This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<div class="i4c-page">
|
||||||
|
@if (loading) {
|
||||||
|
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
|
||||||
|
}
|
||||||
|
@if (error) {
|
||||||
|
<div class="i4c-error">{{ error }}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="summary" [class.summary--manual]="!enabledChannels.length">
|
||||||
|
<fa-icon [icon]="enabledChannels.length ? 'check' : 'exclamation-triangle'" class="summary__icon"></fa-icon>
|
||||||
|
<div>
|
||||||
|
@if (enabledChannels.length) {
|
||||||
|
<div class="summary__title">
|
||||||
|
Customers are told automatically by
|
||||||
|
@for (c of enabledChannels; track c.channel; let last = $last) {
|
||||||
|
<b>{{ info(c.channel).label }}</b
|
||||||
|
>{{ last ? '' : ', then ' }}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="summary__text">
|
||||||
|
As soon as money is held, the channels are tried in this order. The first that reaches the customer records
|
||||||
|
the intimation on the hold. If none does (no email on file, gateway down), the hold stays "customer not yet
|
||||||
|
told" for an officer to call and record by hand.
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="summary__title">No automatic channel is switched on</div>
|
||||||
|
<div class="summary__text">
|
||||||
|
Every hold waits for an officer to contact the customer (phone, letter, in person) and record it on the hold's
|
||||||
|
Record Intimation tab.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@for (c of channels; track c.channel) {
|
||||||
|
<div class="i4c-card channel" [class.channel--off]="!c.enabled">
|
||||||
|
<div class="i4c-card__head">
|
||||||
|
<fa-icon [icon]="info(c.channel).icon"></fa-icon>
|
||||||
|
<span>{{ info(c.channel).label }}</span>
|
||||||
|
<span class="i4c-badge" [class.i4c-badge--green]="c.enabled">{{ c.enabled ? 'ON' : 'OFF' }}</span>
|
||||||
|
<span class="i4c-toolbar__spacer"></span>
|
||||||
|
<mat-slide-toggle
|
||||||
|
[id]="'toggle-' + c.channel"
|
||||||
|
[(ngModel)]="c.enabled"
|
||||||
|
(change)="toggle(c)"
|
||||||
|
[disabled]="c.saving"
|
||||||
|
[attr.aria-label]="'Send automatically by ' + info(c.channel).label"
|
||||||
|
>
|
||||||
|
Send automatically
|
||||||
|
</mat-slide-toggle>
|
||||||
|
</div>
|
||||||
|
<div class="i4c-card__body">
|
||||||
|
<div class="gateway">Sent through: {{ info(c.channel).gateway }}</div>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<div class="edit">
|
||||||
|
<div class="row">
|
||||||
|
<mat-form-field appearance="outline" class="priority">
|
||||||
|
<mat-label>Order</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="99"
|
||||||
|
[id]="'priority-' + c.channel"
|
||||||
|
[(ngModel)]="c.priority"
|
||||||
|
(ngModelChange)="c.dirty = true"
|
||||||
|
/>
|
||||||
|
</mat-form-field>
|
||||||
|
@if (c.channel === 'EMAIL') {
|
||||||
|
<mat-form-field appearance="outline" class="subject">
|
||||||
|
<mat-label>Subject</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
maxlength="300"
|
||||||
|
[id]="'subject-' + c.channel"
|
||||||
|
[(ngModel)]="c.subject"
|
||||||
|
(ngModelChange)="c.dirty = true"
|
||||||
|
/>
|
||||||
|
</mat-form-field>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<mat-form-field appearance="outline" class="full">
|
||||||
|
<mat-label>Message</mat-label>
|
||||||
|
<textarea
|
||||||
|
matInput
|
||||||
|
rows="9"
|
||||||
|
[id]="'body-' + c.channel"
|
||||||
|
[(ngModel)]="c.body"
|
||||||
|
(ngModelChange)="c.dirty = true"
|
||||||
|
></textarea>
|
||||||
|
</mat-form-field>
|
||||||
|
<div class="chips">
|
||||||
|
<span class="chips__label">Insert:</span>
|
||||||
|
@for (p of placeholders; track p) {
|
||||||
|
<button type="button" class="chip" (click)="insert(c, p)">{{ p }}</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="i4c-actions">
|
||||||
|
<button mat-flat-button color="primary" (click)="saveText(c)" [disabled]="!c.dirty || c.saving">
|
||||||
|
<fa-icon icon="check"></fa-icon> Save {{ info(c.channel).label }} message
|
||||||
|
</button>
|
||||||
|
@if (c.updated_at) {
|
||||||
|
<span class="muted"
|
||||||
|
>Last changed {{ c.updated_at | date: 'dd MMM yyyy, HH:mm' }} by {{ c.updated_by }}</span
|
||||||
|
>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preview">
|
||||||
|
<div class="preview__label">Preview (sample values)</div>
|
||||||
|
@if (c.channel === 'EMAIL') {
|
||||||
|
<div class="preview__subject">{{ preview(c.subject) }}</div>
|
||||||
|
}
|
||||||
|
<div class="preview__body">{{ preview(c.body) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (c.channel === 'EMAIL') {
|
||||||
|
<div class="test">
|
||||||
|
<mat-form-field appearance="outline" class="test__field" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Send a test to</mat-label>
|
||||||
|
<input matInput type="email" id="test-email-to" [(ngModel)]="testTo" placeholder="you@bank.in" />
|
||||||
|
</mat-form-field>
|
||||||
|
<button mat-stroked-button (click)="sendTest()" [disabled]="testing">
|
||||||
|
<fa-icon icon="paper-plane"></fa-icon> Send test email
|
||||||
|
</button>
|
||||||
|
<span class="muted">Uses the saved message with sample values; the subject starts with [TEST].</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="i4c-note">
|
||||||
|
Phone, letter and in-person intimation are always recorded by an officer on the hold's Record Intimation tab; the
|
||||||
|
CBS cannot send them itself. Every notice, automatic or recorded, is listed in the hold's intimation history.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@use '../i4c' as *;
|
||||||
|
|
||||||
|
@include i4c-skin;
|
||||||
|
|
||||||
|
.summary {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid #bbf7d0;
|
||||||
|
background: #f0fdf4;
|
||||||
|
color: #166534;
|
||||||
|
|
||||||
|
&--manual {
|
||||||
|
border-color: #fde68a;
|
||||||
|
background: #fffbeb;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__text {
|
||||||
|
font-size: 13px;
|
||||||
|
margin-top: 2px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel--off {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gateway {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 1024px) {
|
||||||
|
.grid {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.priority {
|
||||||
|
width: 110px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subject {
|
||||||
|
flex: 1 1 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.full {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 2px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
color: #334155;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #2563eb;
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid #2563eb;
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview {
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #f8fafc;
|
||||||
|
padding: 12px 14px;
|
||||||
|
align-self: start;
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__subject {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__body {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: #1e293b;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.test {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 14px;
|
||||||
|
padding-top: 14px;
|
||||||
|
border-top: 1px solid #f1f5f9;
|
||||||
|
|
||||||
|
&__field {
|
||||||
|
width: 280px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
|
import { DatePipe } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||||
|
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||||
|
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||||
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
import { I4cService, IntimationChannel, i4cErrorMessage } from '../i4c.service';
|
||||||
|
|
||||||
|
/** Sample values for the live preview; the server fills the real ones per hold. */
|
||||||
|
const SAMPLE: Record<string, string> = {
|
||||||
|
customerName: 'Anshul Rathore',
|
||||||
|
accountNumber: 'XXXXXXXXXXX0006',
|
||||||
|
amountHeld: '1500.00',
|
||||||
|
disputedAmount: '1500.00',
|
||||||
|
ackNo: '31610201726120',
|
||||||
|
holdDate: '20-10-2026',
|
||||||
|
reviewDate: '18-01-2027',
|
||||||
|
bankName: 'LincPay Demo Co-operative Bank',
|
||||||
|
branchName: 'Head Office',
|
||||||
|
grievanceEmail: 'grievance@lpdb.example.in',
|
||||||
|
customerCare: '1800-200-4570'
|
||||||
|
};
|
||||||
|
|
||||||
|
const CHANNEL_INFO: Record<string, { label: string; icon: string; gateway: string }> = {
|
||||||
|
EMAIL: {
|
||||||
|
label: 'Email',
|
||||||
|
icon: 'envelope',
|
||||||
|
gateway: "CBS mail server (spring.mail settings), to the customer's email on file"
|
||||||
|
},
|
||||||
|
SMS: {
|
||||||
|
label: 'SMS',
|
||||||
|
icon: 'mobile-alt',
|
||||||
|
gateway: "SMS gateway (fineract.notification.sms), to the customer's mobile on file"
|
||||||
|
},
|
||||||
|
WHATSAPP: {
|
||||||
|
label: 'WhatsApp',
|
||||||
|
icon: 'phone',
|
||||||
|
gateway: "WhatsApp gateway (fineract.notification.whatsapp), to the customer's mobile"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
interface EditableChannel extends IntimationChannel {
|
||||||
|
dirty?: boolean;
|
||||||
|
saving?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which channels tell the account holder about an I4C hold on their own. Switched-on channels are tried in priority
|
||||||
|
* order as soon as money is held; the first that reaches the customer records the intimation. With none switched on,
|
||||||
|
* or none reaching the customer, the hold waits for an officer to phone or write and record it by hand.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-i4c-intimation-settings',
|
||||||
|
templateUrl: './i4c-intimation-settings.component.html',
|
||||||
|
styleUrls: ['./i4c-intimation-settings.component.scss'],
|
||||||
|
imports: [
|
||||||
|
DatePipe,
|
||||||
|
FormsModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatSlideToggleModule,
|
||||||
|
MatProgressBarModule,
|
||||||
|
FaIconComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class I4cIntimationSettingsComponent implements OnInit {
|
||||||
|
private i4c = inject(I4cService);
|
||||||
|
private snackBar = inject(MatSnackBar);
|
||||||
|
|
||||||
|
channels: EditableChannel[] = [];
|
||||||
|
placeholders: string[] = [];
|
||||||
|
loading = false;
|
||||||
|
error = '';
|
||||||
|
testTo = '';
|
||||||
|
testing = false;
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
load(): void {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = '';
|
||||||
|
this.i4c.intimationChannels().subscribe({
|
||||||
|
next: (s) => {
|
||||||
|
this.channels = s.channels.map((c) => ({ ...c }));
|
||||||
|
this.placeholders = s.placeholders;
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.error = i4cErrorMessage(e);
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get enabledChannels(): EditableChannel[] {
|
||||||
|
return this.channels.filter((c) => c.enabled).sort((a, b) => a.priority - b.priority);
|
||||||
|
}
|
||||||
|
|
||||||
|
info(channel: string) {
|
||||||
|
return CHANNEL_INFO[channel] ?? { label: channel, icon: 'bell', gateway: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
preview(text: string | undefined): string {
|
||||||
|
return (text ?? '').replace(/\{(\w+)\}/g, (match, key) => SAMPLE[key] ?? match);
|
||||||
|
}
|
||||||
|
|
||||||
|
insert(channel: EditableChannel, placeholder: string): void {
|
||||||
|
channel.body = (channel.body ?? '') + placeholder;
|
||||||
|
channel.dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The switch saves at once: it decides whether customers get told automatically. */
|
||||||
|
toggle(channel: EditableChannel): void {
|
||||||
|
this.save(channel, { enabled: channel.enabled });
|
||||||
|
}
|
||||||
|
|
||||||
|
saveText(channel: EditableChannel): void {
|
||||||
|
if (!channel.body?.trim()) {
|
||||||
|
this.snackBar.open('The message text cannot be empty.', 'Close', { duration: 4000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (channel.channel === 'EMAIL' && !channel.subject?.trim()) {
|
||||||
|
this.snackBar.open('An email needs a subject.', 'Close', { duration: 4000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.save(channel, { priority: channel.priority, subject: channel.subject, body: channel.body });
|
||||||
|
}
|
||||||
|
|
||||||
|
private save(
|
||||||
|
channel: EditableChannel,
|
||||||
|
body: { enabled?: boolean; priority?: number; subject?: string; body?: string }
|
||||||
|
): void {
|
||||||
|
channel.saving = true;
|
||||||
|
this.i4c.updateIntimationChannel(channel.channel, body).subscribe({
|
||||||
|
next: (rows) => {
|
||||||
|
const fresh = rows.find((r) => r.channel === channel.channel);
|
||||||
|
if (fresh) {
|
||||||
|
Object.assign(channel, fresh, { dirty: false });
|
||||||
|
}
|
||||||
|
channel.saving = false;
|
||||||
|
this.snackBar.open(this.info(channel.channel).label + ' settings saved.', 'OK', { duration: 3000 });
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
channel.saving = false;
|
||||||
|
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 });
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
sendTest(): void {
|
||||||
|
if (!this.testTo.trim()) {
|
||||||
|
this.snackBar.open('Enter an email address for the test.', 'Close', { duration: 4000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.testing = true;
|
||||||
|
this.i4c.testIntimationChannel('EMAIL', this.testTo.trim()).subscribe({
|
||||||
|
next: (r) => {
|
||||||
|
this.testing = false;
|
||||||
|
this.snackBar.open(r.outcome === 'SENT' ? 'Test email sent to ' + this.testTo + '.' : r.message, 'OK', {
|
||||||
|
duration: 6000
|
||||||
|
});
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.testing = false;
|
||||||
|
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
<!--
|
||||||
|
Copyright since 2025 Mifos Initiative
|
||||||
|
|
||||||
|
This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<div class="i4c-page">
|
||||||
|
<div class="i4c-toolbar">
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="search-input">
|
||||||
|
<mat-label>Filter account / customer / branch / ack</mat-label>
|
||||||
|
<input matInput [(ngModel)]="filter" />
|
||||||
|
</mat-form-field>
|
||||||
|
<span class="i4c-toolbar__spacer"></span>
|
||||||
|
<span class="count-tag"
|
||||||
|
>{{ shown.length }} accounts · {{ totals.complaints }} complaints · ₹{{
|
||||||
|
totals.held | number: '1.2-2'
|
||||||
|
}}
|
||||||
|
held</span
|
||||||
|
>
|
||||||
|
<button mat-stroked-button (click)="load()" [disabled]="loading"><fa-icon icon="sync"></fa-icon> Refresh</button>
|
||||||
|
<button mat-flat-button color="primary" (click)="download()">
|
||||||
|
<fa-icon icon="file-csv"></fa-icon> Download for MRM (CSV)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (loading) {
|
||||||
|
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
|
||||||
|
}
|
||||||
|
@if (error) {
|
||||||
|
<div class="i4c-error">
|
||||||
|
<fa-icon icon="exclamation-circle"></fa-icon> <span>{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head">
|
||||||
|
<fa-icon icon="university"></fa-icon>
|
||||||
|
<span>MRM: Update Account Balance</span>
|
||||||
|
<span class="head-hint">
|
||||||
|
Every account with an active I4C hold. Enter these balances on MRM → Money Distribution Process (one by one or
|
||||||
|
with MRM's bulk format). Click an account for its pro-rata split.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="i4c-table-wrap">
|
||||||
|
<table class="i4c-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Account</th>
|
||||||
|
<th>Holder / Branch</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th class="num">Ledger balance</th>
|
||||||
|
<th class="num">Available</th>
|
||||||
|
<th class="num">On hold</th>
|
||||||
|
<th class="num">Disputed</th>
|
||||||
|
<th class="num">Complaints</th>
|
||||||
|
<th>Acknowledgement nos.</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (r of shown; track r.account_type + r.account_id) {
|
||||||
|
<tr class="clickable" (click)="pick(r)">
|
||||||
|
<td>
|
||||||
|
<b>{{ r.account_no }}</b>
|
||||||
|
<div class="sub">{{ r.account_type }}{{ r.debit_frozen ? ' · debits frozen' : '' }}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ r.client_name }}
|
||||||
|
<div class="sub">{{ r.office_name }}</div>
|
||||||
|
</td>
|
||||||
|
<td>{{ r.account_status || '-' }}</td>
|
||||||
|
<td class="num">{{ r.ledger_balance | number: '1.2-2' }}</td>
|
||||||
|
<td class="num">{{ r.available_balance | number: '1.2-2' }}</td>
|
||||||
|
<td class="num">
|
||||||
|
<b>{{ r.held | number: '1.2-2' }}</b>
|
||||||
|
</td>
|
||||||
|
<td class="num">{{ r.disputed | number: '1.2-2' }}</td>
|
||||||
|
<td class="num">{{ r.complaints }}</td>
|
||||||
|
<td class="wrap">{{ r.acknowledgement_nos }}</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="9" class="i4c-empty">No accounts with active I4C holds.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head">
|
||||||
|
<fa-icon icon="calculator"></fa-icon>
|
||||||
|
<span>Pro-rata distribution</span>
|
||||||
|
<span class="head-hint">
|
||||||
|
MRM: when money from several complaints is mixed in one account, it is shared in proportion to the disputed
|
||||||
|
amounts. Leave the amount empty to share everything held.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="i4c-card__body">
|
||||||
|
<div class="i4c-filter-bar i4c-filter-bar--inset">
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Account no.</mat-label>
|
||||||
|
<input matInput [(ngModel)]="proRataAccount" (keyup.enter)="calculate()" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Amount to share (₹)</mat-label>
|
||||||
|
<input matInput type="number" min="0" [(ngModel)]="proRataAmount" (keyup.enter)="calculate()" />
|
||||||
|
</mat-form-field>
|
||||||
|
<button mat-flat-button color="primary" (click)="calculate()" [disabled]="!proRataAccount.trim()">
|
||||||
|
<fa-icon icon="calculator"></fa-icon> Calculate
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
@if (proRata) {
|
||||||
|
<table class="i4c-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Complaint (ack)</th>
|
||||||
|
<th class="num">Layer</th>
|
||||||
|
<th class="num">Disputed</th>
|
||||||
|
<th class="num">Held for it</th>
|
||||||
|
<th class="num">Share</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (h of proRata.holds; track h.id) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ h.acknowledgement_no }}</td>
|
||||||
|
<td class="num">{{ h.layer }}</td>
|
||||||
|
<td class="num">{{ h.disputed_amount | number: '1.2-2' }}</td>
|
||||||
|
<td class="num">{{ h.amount_held | number: '1.2-2' }}</td>
|
||||||
|
<td class="num">
|
||||||
|
<b>{{ h.share | number: '1.2-2' }}</b>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="sub">
|
||||||
|
₹{{ proRata.amount | number: '1.2-2' }} of ₹{{ proRata.totalHeld | number: '1.2-2' }} held on
|
||||||
|
{{ proRata.accountNo }}, by disputed amount (total ₹{{ proRata.totalDisputed | number: '1.2-2' }}). A share
|
||||||
|
never exceeds what that complaint's hold has; use the figure as the restorable amount of that complaint's
|
||||||
|
restoration.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@use '../i4c' as *;
|
||||||
|
@use '../i4c-cases' as *;
|
||||||
|
|
||||||
|
@include i4c-skin;
|
||||||
|
@include i4c-case-page;
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
|
import { DecimalPipe } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||||
|
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||||
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
import { I4cService, i4cErrorMessage, saveBlob } from '../i4c.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MRM "Update Account Balance": every account with an active I4C hold and its balances now, for the bank to update on
|
||||||
|
* MRM (one by one or with the bulk sheet), plus the pro-rata calculator for accounts with several complaints.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-i4c-mrm-report',
|
||||||
|
templateUrl: './i4c-mrm-report.component.html',
|
||||||
|
styleUrls: ['./i4c-mrm-report.component.scss'],
|
||||||
|
imports: [
|
||||||
|
DecimalPipe,
|
||||||
|
FormsModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatProgressBarModule,
|
||||||
|
FaIconComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class I4cMrmReportComponent implements OnInit {
|
||||||
|
private i4c = inject(I4cService);
|
||||||
|
private snackBar = inject(MatSnackBar);
|
||||||
|
|
||||||
|
rows: any[] = [];
|
||||||
|
loading = false;
|
||||||
|
error = '';
|
||||||
|
filter = '';
|
||||||
|
proRataAccount = '';
|
||||||
|
proRataAmount: number | null = null;
|
||||||
|
proRata: any = null;
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
load(): void {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = '';
|
||||||
|
this.i4c.mrmBalanceReport().subscribe({
|
||||||
|
next: (rows) => {
|
||||||
|
this.rows = rows;
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.error = i4cErrorMessage(e);
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get shown(): any[] {
|
||||||
|
const f = this.filter.trim().toLowerCase();
|
||||||
|
return f
|
||||||
|
? this.rows.filter((r) =>
|
||||||
|
[
|
||||||
|
r.account_no,
|
||||||
|
r.client_name,
|
||||||
|
r.office_name,
|
||||||
|
r.acknowledgement_nos
|
||||||
|
].some((v) =>
|
||||||
|
String(v || '')
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
: this.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
get totals(): { held: number; ledger: number; complaints: number } {
|
||||||
|
return this.shown.reduce(
|
||||||
|
(t, r) => ({
|
||||||
|
held: t.held + Number(r.held || 0),
|
||||||
|
ledger: t.ledger + Number(r.ledger_balance || 0),
|
||||||
|
complaints: t.complaints + Number(r.complaints || 0)
|
||||||
|
}),
|
||||||
|
{ held: 0, ledger: 0, complaints: 0 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
download(): void {
|
||||||
|
this.i4c.mrmBalanceReportCsv().subscribe({
|
||||||
|
next: (blob) => saveBlob(blob, 'mrm-balance-report-' + new Date().toISOString().slice(0, 10) + '.csv'),
|
||||||
|
error: (e) => this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pick(r: any): void {
|
||||||
|
this.proRataAccount = r.account_no;
|
||||||
|
this.proRataAmount = null;
|
||||||
|
this.calculate();
|
||||||
|
}
|
||||||
|
|
||||||
|
calculate(): void {
|
||||||
|
if (!this.proRataAccount.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.i4c.proRata(this.proRataAccount.trim(), this.proRataAmount).subscribe({
|
||||||
|
next: (p) => (this.proRata = p),
|
||||||
|
error: (e) => {
|
||||||
|
this.proRata = null;
|
||||||
|
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,497 @@
|
|||||||
|
<!--
|
||||||
|
Copyright since 2025 Mifos Initiative
|
||||||
|
|
||||||
|
This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<div class="i4c-page">
|
||||||
|
<!-- View Switcher & Toolbar -->
|
||||||
|
<div class="i4c-toolbar">
|
||||||
|
<div class="i4c-filter-chips">
|
||||||
|
<button class="filter-chip" [class.filter-chip--active]="activeSection === 'all'" (click)="activeSection = 'all'">
|
||||||
|
<fa-icon icon="list-ul"></fa-icon> <span>All (Outbox & Log)</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="filter-chip"
|
||||||
|
[class.filter-chip--active]="activeSection === 'outbox'"
|
||||||
|
(click)="activeSection = 'outbox'"
|
||||||
|
>
|
||||||
|
<fa-icon icon="paper-plane"></fa-icon> <span>Responses to I4C ({{ outboxTotal }})</span>
|
||||||
|
</button>
|
||||||
|
<button class="filter-chip" [class.filter-chip--active]="activeSection === 'log'" (click)="activeSection = 'log'">
|
||||||
|
<fa-icon icon="history"></fa-icon> <span>API Audit Log ({{ logTotal }})</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="i4c-toolbar__spacer"></span>
|
||||||
|
|
||||||
|
<button mat-flat-button color="primary" class="refresh-btn" (click)="loadOutbox(); loadLog()" [disabled]="loading">
|
||||||
|
<fa-icon icon="sync" [animation]="loading ? 'spin' : undefined"></fa-icon> Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (loading) {
|
||||||
|
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
|
||||||
|
}
|
||||||
|
@if (error) {
|
||||||
|
<div class="i4c-error">
|
||||||
|
<fa-icon icon="exclamation-circle"></fa-icon>
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- SECTION 1: RESPONSES TO I4C (OUTBOX) -->
|
||||||
|
@if (activeSection === 'all' || activeSection === 'outbox') {
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head">
|
||||||
|
<fa-icon icon="paper-plane"></fa-icon>
|
||||||
|
<span>Outbox Queue: Delivery to I4C</span>
|
||||||
|
<span class="head-hint">Automated HTTP callbacks carrying bank findings to I4C</span>
|
||||||
|
|
||||||
|
<span class="i4c-toolbar__spacer"></span>
|
||||||
|
|
||||||
|
<!-- Status Filter Chips -->
|
||||||
|
<div class="mini-chips">
|
||||||
|
@for (s of outboxStatuses; track s) {
|
||||||
|
<button class="mini-chip" [class.mini-chip--active]="outboxStatus === s" (click)="setOutboxStatus(s)">
|
||||||
|
{{ s || 'All' }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-filter-bar i4c-filter-bar--inset">
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Ack no. / job id</mat-label>
|
||||||
|
<input matInput [(ngModel)]="outboxSearch" (keyup.enter)="loadOutbox(true)" (blur)="loadOutbox(true)" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Type</mat-label>
|
||||||
|
<mat-select [(ngModel)]="outboxType" (selectionChange)="loadOutbox(true)">
|
||||||
|
<mat-option value="">ACK and RESPONSE</mat-option>
|
||||||
|
<mat-option value="ACK">ACK only</mat-option>
|
||||||
|
<mat-option value="RESPONSE">RESPONSE only</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mifosx-i4c-date-range
|
||||||
|
label="Queued"
|
||||||
|
[range]="outboxDates"
|
||||||
|
(rangeChange)="onOutboxDates($event)"
|
||||||
|
></mifosx-i4c-date-range>
|
||||||
|
<button
|
||||||
|
mat-stroked-button
|
||||||
|
class="i4c-filter-bar__clear"
|
||||||
|
(click)="clearOutboxFilters()"
|
||||||
|
[disabled]="!outboxHasFilters"
|
||||||
|
>
|
||||||
|
<fa-icon icon="times"></fa-icon> Clear filters
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-table-wrap">
|
||||||
|
<table class="i4c-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Created At</th>
|
||||||
|
<th>Complaint Ack No.</th>
|
||||||
|
<th title="ACK: separate acknowledgement callback. RESPONSE: the Section 6 findings.">Type</th>
|
||||||
|
<th>Delivery Status</th>
|
||||||
|
<th class="num">Attempts</th>
|
||||||
|
<th>Next Retry</th>
|
||||||
|
<th>HTTP Code</th>
|
||||||
|
<th>Sent Timestamp</th>
|
||||||
|
<th>Error</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (o of outbox; track o.id) {
|
||||||
|
<tr class="clickable" (click)="openOutboxModal(o)">
|
||||||
|
<td>
|
||||||
|
<span class="date-main">{{ o.created_at | date: 'dd MMM, HH:mm:ss' }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a
|
||||||
|
[routerLink]="['../requests', o.request_id]"
|
||||||
|
class="font-semibold text-accent"
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
|
>
|
||||||
|
{{ o.acknowledgement_no }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="i4c-badge" [class.i4c-badge--blue]="o.message_type === 'ACK'">{{
|
||||||
|
o.message_type === 'ACK' ? 'ACK' : 'RESPONSE'
|
||||||
|
}}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
class="i4c-badge"
|
||||||
|
[class.i4c-badge--green]="o.status === 'SENT'"
|
||||||
|
[class.i4c-badge--red]="o.status === 'FAILED'"
|
||||||
|
[class.i4c-badge--amber]="o.status === 'PENDING' || o.status === 'SENDING'"
|
||||||
|
>
|
||||||
|
{{ o.status }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="num font-semibold">{{ o.attempts }}</td>
|
||||||
|
<td>{{ o.status === 'PENDING' ? (o.next_attempt_at | date: 'dd MMM, HH:mm:ss') : '-' }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="font-mono">{{ o.http_status ?? '-' }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ o.sent_at ? (o.sent_at | date: 'dd MMM, HH:mm:ss') : '-' }}</td>
|
||||||
|
<td class="err-text">{{ o.last_error || '-' }}</td>
|
||||||
|
<td (click)="$event.stopPropagation()">
|
||||||
|
<div class="i4c-actions">
|
||||||
|
<button class="table-action-btn" (click)="openOutboxModal(o)">
|
||||||
|
<fa-icon icon="eye"></fa-icon> Payload
|
||||||
|
</button>
|
||||||
|
@if (o.status === 'FAILED') {
|
||||||
|
<button class="retry-btn" (click)="retry(o)" [disabled]="busyId === o.id">
|
||||||
|
<fa-icon icon="redo" [animation]="busyId === o.id ? 'spin' : undefined"></fa-icon> Retry
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="10" class="i4c-empty">
|
||||||
|
<div class="empty-icon"><fa-icon icon="paper-plane"></fa-icon></div>
|
||||||
|
<div>No responses in the outbox queue matching current filter.</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<mat-paginator
|
||||||
|
class="i4c-paginator"
|
||||||
|
[length]="outboxTotal"
|
||||||
|
[pageIndex]="outboxPageIndex"
|
||||||
|
[pageSize]="outboxPageSize"
|
||||||
|
[pageSizeOptions]="pageSizes"
|
||||||
|
(page)="onOutboxPage($event)"
|
||||||
|
showFirstLastButtons
|
||||||
|
aria-label="Outbox pages"
|
||||||
|
></mat-paginator>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- SECTION 2: API LOG -->
|
||||||
|
@if (activeSection === 'all' || activeSection === 'log') {
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head">
|
||||||
|
<fa-icon icon="history"></fa-icon>
|
||||||
|
<span>API Gateway Audit Trail (Masked)</span>
|
||||||
|
<span class="head-hint">Every call to and from I4C. Click a row to see its request and response.</span>
|
||||||
|
|
||||||
|
<span class="i4c-toolbar__spacer"></span>
|
||||||
|
|
||||||
|
<!-- Direction Filters -->
|
||||||
|
<div class="mini-chips">
|
||||||
|
<button class="mini-chip" [class.mini-chip--active]="direction === ''" (click)="setDirection('')">
|
||||||
|
Both
|
||||||
|
</button>
|
||||||
|
<button class="mini-chip" [class.mini-chip--active]="direction === 'IN'" (click)="setDirection('IN')">
|
||||||
|
↓ Inbound
|
||||||
|
</button>
|
||||||
|
<button class="mini-chip" [class.mini-chip--active]="direction === 'OUT'" (click)="setDirection('OUT')">
|
||||||
|
↑ Outbound
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<mat-checkbox [(ngModel)]="failuresOnly" (change)="loadLog(true)" class="failures-chk">
|
||||||
|
Failures Only
|
||||||
|
</mat-checkbox>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-filter-bar i4c-filter-bar--inset">
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Endpoint / ack no. / IP</mat-label>
|
||||||
|
<input matInput [(ngModel)]="logSearch" (keyup.enter)="loadLog(true)" (blur)="loadLog(true)" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>HTTP status</mat-label>
|
||||||
|
<mat-select [(ngModel)]="logHttpStatus" (selectionChange)="loadLog(true)">
|
||||||
|
<mat-option value="">Any</mat-option>
|
||||||
|
<mat-option value="200">200 OK</mat-option>
|
||||||
|
<mat-option value="400">400 Bad request</mat-option>
|
||||||
|
<mat-option value="401">401 Not authorised</mat-option>
|
||||||
|
<mat-option value="403">403 Forbidden</mat-option>
|
||||||
|
<mat-option value="409">409 Duplicate</mat-option>
|
||||||
|
<mat-option value="500">500 Server error</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Response code</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
[(ngModel)]="logResponseCode"
|
||||||
|
(keyup.enter)="loadLog(true)"
|
||||||
|
(blur)="loadLog(true)"
|
||||||
|
placeholder="e.g. 00, 11"
|
||||||
|
/>
|
||||||
|
</mat-form-field>
|
||||||
|
<mifosx-i4c-date-range
|
||||||
|
label="Call time"
|
||||||
|
[range]="logDates"
|
||||||
|
(rangeChange)="onLogDates($event)"
|
||||||
|
></mifosx-i4c-date-range>
|
||||||
|
<button
|
||||||
|
mat-stroked-button
|
||||||
|
class="i4c-filter-bar__clear"
|
||||||
|
(click)="clearLogFilters()"
|
||||||
|
[disabled]="!logHasFilters"
|
||||||
|
>
|
||||||
|
<fa-icon icon="times"></fa-icon> Clear filters
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-table-wrap">
|
||||||
|
<table class="i4c-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Time</th>
|
||||||
|
<th>Direction</th>
|
||||||
|
<th>Endpoint</th>
|
||||||
|
<th>HTTP Status</th>
|
||||||
|
<th>Response Code</th>
|
||||||
|
<th>Ack No.</th>
|
||||||
|
<th class="num">Latency (ms)</th>
|
||||||
|
<th>Caller IP</th>
|
||||||
|
<th>Error Details</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (l of log; track l.id) {
|
||||||
|
<tr class="clickable" (click)="openLogModal(l)">
|
||||||
|
<td>{{ l.created_at | date: 'dd MMM, HH:mm:ss' }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="direction-badge direction-badge--{{ l.direction?.toLowerCase() }}">
|
||||||
|
{{ l.direction === 'IN' ? '↓ I4C → Bank' : '↑ Bank → I4C' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="font-mono text-xs">{{ l.endpoint }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
class="i4c-badge"
|
||||||
|
[class.i4c-badge--green]="ok(l.http_status)"
|
||||||
|
[class.i4c-badge--red]="!ok(l.http_status)"
|
||||||
|
>
|
||||||
|
{{ l.http_status ?? 'N/A' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ l.response_code || '-' }}</td>
|
||||||
|
<td>
|
||||||
|
<b>{{ l.acknowledgement_no || '-' }}</b>
|
||||||
|
</td>
|
||||||
|
<td class="num">{{ l.duration_ms }} ms</td>
|
||||||
|
<td>{{ l.client_ip || '-' }}</td>
|
||||||
|
<td [class.err-text]="isFailedCall(l)" [class.note-text]="!isFailedCall(l)">
|
||||||
|
{{ logNote(l.error) || '-' }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="9" class="i4c-empty">
|
||||||
|
<div class="empty-icon"><fa-icon icon="history"></fa-icon></div>
|
||||||
|
<div>No API calls recorded.</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<mat-paginator
|
||||||
|
class="i4c-paginator"
|
||||||
|
[length]="logTotal"
|
||||||
|
[pageIndex]="logPageIndex"
|
||||||
|
[pageSize]="logPageSize"
|
||||||
|
[pageSizeOptions]="pageSizes"
|
||||||
|
(page)="onLogPage($event)"
|
||||||
|
showFirstLastButtons
|
||||||
|
aria-label="API log pages"
|
||||||
|
></mat-paginator>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- ========================================================
|
||||||
|
MODERN POPUP WINDOW: API LOG JSON INSPECTION MODAL
|
||||||
|
======================================================== -->
|
||||||
|
@if (selectedLogModal) {
|
||||||
|
<div class="i4c-modal-backdrop" (click)="closeLogModal()">
|
||||||
|
<div class="i4c-modal-window i4c-modal-window--wide" (click)="$event.stopPropagation()">
|
||||||
|
<!-- Modal Header -->
|
||||||
|
<div class="i4c-modal-header">
|
||||||
|
<div class="i4c-modal-header__title">
|
||||||
|
<fa-icon icon="code"></fa-icon>
|
||||||
|
<div>
|
||||||
|
<div class="modal-title-row">
|
||||||
|
<span
|
||||||
|
>{{ selectedLogModal.direction === 'IN' ? 'Inbound Call' : 'Outbound Call' }}:
|
||||||
|
{{ selectedLogModal.endpoint }}</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="i4c-badge"
|
||||||
|
[class.i4c-badge--green]="ok(selectedLogModal.http_status)"
|
||||||
|
[class.i4c-badge--red]="!ok(selectedLogModal.http_status)"
|
||||||
|
>
|
||||||
|
HTTP {{ selectedLogModal.http_status ?? 'N/A' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="i4c-modal-header__sub">
|
||||||
|
Time: {{ selectedLogModal.created_at | date: 'dd MMM yyyy, HH:mm:ss' }} • Latency:
|
||||||
|
{{ selectedLogModal.duration_ms }} ms • Client IP: {{ selectedLogModal.client_ip }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="modal-close-btn" (click)="closeLogModal()" aria-label="Close">
|
||||||
|
<fa-icon icon="times"></fa-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Navigation Tabs inside the Modal -->
|
||||||
|
<div class="i4c-modal-tabs">
|
||||||
|
<button class="modal-tab" [class.modal-tab--active]="logModalTab === 'req'" (click)="logModalTab = 'req'">
|
||||||
|
<fa-icon icon="arrow-up"></fa-icon> Request Payload
|
||||||
|
</button>
|
||||||
|
<button class="modal-tab" [class.modal-tab--active]="logModalTab === 'res'" (click)="logModalTab = 'res'">
|
||||||
|
<fa-icon icon="arrow-down"></fa-icon> Response Payload
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal Body -->
|
||||||
|
<div class="i4c-modal-body">
|
||||||
|
@if (logModalTab === 'req') {
|
||||||
|
<div class="payload-box">
|
||||||
|
<div class="payload-box__title">
|
||||||
|
<span>HTTP Request Payload (Masked)</span>
|
||||||
|
<button class="copy-chip" (click)="copyPayload(pretty(selectedLogModal.request_body), 'req-body')">
|
||||||
|
<fa-icon icon="copy"></fa-icon> {{ copiedKey === 'req-body' ? 'Copied!' : 'Copy Request' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre class="i4c-json">{{ pretty(selectedLogModal.request_body) || 'No request body sent.' }}</pre>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (logModalTab === 'res') {
|
||||||
|
<div class="payload-box">
|
||||||
|
<div class="payload-box__title">
|
||||||
|
<span>HTTP Response Payload</span>
|
||||||
|
<button class="copy-chip" (click)="copyPayload(pretty(selectedLogModal.response_body), 'res-body')">
|
||||||
|
<fa-icon icon="copy"></fa-icon> {{ copiedKey === 'res-body' ? 'Copied!' : 'Copy Response' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre class="i4c-json">{{ pretty(selectedLogModal.response_body) || 'No response body received.' }}</pre>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (selectedLogModal.error) {
|
||||||
|
@if (isFailedCall(selectedLogModal)) {
|
||||||
|
<div class="i4c-error">
|
||||||
|
<fa-icon icon="exclamation-circle"></fa-icon>
|
||||||
|
<span>Error logged: {{ logNote(selectedLogModal.error) }}</span>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="i4c-note">
|
||||||
|
<fa-icon icon="info-circle"></fa-icon>
|
||||||
|
<span>Note: {{ logNote(selectedLogModal.error) }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal Footer -->
|
||||||
|
<div class="i4c-modal-footer">
|
||||||
|
<button class="footer-btn-close" (click)="closeLogModal()">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- ========================================================
|
||||||
|
MODERN POPUP WINDOW: OUTBOX RESPONSE INSPECTION MODAL
|
||||||
|
======================================================== -->
|
||||||
|
@if (selectedOutboxModal) {
|
||||||
|
<div class="i4c-modal-backdrop" (click)="closeOutboxModal()">
|
||||||
|
<div class="i4c-modal-window i4c-modal-window--wide" (click)="$event.stopPropagation()">
|
||||||
|
<div class="i4c-modal-header">
|
||||||
|
<div class="i4c-modal-header__title">
|
||||||
|
<fa-icon icon="paper-plane"></fa-icon>
|
||||||
|
<div>
|
||||||
|
<div class="modal-title-row">
|
||||||
|
<span
|
||||||
|
>Outbox Response #{{ selectedOutboxModal.id }} (Ack #{{
|
||||||
|
selectedOutboxModal.acknowledgement_no
|
||||||
|
}})</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="i4c-badge"
|
||||||
|
[class.i4c-badge--green]="selectedOutboxModal.status === 'SENT'"
|
||||||
|
[class.i4c-badge--red]="selectedOutboxModal.status === 'FAILED'"
|
||||||
|
>
|
||||||
|
{{ selectedOutboxModal.status }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="i4c-modal-header__sub">
|
||||||
|
Created: {{ selectedOutboxModal.created_at | date: 'dd MMM yyyy, HH:mm:ss' }} • Attempts:
|
||||||
|
{{ selectedOutboxModal.attempts }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="modal-close-btn" (click)="closeOutboxModal()" aria-label="Close">
|
||||||
|
<fa-icon icon="times"></fa-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-modal-body">
|
||||||
|
<div class="payload-box">
|
||||||
|
<div class="payload-box__title">
|
||||||
|
<span>Report Payload Prepared for I4C Delivery</span>
|
||||||
|
<button class="copy-chip" (click)="copyPayload(pretty(selectedOutboxModal.payload), 'outbox-payload')">
|
||||||
|
<fa-icon icon="copy"></fa-icon> {{ copiedKey === 'outbox-payload' ? 'Copied!' : 'Copy Payload' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre class="i4c-json">{{ pretty(selectedOutboxModal.payload) || 'No payload recorded.' }}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (selectedOutboxModal.response_body) {
|
||||||
|
<div class="payload-box">
|
||||||
|
<div class="payload-box__title">
|
||||||
|
<span>Response Received from I4C Gateway</span>
|
||||||
|
<button
|
||||||
|
class="copy-chip"
|
||||||
|
(click)="copyPayload(pretty(selectedOutboxModal.response_body), 'outbox-res')"
|
||||||
|
>
|
||||||
|
<fa-icon icon="copy"></fa-icon> {{ copiedKey === 'outbox-res' ? 'Copied!' : 'Copy Response' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre class="i4c-json">{{ pretty(selectedOutboxModal.response_body) }}</pre>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (selectedOutboxModal.last_error) {
|
||||||
|
<div class="i4c-error">
|
||||||
|
<fa-icon icon="exclamation-circle"></fa-icon>
|
||||||
|
<span>Last Delivery Error: {{ selectedOutboxModal.last_error }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-modal-footer">
|
||||||
|
@if (selectedOutboxModal.status === 'FAILED') {
|
||||||
|
<button mat-flat-button color="warn" (click)="retry(selectedOutboxModal); closeOutboxModal()">
|
||||||
|
<fa-icon icon="redo"></fa-icon> Retry Send Now
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
<button class="footer-btn-close" (click)="closeOutboxModal()">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@use '../i4c' as *;
|
||||||
|
|
||||||
|
@include i4c-skin;
|
||||||
|
|
||||||
|
.compact {
|
||||||
|
width: 160px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.err-text {
|
||||||
|
color: #dc2626;
|
||||||
|
font-size: 11px;
|
||||||
|
max-width: 240px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-weight: normal;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-main {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-chips {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
|
||||||
|
.mini-chip {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #475569;
|
||||||
|
background: #f1f5f9;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--active {
|
||||||
|
background: #2563eb;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #2563eb;
|
||||||
|
box-shadow: 0 2px 6px rgb(37 99 235 / 25%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.failures-chk {
|
||||||
|
margin-left: 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.direction-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
|
||||||
|
&--in {
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #1d4ed8;
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--out {
|
||||||
|
background: #f5f3ff;
|
||||||
|
color: #6d28d9;
|
||||||
|
border: 1px solid #ddd6fe;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.retry-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #dc2626;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: #fee2e2;
|
||||||
|
border-color: #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payload-box {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
color: #334155;
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-btn-close {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #334155;
|
||||||
|
border: none;
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #cbd5e1;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-text {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
|
import { DatePipe } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
|
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||||
|
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||||
|
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||||
|
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||||
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
import { I4cService, i4cErrorMessage } from '../i4c.service';
|
||||||
|
import { ALL_DATES, I4cDateRange, I4cDateRangeComponent } from '../i4c-date-range/i4c-date-range.component';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Responses waiting for, or delivered to, I4C, with a retry for the failed ones; and the log of every call in
|
||||||
|
* either direction, which is what the periodic review of the API integration reads.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-i4c-outbox',
|
||||||
|
templateUrl: './i4c-outbox.component.html',
|
||||||
|
styleUrls: ['./i4c-outbox.component.scss'],
|
||||||
|
imports: [
|
||||||
|
DatePipe,
|
||||||
|
FormsModule,
|
||||||
|
RouterLink,
|
||||||
|
MatButtonModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatSelectModule,
|
||||||
|
MatCheckboxModule,
|
||||||
|
MatProgressBarModule,
|
||||||
|
MatPaginatorModule,
|
||||||
|
I4cDateRangeComponent,
|
||||||
|
FaIconComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class I4cOutboxComponent implements OnInit {
|
||||||
|
private i4c = inject(I4cService);
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
|
private snackBar = inject(MatSnackBar);
|
||||||
|
|
||||||
|
readonly outboxStatuses = [
|
||||||
|
'',
|
||||||
|
'PENDING',
|
||||||
|
'SENDING',
|
||||||
|
'SENT',
|
||||||
|
'FAILED'
|
||||||
|
];
|
||||||
|
readonly pageSizes = [
|
||||||
|
10,
|
||||||
|
20,
|
||||||
|
50,
|
||||||
|
100
|
||||||
|
];
|
||||||
|
|
||||||
|
outboxStatus = '';
|
||||||
|
outboxType = '';
|
||||||
|
outboxSearch = '';
|
||||||
|
outboxDates: I4cDateRange = { ...ALL_DATES };
|
||||||
|
outboxTotal = 0;
|
||||||
|
outboxPageIndex = 0;
|
||||||
|
outboxPageSize = 20;
|
||||||
|
outbox: any[] = [];
|
||||||
|
|
||||||
|
direction = '';
|
||||||
|
failuresOnly = false;
|
||||||
|
logSearch = '';
|
||||||
|
logHttpStatus = '';
|
||||||
|
logResponseCode = '';
|
||||||
|
logDates: I4cDateRange = { ...ALL_DATES };
|
||||||
|
logTotal = 0;
|
||||||
|
logPageIndex = 0;
|
||||||
|
logPageSize = 20;
|
||||||
|
log: any[] = [];
|
||||||
|
logEntry: any = null;
|
||||||
|
|
||||||
|
loading = false;
|
||||||
|
busyId: number | null = null;
|
||||||
|
error = '';
|
||||||
|
|
||||||
|
activeSection: 'all' | 'outbox' | 'log' = 'all';
|
||||||
|
selectedLogModal: any = null;
|
||||||
|
selectedOutboxModal: any = null;
|
||||||
|
logModalTab: 'req' | 'res' = 'req';
|
||||||
|
copiedKey = '';
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.outboxStatus = this.route.snapshot.queryParamMap.get('status') || '';
|
||||||
|
this.loadOutbox();
|
||||||
|
this.loadLog();
|
||||||
|
}
|
||||||
|
|
||||||
|
setOutboxStatus(s: string): void {
|
||||||
|
this.outboxStatus = s;
|
||||||
|
this.loadOutbox(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
setDirection(d: string): void {
|
||||||
|
this.direction = d;
|
||||||
|
this.loadLog(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
onOutboxPage(e: PageEvent): void {
|
||||||
|
this.outboxPageIndex = e.pageIndex;
|
||||||
|
this.outboxPageSize = e.pageSize;
|
||||||
|
this.loadOutbox();
|
||||||
|
}
|
||||||
|
|
||||||
|
onOutboxDates(range: I4cDateRange): void {
|
||||||
|
this.outboxDates = range;
|
||||||
|
this.loadOutbox(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
get outboxHasFilters(): boolean {
|
||||||
|
return !!(this.outboxStatus || this.outboxType || this.outboxSearch || this.outboxDates.preset !== 'ALL');
|
||||||
|
}
|
||||||
|
|
||||||
|
clearOutboxFilters(): void {
|
||||||
|
this.outboxStatus = '';
|
||||||
|
this.outboxType = '';
|
||||||
|
this.outboxSearch = '';
|
||||||
|
this.outboxDates = { ...ALL_DATES };
|
||||||
|
this.loadOutbox(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
onLogPage(e: PageEvent): void {
|
||||||
|
this.logPageIndex = e.pageIndex;
|
||||||
|
this.logPageSize = e.pageSize;
|
||||||
|
this.loadLog();
|
||||||
|
}
|
||||||
|
|
||||||
|
onLogDates(range: I4cDateRange): void {
|
||||||
|
this.logDates = range;
|
||||||
|
this.loadLog(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
get logHasFilters(): boolean {
|
||||||
|
return !!(
|
||||||
|
this.direction ||
|
||||||
|
this.failuresOnly ||
|
||||||
|
this.logSearch ||
|
||||||
|
this.logHttpStatus ||
|
||||||
|
this.logResponseCode ||
|
||||||
|
this.logDates.preset !== 'ALL'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearLogFilters(): void {
|
||||||
|
this.direction = '';
|
||||||
|
this.failuresOnly = false;
|
||||||
|
this.logSearch = '';
|
||||||
|
this.logHttpStatus = '';
|
||||||
|
this.logResponseCode = '';
|
||||||
|
this.logDates = { ...ALL_DATES };
|
||||||
|
this.loadLog(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reload the current page; a changed filter passes `fromFirstPage` so the officer lands on page one. */
|
||||||
|
loadOutbox(fromFirstPage = false): void {
|
||||||
|
if (fromFirstPage) {
|
||||||
|
this.outboxPageIndex = 0;
|
||||||
|
}
|
||||||
|
this.loading = true;
|
||||||
|
this.error = '';
|
||||||
|
this.i4c
|
||||||
|
.outbox({
|
||||||
|
status: this.outboxStatus,
|
||||||
|
type: this.outboxType,
|
||||||
|
search: this.outboxSearch.trim(),
|
||||||
|
fromDate: this.outboxDates.fromDate,
|
||||||
|
toDate: this.outboxDates.toDate,
|
||||||
|
offset: this.outboxPageIndex * this.outboxPageSize,
|
||||||
|
limit: this.outboxPageSize
|
||||||
|
})
|
||||||
|
.subscribe({
|
||||||
|
next: (page) => {
|
||||||
|
this.outbox = page.pageItems;
|
||||||
|
this.outboxTotal = page.totalFilteredRecords;
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.error = i4cErrorMessage(e);
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
loadLog(fromFirstPage = false): void {
|
||||||
|
if (fromFirstPage) {
|
||||||
|
this.logPageIndex = 0;
|
||||||
|
}
|
||||||
|
this.i4c
|
||||||
|
.apiLog({
|
||||||
|
direction: this.direction,
|
||||||
|
failures: this.failuresOnly || null,
|
||||||
|
search: this.logSearch.trim(),
|
||||||
|
httpStatus: this.logHttpStatus,
|
||||||
|
responseCode: this.logResponseCode.trim(),
|
||||||
|
fromDate: this.logDates.fromDate,
|
||||||
|
toDate: this.logDates.toDate,
|
||||||
|
offset: this.logPageIndex * this.logPageSize,
|
||||||
|
limit: this.logPageSize
|
||||||
|
})
|
||||||
|
.subscribe({
|
||||||
|
next: (page) => {
|
||||||
|
this.log = page.pageItems;
|
||||||
|
this.logTotal = page.totalFilteredRecords;
|
||||||
|
},
|
||||||
|
error: (e) => (this.error = i4cErrorMessage(e))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
retry(row: any): void {
|
||||||
|
this.busyId = row.id;
|
||||||
|
this.i4c.retryOutbox(row.id).subscribe({
|
||||||
|
next: (r) => {
|
||||||
|
this.busyId = null;
|
||||||
|
this.snackBar.open(r?.sent ? 'Delivered to I4C.' : 'Queued; it will be sent again shortly.', 'OK', {
|
||||||
|
duration: 4000
|
||||||
|
});
|
||||||
|
this.loadOutbox();
|
||||||
|
this.loadLog();
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.busyId = null;
|
||||||
|
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
openLogModal(row: any): void {
|
||||||
|
this.selectedLogModal = { ...row };
|
||||||
|
this.logModalTab = 'req';
|
||||||
|
this.i4c.apiLogEntry(row.id).subscribe({
|
||||||
|
next: (entry) => (this.selectedLogModal = entry),
|
||||||
|
error: (e) => this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
closeLogModal(): void {
|
||||||
|
this.selectedLogModal = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
openOutboxModal(row: any): void {
|
||||||
|
this.selectedOutboxModal = row;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeOutboxModal(): void {
|
||||||
|
this.selectedOutboxModal = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
copyPayload(text: string, key: string): void {
|
||||||
|
if (!text) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
this.copiedKey = key;
|
||||||
|
this.snackBar.open('Payload copied to clipboard.', 'OK', { duration: 2500 });
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.copiedKey === key) {
|
||||||
|
this.copiedKey = '';
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A call failed when HTTP says so or I4C/the bank answered with a code other than 00. */
|
||||||
|
isFailedCall(log: any): boolean {
|
||||||
|
return (log?.http_status ?? 0) >= 400 || (!!log?.response_code && log.response_code !== '00');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Successful calls keep a short note in the error column, e.g. how I4C logged in; spell it out. */
|
||||||
|
logNote(text: string): string {
|
||||||
|
const modes: Record<string, string> = {
|
||||||
|
TOKEN: 'I4C logged in with a Bearer token',
|
||||||
|
BASIC: 'I4C logged in with Basic authentication',
|
||||||
|
BODY: 'I4C sent its username and password in the request body'
|
||||||
|
};
|
||||||
|
return (text || '').replace(/auth (TOKEN|BASIC|BODY)/g, (m, mode) => modes[mode] ?? m);
|
||||||
|
}
|
||||||
|
|
||||||
|
pretty(text: string): string {
|
||||||
|
if (!text) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.stringify(JSON.parse(text), null, 2);
|
||||||
|
} catch {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ok(status: number | null): boolean {
|
||||||
|
return status !== null && status !== undefined && status >= 200 && status < 300;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
<!--
|
||||||
|
Copyright since 2025 Mifos Initiative
|
||||||
|
|
||||||
|
This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<div class="i4c-page">
|
||||||
|
<!-- Top Action Toolbar -->
|
||||||
|
<div class="i4c-toolbar">
|
||||||
|
<a mat-stroked-button routerLink="../" class="back-btn"> <fa-icon icon="arrow-left"></fa-icon> All Complaints </a>
|
||||||
|
|
||||||
|
@if (data) {
|
||||||
|
<div class="ack-badge-strip">
|
||||||
|
<span class="label">Ack No:</span>
|
||||||
|
<b class="val">{{ data.acknowledgement_no }}</b>
|
||||||
|
<button class="copy-chip" (click)="copyAck(data.acknowledgement_no)">
|
||||||
|
<fa-icon icon="copy"></fa-icon> Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<span class="i4c-toolbar__spacer"></span>
|
||||||
|
|
||||||
|
@if (canReprocess) {
|
||||||
|
<button mat-flat-button color="primary" (click)="reprocess()" [disabled]="busy">
|
||||||
|
<fa-icon icon="redo" [animation]="busy ? 'spin' : undefined"></fa-icon> Reprocess Now
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
<button mat-stroked-button (click)="load()" [disabled]="loading">
|
||||||
|
<fa-icon icon="sync" [animation]="loading ? 'spin' : undefined"></fa-icon> Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (loading || busy) {
|
||||||
|
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
|
||||||
|
}
|
||||||
|
@if (error) {
|
||||||
|
<div class="i4c-error">
|
||||||
|
<fa-icon icon="exclamation-circle"></fa-icon>
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (data) {
|
||||||
|
<!-- Main Top Info: Customer Target Account + Processing -->
|
||||||
|
<div class="i4c-grid-2">
|
||||||
|
<!-- Target Account Info -->
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head">
|
||||||
|
<fa-icon icon="inbox"></fa-icon>
|
||||||
|
<span>Complaint Details (From I4C)</span>
|
||||||
|
</div>
|
||||||
|
<div class="i4c-card__body">
|
||||||
|
<dl class="i4c-kv">
|
||||||
|
<dt>Acknowledgement No.</dt>
|
||||||
|
<dd>
|
||||||
|
<b class="text-accent">{{ data.acknowledgement_no }}</b>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>CBS Internal Job ID</dt>
|
||||||
|
<dd>{{ data.job_id }}</dd>
|
||||||
|
|
||||||
|
<dt>Sub-Category</dt>
|
||||||
|
<dd>
|
||||||
|
<span class="cat-chip">{{ data.sub_category }}</span>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>Account in this Bank</dt>
|
||||||
|
<dd>
|
||||||
|
<b>{{ data.payer_account_number }}</b>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>Complainant Bank</dt>
|
||||||
|
<dd>{{ data.payer_bank }} {{ data.payer_bank_code ? '(' + data.payer_bank_code + ')' : '' }}</dd>
|
||||||
|
|
||||||
|
<dt>Payment Mode / Type</dt>
|
||||||
|
<dd>{{ data.mode_of_payment || '-' }} {{ data.transaction_type ? '/ ' + data.transaction_type : '' }}</dd>
|
||||||
|
|
||||||
|
<dt>Wallet Reference</dt>
|
||||||
|
<dd>{{ data.wallet || 'None' }}</dd>
|
||||||
|
|
||||||
|
<dt>State & District</dt>
|
||||||
|
<dd>{{ data.state }} / {{ data.district }}</dd>
|
||||||
|
|
||||||
|
<dt>Received At</dt>
|
||||||
|
<dd>{{ data.received_at | date: 'dd MMM yyyy, HH:mm:ss' }} from {{ data.source_ip }}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Processing State Info -->
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head">
|
||||||
|
<fa-icon icon="tasks"></fa-icon>
|
||||||
|
<span>Automation & Processing Outcome</span>
|
||||||
|
</div>
|
||||||
|
<div class="i4c-card__body">
|
||||||
|
<dl class="i4c-kv">
|
||||||
|
<dt>Processing Status</dt>
|
||||||
|
<dd>
|
||||||
|
<span class="i4c-badge">{{ data.status }}</span>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>Processing Attempts</dt>
|
||||||
|
<dd>{{ data.processing_attempts }}</dd>
|
||||||
|
|
||||||
|
<dt>Processed On</dt>
|
||||||
|
<dd>{{ data.processed_at ? (data.processed_at | date: 'dd MMM yyyy, HH:mm:ss') : 'Not yet processed' }}</dd>
|
||||||
|
|
||||||
|
<dt>Total Amount Held</dt>
|
||||||
|
<dd>
|
||||||
|
<b class="text-accent">₹{{ data.amount_held | number: '1.2-2' }}</b>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>Outbox Response Status</dt>
|
||||||
|
<dd>
|
||||||
|
<span class="i4c-badge">{{ data.response_status || 'NOT YET SENT' }}</span>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
@if (data.processing_error) {
|
||||||
|
<dt>Last Processing Error</dt>
|
||||||
|
<dd class="err-text">{{ data.processing_error }}</dd>
|
||||||
|
}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Incidents Table -->
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head">
|
||||||
|
<fa-icon icon="list-alt"></fa-icon>
|
||||||
|
<span>Reported Incidents & CBS Matching Engine Outcomes</span>
|
||||||
|
</div>
|
||||||
|
<div class="i4c-card__body i4c-table-wrap">
|
||||||
|
<table class="i4c-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>RRN</th>
|
||||||
|
<th>Date & Time</th>
|
||||||
|
<th class="num">Amount (₹)</th>
|
||||||
|
<th class="num">Disputed (₹)</th>
|
||||||
|
<th class="num">Layer</th>
|
||||||
|
<th>Match Method</th>
|
||||||
|
<th>Outcome</th>
|
||||||
|
<th class="num">Held Amount</th>
|
||||||
|
<th>Remarks</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (inc of data.incidents; track inc.id) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<b>{{ inc.seq_no }}</b>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="font-mono">{{ inc.rrn }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ inc.transaction_date }} {{ inc.transaction_time }}</td>
|
||||||
|
<td class="num">{{ inc.amount | number: '1.2-2' }}</td>
|
||||||
|
<td class="num">{{ inc.disputed_amount | number: '1.2-2' }}</td>
|
||||||
|
<td class="num font-semibold">{{ inc.layer }}</td>
|
||||||
|
<td>
|
||||||
|
<span>{{ methodLabel(inc.match_method) }}</span>
|
||||||
|
@if (inc.matched_txn_id) {
|
||||||
|
<div class="sub">Txn #{{ inc.matched_txn_id }}</div>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="i4c-badge" [class]="outcomeClass(inc.outcome)">{{ inc.outcome || 'PENDING' }}</span>
|
||||||
|
<div class="sub">Code: {{ inc.status_code || '-' }}</div>
|
||||||
|
</td>
|
||||||
|
<td class="num">
|
||||||
|
@if (holdFor(inc.id); as hold) {
|
||||||
|
₹{{ hold.amount_held | number: '1.2-2' }}
|
||||||
|
@if (hold.shortfall > 0) {
|
||||||
|
<div class="sub err-text">Short: ₹{{ hold.shortfall | number: '1.2-2' }}</div>
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
-
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>{{ inc.remarks || '-' }}</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Section 6 Findings -->
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head">
|
||||||
|
<fa-icon icon="paper-plane"></fa-icon>
|
||||||
|
<span>Entries Reported to I4C (Section 6 Compliance)</span>
|
||||||
|
</div>
|
||||||
|
<div class="i4c-card__body i4c-table-wrap">
|
||||||
|
<table class="i4c-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Incident</th>
|
||||||
|
<th>Transaction Type</th>
|
||||||
|
<th class="num">Amount (₹)</th>
|
||||||
|
<th class="num">Disputed (₹)</th>
|
||||||
|
<th>Status Code</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (f of data.findings; track f.id) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<b>{{ f.incident_id }}</b>
|
||||||
|
</td>
|
||||||
|
<td>{{ f.txn_type }} ({{ f.txn_type_id }})</td>
|
||||||
|
<td class="num">₹{{ f.amount | number: '1.2-2' }}</td>
|
||||||
|
<td class="num">₹{{ f.disputed_amount | number: '1.2-2' }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="i4c-badge">{{ f.status_code }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button class="table-action-btn" (click)="openEntry = openEntry === f.id ? null : f.id">
|
||||||
|
<fa-icon [icon]="openEntry === f.id ? 'eye-slash' : 'eye'"></fa-icon>
|
||||||
|
<span>{{ openEntry === f.id ? 'Hide JSON' : 'View JSON' }}</span>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@if (openEntry === f.id) {
|
||||||
|
<tr class="finding-json-row">
|
||||||
|
<td colspan="6" class="finding-json-cell">
|
||||||
|
<div class="finding-json-box">
|
||||||
|
<div class="finding-json-head">
|
||||||
|
<span> <fa-icon icon="code"></fa-icon> Finding #{{ f.id }} Payload </span>
|
||||||
|
<button type="button" class="copy-chip" (click)="copyAck(pretty(f.entry_json))">
|
||||||
|
<fa-icon icon="copy"></fa-icon>
|
||||||
|
<span>Copy</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre class="i4c-json">{{ pretty(f.entry_json) }}</pre>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="i4c-empty">No section 6 findings reported yet.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Outbox Delivery -->
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head">
|
||||||
|
<fa-icon icon="history"></fa-icon>
|
||||||
|
<span>Delivery to I4C Outbox Queue</span>
|
||||||
|
</div>
|
||||||
|
<div class="i4c-card__body i4c-table-wrap">
|
||||||
|
<table class="i4c-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Created At</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th class="num">Attempts</th>
|
||||||
|
<th>HTTP Code</th>
|
||||||
|
<th>Sent Timestamp</th>
|
||||||
|
<th>Error</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (o of data.outbox; track o.id) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ o.created_at | date: 'dd MMM, HH:mm:ss' }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="i4c-badge">{{ o.status }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="num">{{ o.attempts }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="font-mono">{{ o.http_status ?? '-' }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ o.sent_at ? (o.sent_at | date: 'dd MMM, HH:mm:ss') : '-' }}</td>
|
||||||
|
<td class="err-text">{{ o.last_error || '-' }}</td>
|
||||||
|
<td>
|
||||||
|
@if (o.status === 'FAILED') {
|
||||||
|
<button mat-stroked-button color="warn" (click)="retry(o.id)" [disabled]="busy">
|
||||||
|
<fa-icon icon="redo"></fa-icon> Retry Send
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="i4c-empty">No response written to outbox yet.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Raw Payloads Accordion -->
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head">
|
||||||
|
<fa-icon icon="code"></fa-icon>
|
||||||
|
<span>Cryptographic & Raw Payloads Inspection</span>
|
||||||
|
<span class="i4c-toolbar__spacer"></span>
|
||||||
|
<button mat-button (click)="showRaw = !showRaw">
|
||||||
|
<fa-icon [icon]="showRaw ? 'eye-slash' : 'eye'"></fa-icon>
|
||||||
|
<span>{{ showRaw ? 'Hide Payloads' : 'Inspect Payloads' }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
@if (showRaw) {
|
||||||
|
<div class="i4c-card__body payloads">
|
||||||
|
<div>
|
||||||
|
<div class="sub-title">Received from I4C Gateway (Sensitive credentials and PAN masked)</div>
|
||||||
|
<pre class="i4c-json">{{ pretty(data.raw_payload) }}</pre>
|
||||||
|
</div>
|
||||||
|
@for (o of data.outbox; track o.id) {
|
||||||
|
<div>
|
||||||
|
<div class="sub-title">Response #{{ o.id }} Sent to I4C</div>
|
||||||
|
<pre class="i4c-json">{{ pretty(o.payload) }}</pre>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@use '../i4c' as *;
|
||||||
|
|
||||||
|
@include i4c-skin;
|
||||||
|
|
||||||
|
.ack-badge-strip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.val {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-accent {
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cat-chip {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub {
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: #64748b;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-title {
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #475569;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.err-text {
|
||||||
|
color: #dc2626;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payloads {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
|
import { DatePipe, DecimalPipe } from '@angular/common';
|
||||||
|
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||||
|
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||||
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
import { I4cService, i4cErrorMessage } from '../i4c.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One complaint from I4C end to end: what I4C sent, what the bank matched in its books, the holds it placed, every
|
||||||
|
* entry it reported back, and each attempt to deliver that report.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-i4c-request-detail',
|
||||||
|
templateUrl: './i4c-request-detail.component.html',
|
||||||
|
styleUrls: ['./i4c-request-detail.component.scss'],
|
||||||
|
imports: [
|
||||||
|
DatePipe,
|
||||||
|
DecimalPipe,
|
||||||
|
RouterLink,
|
||||||
|
MatButtonModule,
|
||||||
|
MatProgressBarModule,
|
||||||
|
FaIconComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class I4cRequestDetailComponent implements OnInit {
|
||||||
|
private i4c = inject(I4cService);
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
|
private snackBar = inject(MatSnackBar);
|
||||||
|
|
||||||
|
data: any = null;
|
||||||
|
loading = false;
|
||||||
|
busy = false;
|
||||||
|
error = '';
|
||||||
|
showRaw = false;
|
||||||
|
openEntry: number | null = null;
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
get id(): number {
|
||||||
|
return Number(this.route.snapshot.paramMap.get('id'));
|
||||||
|
}
|
||||||
|
|
||||||
|
load(): void {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = '';
|
||||||
|
this.i4c.request(this.id).subscribe({
|
||||||
|
next: (d) => {
|
||||||
|
this.data = d;
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.error = i4cErrorMessage(e);
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get canReprocess(): boolean {
|
||||||
|
return this.data?.status === 'RETRY' || this.data?.status === 'RECEIVED';
|
||||||
|
}
|
||||||
|
|
||||||
|
reprocess(): void {
|
||||||
|
this.busy = true;
|
||||||
|
this.i4c.reprocess(this.id).subscribe({
|
||||||
|
next: (d) => {
|
||||||
|
this.data = d;
|
||||||
|
this.busy = false;
|
||||||
|
this.snackBar.open('Complaint processed again.', 'OK', { duration: 3000 });
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.busy = false;
|
||||||
|
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
retry(outboxId: number): void {
|
||||||
|
this.busy = true;
|
||||||
|
this.i4c.retryOutbox(outboxId).subscribe({
|
||||||
|
next: (r) => {
|
||||||
|
this.busy = false;
|
||||||
|
this.snackBar.open(
|
||||||
|
r?.sent ? 'Response delivered to I4C.' : 'Retry queued; it will be sent again shortly.',
|
||||||
|
'OK',
|
||||||
|
{
|
||||||
|
duration: 4000
|
||||||
|
}
|
||||||
|
);
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.busy = false;
|
||||||
|
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
findingsFor(incidentId: number): any[] {
|
||||||
|
return (this.data?.findings || []).filter((f: any) => f.incident_id === incidentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
holdFor(incidentId: number): any {
|
||||||
|
return (this.data?.holds || []).find((h: any) => h.incident_id === incidentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
pretty(json: string): string {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(JSON.parse(json), null, 2);
|
||||||
|
} catch {
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
outcomeClass(outcome: string): string {
|
||||||
|
switch (outcome) {
|
||||||
|
case 'HELD':
|
||||||
|
case 'TRACED':
|
||||||
|
return 'i4c-badge--green';
|
||||||
|
case 'PARTIALLY_HELD':
|
||||||
|
case 'PROSPECTIVE_HOLD':
|
||||||
|
return 'i4c-badge--amber';
|
||||||
|
case 'NOT_FOUND':
|
||||||
|
case 'NO_MATCH':
|
||||||
|
case 'NOT_HOLDABLE':
|
||||||
|
case 'ERROR':
|
||||||
|
return 'i4c-badge--red';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
methodLabel(method: string): string {
|
||||||
|
switch (method) {
|
||||||
|
case 'RRN_FUND_TRANSFER':
|
||||||
|
return 'RRN on outward transfer';
|
||||||
|
case 'RRN_REFERENCE_TEXT':
|
||||||
|
return 'RRN in transaction narration';
|
||||||
|
case 'AMOUNT_DATE':
|
||||||
|
return 'Amount + date (no RRN in CBS)';
|
||||||
|
case 'NONE':
|
||||||
|
return 'Not matched';
|
||||||
|
default:
|
||||||
|
return method || '-';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
copyAck(text: string): void {
|
||||||
|
if (!text) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
this.snackBar.open(`Copied: ${text}`, 'OK', { duration: 2500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,566 @@
|
|||||||
|
<!--
|
||||||
|
Copyright since 2025 Mifos Initiative
|
||||||
|
|
||||||
|
This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<div class="i4c-page">
|
||||||
|
<!-- Modern Toolbar & Filter Chips -->
|
||||||
|
<div class="i4c-toolbar">
|
||||||
|
<div class="i4c-filter-chips">
|
||||||
|
<button class="filter-chip" [class.filter-chip--active]="status === ''" (click)="setStatus('')">
|
||||||
|
<span>All Complaints</span>
|
||||||
|
</button>
|
||||||
|
@for (s of statuses; track s) {
|
||||||
|
<button class="filter-chip" [class.filter-chip--active]="status === s" (click)="setStatus(s)">
|
||||||
|
<span>{{ s }}</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="i4c-toolbar__spacer"></span>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="search-input">
|
||||||
|
<mat-label>Search acknowledgement / account / job id</mat-label>
|
||||||
|
<input matInput [(ngModel)]="search" (keyup.enter)="load(true)" placeholder="Type & hit enter..." />
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<button mat-flat-button color="primary" class="search-btn" (click)="load(true)">
|
||||||
|
<fa-icon icon="search"></fa-icon> Search
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span class="count-tag">{{ total }} records</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-filter-bar">
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Sub-category</mat-label>
|
||||||
|
<input matInput [(ngModel)]="subCategory" (keyup.enter)="load(true)" (blur)="load(true)" placeholder="e.g. UPI" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Layer</mat-label>
|
||||||
|
<mat-select [(ngModel)]="layer" (selectionChange)="load(true)">
|
||||||
|
<mat-option value="">All layers</mat-option>
|
||||||
|
<mat-option value="0">Layer 0 (victim's account)</mat-option>
|
||||||
|
<mat-option value="1">Layer 1+ (money landed here)</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Response to I4C</mat-label>
|
||||||
|
<mat-select [(ngModel)]="response" (selectionChange)="load(true)">
|
||||||
|
<mat-option value="">Any</mat-option>
|
||||||
|
<mat-option value="SENT">Sent</mat-option>
|
||||||
|
<mat-option value="PENDING">Pending / sending</mat-option>
|
||||||
|
<mat-option value="FAILED">Failed</mat-option>
|
||||||
|
<mat-option value="NONE">Not yet created</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mifosx-i4c-date-range label="Received" [range]="dates" (rangeChange)="onDates($event)"></mifosx-i4c-date-range>
|
||||||
|
<button mat-stroked-button class="i4c-filter-bar__clear" (click)="clearFilters()" [disabled]="!hasFilters">
|
||||||
|
<fa-icon icon="times"></fa-icon> Clear filters
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (loading) {
|
||||||
|
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
|
||||||
|
}
|
||||||
|
@if (error) {
|
||||||
|
<div class="i4c-error">
|
||||||
|
<fa-icon icon="exclamation-circle"></fa-icon>
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Complaints Data Table -->
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head">
|
||||||
|
<fa-icon icon="inbox"></fa-icon>
|
||||||
|
<span>I4C-MHA Cyber Fraud Complaints Log</span>
|
||||||
|
<span class="head-hint">Click any complaint to open modern detail popup window</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-table-wrap">
|
||||||
|
<table class="i4c-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Received</th>
|
||||||
|
<th>Acknowledgement No.</th>
|
||||||
|
<th>Sub-Category</th>
|
||||||
|
<th>Target Account</th>
|
||||||
|
<th class="num">Layer</th>
|
||||||
|
<th class="num">Incidents</th>
|
||||||
|
<th class="num">Held (₹)</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>I4C Response</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (row of rows; track row.id) {
|
||||||
|
<tr class="clickable" (click)="openModal(row)">
|
||||||
|
<td>
|
||||||
|
<span class="date-main">{{ row.received_at | date: 'dd MMM yyyy' }}</span>
|
||||||
|
<div class="sub">{{ row.received_at | date: 'HH:mm:ss' }}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="ack-cell">
|
||||||
|
<span class="ack-no">{{ row.acknowledgement_no }}</span>
|
||||||
|
<button
|
||||||
|
class="copy-chip"
|
||||||
|
(click)="copyAck(row.acknowledgement_no, 'ack-' + row.id, $event)"
|
||||||
|
title="Copy Ack No."
|
||||||
|
>
|
||||||
|
<fa-icon icon="copy"></fa-icon>
|
||||||
|
<span>{{ copiedId === 'ack-' + row.id ? 'Copied' : 'Copy' }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="sub">Job: {{ row.job_id }}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="cat-pill">{{ row.sub_category || 'N/A' }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<b>{{ row.payer_account_number }}</b>
|
||||||
|
</td>
|
||||||
|
<td class="num font-semibold">{{ row.max_layer }}</td>
|
||||||
|
<td class="num">{{ row.incidents }}</td>
|
||||||
|
<td class="num font-bold">₹{{ row.amount_held | number: '1.2-2' }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="i4c-badge" [class]="statusClass(row.status)">{{ row.status }}</span>
|
||||||
|
@if (row.processing_error) {
|
||||||
|
<div class="sub err-text" [title]="row.processing_error">{{ row.processing_error }}</div>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="i4c-badge" [class]="responseClass(row.response_status)">
|
||||||
|
{{ row.response_status || 'NOT YET' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="9" class="i4c-empty">
|
||||||
|
<div class="empty-icon"><fa-icon icon="inbox"></fa-icon></div>
|
||||||
|
<div>No complaints from I4C matched your query.</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<mat-paginator
|
||||||
|
class="i4c-paginator"
|
||||||
|
[length]="total"
|
||||||
|
[pageIndex]="pageIndex"
|
||||||
|
[pageSize]="pageSize"
|
||||||
|
[pageSizeOptions]="pageSizes"
|
||||||
|
(page)="onPage($event)"
|
||||||
|
showFirstLastButtons
|
||||||
|
aria-label="Complaints pages"
|
||||||
|
></mat-paginator>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ========================================================
|
||||||
|
MODERN POPUP WINDOW: COMPLAINT DETAILS MODAL
|
||||||
|
======================================================== -->
|
||||||
|
@if (selectedComplaint) {
|
||||||
|
<div class="i4c-modal-backdrop" (click)="closeModal()">
|
||||||
|
<div class="i4c-modal-window i4c-modal-window--wide" (click)="$event.stopPropagation()">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="i4c-modal-header">
|
||||||
|
<div class="i4c-modal-header__title">
|
||||||
|
<fa-icon icon="inbox"></fa-icon>
|
||||||
|
<div>
|
||||||
|
<div class="modal-title-row">
|
||||||
|
<span>Ack #{{ selectedComplaint.acknowledgement_no }}</span>
|
||||||
|
<span class="i4c-badge" [class]="statusClass(selectedComplaint.status)">{{
|
||||||
|
selectedComplaint.status
|
||||||
|
}}</span>
|
||||||
|
<button class="copy-chip" (click)="copyAck(selectedComplaint.acknowledgement_no, 'modal-ack')">
|
||||||
|
<fa-icon icon="copy"></fa-icon>
|
||||||
|
<span>{{ copiedId === 'modal-ack' ? 'Copied' : 'Copy Ack' }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="i4c-modal-header__sub">
|
||||||
|
Job ID: {{ selectedComplaint.job_id }} • Received:
|
||||||
|
{{ selectedComplaint.received_at | date: 'dd MMM yyyy, HH:mm:ss' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="i4c-modal-header__actions">
|
||||||
|
<button class="modal-close-btn" (click)="closeModal()" aria-label="Close">
|
||||||
|
<fa-icon icon="times"></fa-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Navigation Tabs inside the Modal -->
|
||||||
|
<div class="i4c-modal-tabs">
|
||||||
|
<button class="modal-tab" [class.modal-tab--active]="modalTab === 'overview'" (click)="modalTab = 'overview'">
|
||||||
|
<fa-icon icon="info-circle"></fa-icon> Overview & Account
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="modal-tab"
|
||||||
|
[class.modal-tab--active]="modalTab === 'incidents'"
|
||||||
|
(click)="modalTab = 'incidents'"
|
||||||
|
>
|
||||||
|
<fa-icon icon="tasks"></fa-icon> Incidents ({{
|
||||||
|
selectedComplaint.incidents?.length || selectedComplaint.incidents || 0
|
||||||
|
}})
|
||||||
|
</button>
|
||||||
|
<button class="modal-tab" [class.modal-tab--active]="modalTab === 'findings'" (click)="modalTab = 'findings'">
|
||||||
|
<fa-icon icon="list-alt"></fa-icon> Section 6 Findings ({{ selectedComplaint.findings?.length || 0 }})
|
||||||
|
</button>
|
||||||
|
<button class="modal-tab" [class.modal-tab--active]="modalTab === 'outbox'" (click)="modalTab = 'outbox'">
|
||||||
|
<fa-icon icon="paper-plane"></fa-icon> Outbox Responses ({{ selectedComplaint.outbox?.length || 0 }})
|
||||||
|
</button>
|
||||||
|
<button class="modal-tab" [class.modal-tab--active]="modalTab === 'payloads'" (click)="modalTab = 'payloads'">
|
||||||
|
<fa-icon icon="code"></fa-icon> Raw JSON
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal Body -->
|
||||||
|
<div class="i4c-modal-body">
|
||||||
|
@if (detailLoading) {
|
||||||
|
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
|
||||||
|
}
|
||||||
|
@if (detailError) {
|
||||||
|
<div class="i4c-error">{{ detailError }}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- TAB 1: OVERVIEW -->
|
||||||
|
@if (modalTab === 'overview') {
|
||||||
|
<div class="i4c-grid-2">
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head"><fa-icon icon="user-shield"></fa-icon> Target Customer Account</div>
|
||||||
|
<div class="i4c-card__body">
|
||||||
|
<dl class="i4c-kv">
|
||||||
|
<dt>Account Number</dt>
|
||||||
|
<dd>
|
||||||
|
<b>{{ selectedComplaint.payer_account_number }}</b>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>Sub-Category</dt>
|
||||||
|
<dd>{{ selectedComplaint.sub_category }}</dd>
|
||||||
|
|
||||||
|
<dt>Complainant Bank</dt>
|
||||||
|
<dd>
|
||||||
|
{{ selectedComplaint.payer_bank }}
|
||||||
|
{{ selectedComplaint.payer_bank_code ? '(' + selectedComplaint.payer_bank_code + ')' : '' }}
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>Payment Mode</dt>
|
||||||
|
<dd>
|
||||||
|
{{ selectedComplaint.mode_of_payment || 'N/A' }}
|
||||||
|
{{ selectedComplaint.transaction_type ? '/ ' + selectedComplaint.transaction_type : '' }}
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>Wallet Reference</dt>
|
||||||
|
<dd>{{ selectedComplaint.wallet || 'None' }}</dd>
|
||||||
|
|
||||||
|
<dt>State / District</dt>
|
||||||
|
<dd>{{ selectedComplaint.state }} / {{ selectedComplaint.district }}</dd>
|
||||||
|
|
||||||
|
<dt>Source IP</dt>
|
||||||
|
<dd>{{ selectedComplaint.source_ip || '-' }}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head"><fa-icon icon="sync"></fa-icon> Processing Status</div>
|
||||||
|
<div class="i4c-card__body">
|
||||||
|
<dl class="i4c-kv">
|
||||||
|
<dt>Processing Status</dt>
|
||||||
|
<dd>
|
||||||
|
<span class="i4c-badge" [class]="statusClass(selectedComplaint.status)">{{
|
||||||
|
selectedComplaint.status
|
||||||
|
}}</span>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>Processing Attempts</dt>
|
||||||
|
<dd>{{ selectedComplaint.processing_attempts ?? 1 }}</dd>
|
||||||
|
|
||||||
|
<dt>Processed Timestamp</dt>
|
||||||
|
<dd>
|
||||||
|
{{
|
||||||
|
selectedComplaint.processed_at
|
||||||
|
? (selectedComplaint.processed_at | date: 'dd MMM yyyy, HH:mm:ss')
|
||||||
|
: 'Pending processing'
|
||||||
|
}}
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>Total Held Amount</dt>
|
||||||
|
<dd>
|
||||||
|
<b class="amount-accent">₹{{ selectedComplaint.amount_held | number: '1.2-2' }}</b>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>Response to I4C</dt>
|
||||||
|
<dd>
|
||||||
|
<span class="i4c-badge" [class]="responseClass(selectedComplaint.response_status)">
|
||||||
|
{{ selectedComplaint.response_status || 'NOT YET SENT' }}
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
@if (selectedComplaint.processing_error) {
|
||||||
|
<dt>Last Error</dt>
|
||||||
|
<dd class="err-text">{{ selectedComplaint.processing_error }}</dd>
|
||||||
|
}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- TAB 2: INCIDENTS -->
|
||||||
|
@if (modalTab === 'incidents') {
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-table-wrap">
|
||||||
|
<table class="i4c-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>RRN</th>
|
||||||
|
<th>Date</th>
|
||||||
|
<th class="num">Amount (₹)</th>
|
||||||
|
<th class="num">Disputed (₹)</th>
|
||||||
|
<th class="num">Layer</th>
|
||||||
|
<th>Matched By</th>
|
||||||
|
<th>Outcome</th>
|
||||||
|
<th class="num">Held (₹)</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (inc of selectedComplaint.incidents; track inc.id) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<b>{{ inc.seq_no }}</b>
|
||||||
|
</td>
|
||||||
|
<td>{{ inc.rrn }}</td>
|
||||||
|
<td>{{ inc.transaction_date }} {{ inc.transaction_time }}</td>
|
||||||
|
<td class="num">{{ inc.amount | number: '1.2-2' }}</td>
|
||||||
|
<td class="num">{{ inc.disputed_amount | number: '1.2-2' }}</td>
|
||||||
|
<td class="num">{{ inc.layer }}</td>
|
||||||
|
<td>
|
||||||
|
<span>{{ inc.match_method || 'EXACT' }}</span>
|
||||||
|
@if (inc.matched_txn_id) {
|
||||||
|
<div class="sub">Txn #{{ inc.matched_txn_id }}</div>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="i4c-badge" [class]="outcomeClass(inc.outcome)">{{
|
||||||
|
inc.outcome || 'PENDING'
|
||||||
|
}}</span>
|
||||||
|
<div class="sub">Code: {{ inc.status_code || '-' }}</div>
|
||||||
|
</td>
|
||||||
|
<td class="num">
|
||||||
|
@if (holdFor(inc.id); as hold) {
|
||||||
|
₹{{ hold.amount_held | number: '1.2-2' }}
|
||||||
|
@if (hold.shortfall > 0) {
|
||||||
|
<div class="sub err-text">Short ₹{{ hold.shortfall | number: '1.2-2' }}</div>
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
-
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="9" class="i4c-empty">No incident details found.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- TAB 3: FINDINGS (SECTION 6) -->
|
||||||
|
@if (modalTab === 'findings') {
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-table-wrap">
|
||||||
|
<table class="i4c-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Incident ID</th>
|
||||||
|
<th>Transaction Type</th>
|
||||||
|
<th class="num">Amount (₹)</th>
|
||||||
|
<th class="num">Disputed (₹)</th>
|
||||||
|
<th>Status Code</th>
|
||||||
|
<th>Raw JSON</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (f of selectedComplaint.findings; track f.id) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ f.incident_id }}</td>
|
||||||
|
<td>{{ f.txn_type }} ({{ f.txn_type_id }})</td>
|
||||||
|
<td class="num">₹{{ f.amount | number: '1.2-2' }}</td>
|
||||||
|
@if (f.disputed_amount === null || f.disputed_amount === undefined) {
|
||||||
|
<td
|
||||||
|
class="num muted-cell"
|
||||||
|
title="The specification has no disputed amount in a Transaction Put on Hold entry"
|
||||||
|
>
|
||||||
|
-
|
||||||
|
</td>
|
||||||
|
} @else {
|
||||||
|
<td class="num">₹{{ f.disputed_amount | number: '1.2-2' }}</td>
|
||||||
|
}
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
class="i4c-badge"
|
||||||
|
[class]="statusCodeClass(f.status_code)"
|
||||||
|
[title]="statusCodeMeaning(f.status_code)"
|
||||||
|
>
|
||||||
|
{{ f.status_code }} · {{ statusCodeMeaning(f.status_code) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button class="table-action-btn" (click)="toggleFindingJson(f.id)">
|
||||||
|
{{ openFindingJsonId === f.id ? 'Hide JSON' : 'View JSON' }}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@if (openFindingJsonId === f.id) {
|
||||||
|
<tr class="finding-json-row">
|
||||||
|
<td colspan="6" class="finding-json-cell">
|
||||||
|
<div class="finding-json-box">
|
||||||
|
<div class="finding-json-head">
|
||||||
|
<span> <fa-icon icon="code"></fa-icon> Finding #{{ f.id }} Payload </span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="copy-chip"
|
||||||
|
(click)="copyAck(prettyJson(f.entry_json), 'finding-' + f.id, $event)"
|
||||||
|
>
|
||||||
|
<fa-icon icon="copy"></fa-icon>
|
||||||
|
<span>{{ copiedId === 'finding-' + f.id ? 'Copied!' : 'Copy' }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre class="i4c-json">{{ prettyJson(f.entry_json) }}</pre>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="i4c-empty">No section 6 findings reported yet.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- TAB 4: OUTBOX RESPONSES -->
|
||||||
|
@if (modalTab === 'outbox') {
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-table-wrap">
|
||||||
|
<table class="i4c-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Created</th>
|
||||||
|
<th title="ACK: separate acknowledgement callback. RESPONSE: the Section 6 findings.">Type</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th class="num">Attempts</th>
|
||||||
|
<th>HTTP Code</th>
|
||||||
|
<th>Sent Time</th>
|
||||||
|
<th>Error</th>
|
||||||
|
<th>Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (o of selectedComplaint.outbox; track o.id) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ o.created_at | date: 'dd MMM, HH:mm:ss' }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="i4c-badge" [class.i4c-badge--blue]="o.message_type === 'ACK'">{{
|
||||||
|
o.message_type === 'ACK' ? 'ACK' : 'RESPONSE'
|
||||||
|
}}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="i4c-badge" [class]="responseClass(o.status)">{{ o.status }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="num">{{ o.attempts }}</td>
|
||||||
|
<td>{{ o.http_status ?? '-' }}</td>
|
||||||
|
<td>{{ o.sent_at ? (o.sent_at | date: 'dd MMM, HH:mm:ss') : '-' }}</td>
|
||||||
|
<td class="err-text">{{ o.last_error }}</td>
|
||||||
|
<td>
|
||||||
|
@if (o.status === 'FAILED') {
|
||||||
|
<button
|
||||||
|
class="table-action-btn"
|
||||||
|
(click)="retryOutboxItem(o.id)"
|
||||||
|
[disabled]="retryingOutboxId === o.id"
|
||||||
|
>
|
||||||
|
<fa-icon
|
||||||
|
icon="redo"
|
||||||
|
[animation]="retryingOutboxId === o.id ? 'spin' : undefined"
|
||||||
|
></fa-icon>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="8" class="i4c-empty">No outbox delivery records yet.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- TAB 5: RAW PAYLOADS -->
|
||||||
|
@if (modalTab === 'payloads') {
|
||||||
|
<div class="payloads-view">
|
||||||
|
<div class="payload-box">
|
||||||
|
<div class="payload-box__title">
|
||||||
|
<fa-icon icon="inbox"></fa-icon>
|
||||||
|
<span>Payload Received from I4C Gateway (Masked)</span>
|
||||||
|
<button class="copy-chip" (click)="copyAck(prettyJson(selectedComplaint.raw_payload), 'payload-in')">
|
||||||
|
<fa-icon icon="copy"></fa-icon> Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre class="i4c-json">{{ prettyJson(selectedComplaint.raw_payload) }}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@for (o of selectedComplaint.outbox; track o.id) {
|
||||||
|
<div class="payload-box">
|
||||||
|
<div class="payload-box__title">
|
||||||
|
<fa-icon icon="paper-plane"></fa-icon>
|
||||||
|
<span>Response #{{ o.id }} Delivered to I4C</span>
|
||||||
|
<button class="copy-chip" (click)="copyAck(prettyJson(o.payload), 'payload-out-' + o.id)">
|
||||||
|
<fa-icon icon="copy"></fa-icon> Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre class="i4c-json">{{ prettyJson(o.payload) }}</pre>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="i4c-modal-footer">
|
||||||
|
@if (selectedComplaint.status === 'RETRY' || selectedComplaint.status === 'PROCESSING') {
|
||||||
|
<button
|
||||||
|
mat-flat-button
|
||||||
|
color="primary"
|
||||||
|
(click)="reprocessModal(selectedComplaint.id)"
|
||||||
|
[disabled]="reprocessing"
|
||||||
|
>
|
||||||
|
<fa-icon icon="redo" [animation]="reprocessing ? 'spin' : undefined"></fa-icon> Process Now
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
<button mat-stroked-button (click)="openFullPage(selectedComplaint.id)">
|
||||||
|
<fa-icon icon="external-link-alt"></fa-icon> Open in Full Page
|
||||||
|
</button>
|
||||||
|
<button class="footer-btn-close" (click)="closeModal()">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@use '../i4c' as *;
|
||||||
|
|
||||||
|
@include i4c-skin;
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-btn {
|
||||||
|
height: 38px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 11.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count-tag {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #64748b;
|
||||||
|
background: #f1f5f9;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head-hint {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ack-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
.ack-no {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-main {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cat-pill {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub {
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: #64748b;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.err-text {
|
||||||
|
color: #dc2626;
|
||||||
|
font-size: 10.5px;
|
||||||
|
max-width: 220px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount-accent {
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payloads-view {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payload-box {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1e293b;
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-btn-close {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #334155;
|
||||||
|
border: none;
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #cbd5e1;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.finding-json-cell {
|
||||||
|
padding: 12px 16px !important;
|
||||||
|
background: #0b1329 !important;
|
||||||
|
border-bottom: 2px solid #1e293b !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finding-json-box {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.finding-json-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #94a3b8;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
border-bottom: 1px solid #1e293b;
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
color: #60a5fa;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shown at full length: the popup body is the one scroll area, so nothing hides behind the footer. */
|
||||||
|
pre.i4c-json {
|
||||||
|
max-height: none;
|
||||||
|
overflow: visible;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
padding: 6px 0 16px;
|
||||||
|
box-shadow: none;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #38bdf8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted-cell {
|
||||||
|
color: #94a3b8;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
|
import { DatePipe, DecimalPipe } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
|
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||||
|
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||||
|
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||||
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
import { I4cService, i4cErrorMessage } from '../i4c.service';
|
||||||
|
import { ALL_DATES, I4cDateRange, I4cDateRangeComponent } from '../i4c-date-range/i4c-date-range.component';
|
||||||
|
|
||||||
|
/** Every complaint I4C has pushed to the bank, newest first. */
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-i4c-requests',
|
||||||
|
templateUrl: './i4c-requests.component.html',
|
||||||
|
styleUrls: ['./i4c-requests.component.scss'],
|
||||||
|
imports: [
|
||||||
|
DatePipe,
|
||||||
|
DecimalPipe,
|
||||||
|
FormsModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatSelectModule,
|
||||||
|
MatProgressBarModule,
|
||||||
|
MatPaginatorModule,
|
||||||
|
I4cDateRangeComponent,
|
||||||
|
FaIconComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class I4cRequestsComponent implements OnInit {
|
||||||
|
private i4c = inject(I4cService);
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
|
private router = inject(Router);
|
||||||
|
private snackBar = inject(MatSnackBar);
|
||||||
|
|
||||||
|
readonly statuses = [
|
||||||
|
'RECEIVED',
|
||||||
|
'PROCESSING',
|
||||||
|
'RETRY',
|
||||||
|
'PROCESSED'
|
||||||
|
];
|
||||||
|
rows: any[] = [];
|
||||||
|
status = '';
|
||||||
|
search = '';
|
||||||
|
subCategory = '';
|
||||||
|
layer = '';
|
||||||
|
response = '';
|
||||||
|
dates: I4cDateRange = { ...ALL_DATES };
|
||||||
|
total = 0;
|
||||||
|
pageIndex = 0;
|
||||||
|
pageSize = 20;
|
||||||
|
readonly pageSizes = [
|
||||||
|
10,
|
||||||
|
20,
|
||||||
|
50,
|
||||||
|
100
|
||||||
|
];
|
||||||
|
loading = false;
|
||||||
|
error = '';
|
||||||
|
|
||||||
|
// Popup Window Modal state
|
||||||
|
selectedComplaint: any = null;
|
||||||
|
detailLoading = false;
|
||||||
|
detailError = '';
|
||||||
|
modalTab: 'overview' | 'incidents' | 'findings' | 'outbox' | 'payloads' = 'overview';
|
||||||
|
openFindingJsonId: number | null = null;
|
||||||
|
reprocessing = false;
|
||||||
|
retryingOutboxId: number | null = null;
|
||||||
|
copiedId = '';
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.status = this.route.snapshot.queryParamMap.get('status') || '';
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reload the current page; a changed filter passes `fromFirstPage` so the officer lands on page one. */
|
||||||
|
load(fromFirstPage = false): void {
|
||||||
|
if (fromFirstPage) {
|
||||||
|
this.pageIndex = 0;
|
||||||
|
}
|
||||||
|
this.loading = true;
|
||||||
|
this.error = '';
|
||||||
|
this.i4c
|
||||||
|
.requests({
|
||||||
|
status: this.status,
|
||||||
|
search: this.search.trim(),
|
||||||
|
subCategory: this.subCategory.trim(),
|
||||||
|
layer: this.layer,
|
||||||
|
response: this.response,
|
||||||
|
fromDate: this.dates.fromDate,
|
||||||
|
toDate: this.dates.toDate,
|
||||||
|
offset: this.pageIndex * this.pageSize,
|
||||||
|
limit: this.pageSize
|
||||||
|
})
|
||||||
|
.subscribe({
|
||||||
|
next: (page) => {
|
||||||
|
this.rows = page.pageItems;
|
||||||
|
this.total = page.totalFilteredRecords;
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.error = i4cErrorMessage(e);
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus(s: string): void {
|
||||||
|
this.status = s;
|
||||||
|
this.load(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
onPage(e: PageEvent): void {
|
||||||
|
this.pageIndex = e.pageIndex;
|
||||||
|
this.pageSize = e.pageSize;
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
onDates(range: I4cDateRange): void {
|
||||||
|
this.dates = range;
|
||||||
|
this.load(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasFilters(): boolean {
|
||||||
|
return !!(
|
||||||
|
this.status ||
|
||||||
|
this.search ||
|
||||||
|
this.subCategory ||
|
||||||
|
this.layer ||
|
||||||
|
this.response ||
|
||||||
|
this.dates.preset !== 'ALL'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearFilters(): void {
|
||||||
|
this.status = '';
|
||||||
|
this.search = '';
|
||||||
|
this.subCategory = '';
|
||||||
|
this.layer = '';
|
||||||
|
this.response = '';
|
||||||
|
this.dates = { ...ALL_DATES };
|
||||||
|
this.load(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open in Modern Popup Window Modal
|
||||||
|
openModal(row: any): void {
|
||||||
|
this.detailLoading = true;
|
||||||
|
this.detailError = '';
|
||||||
|
this.modalTab = 'overview';
|
||||||
|
this.openFindingJsonId = null;
|
||||||
|
this.selectedComplaint = { ...row };
|
||||||
|
|
||||||
|
this.i4c.request(row.id).subscribe({
|
||||||
|
next: (full) => {
|
||||||
|
// Keep the list figures (held amount, response status) if the detail lacks them.
|
||||||
|
this.selectedComplaint = { ...row, ...full };
|
||||||
|
this.detailLoading = false;
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.detailError = i4cErrorMessage(e);
|
||||||
|
this.detailLoading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
closeModal(): void {
|
||||||
|
this.selectedComplaint = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleFindingJson(id: number): void {
|
||||||
|
this.openFindingJsonId = this.openFindingJsonId === id ? null : id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open in Full Page view
|
||||||
|
openFullPage(id: number): void {
|
||||||
|
this.closeModal();
|
||||||
|
this.router.navigate([id], { relativeTo: this.route });
|
||||||
|
}
|
||||||
|
|
||||||
|
reprocessModal(id: number): void {
|
||||||
|
this.reprocessing = true;
|
||||||
|
this.i4c.reprocess(id).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.reprocessing = false;
|
||||||
|
this.snackBar.open('Complaint queued for reprocessing.', 'OK', { duration: 4000 });
|
||||||
|
this.openModal({ id });
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.reprocessing = false;
|
||||||
|
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
retryOutboxItem(id: number): void {
|
||||||
|
this.retryingOutboxId = id;
|
||||||
|
this.i4c.retryOutbox(id).subscribe({
|
||||||
|
next: (r) => {
|
||||||
|
this.retryingOutboxId = null;
|
||||||
|
this.snackBar.open(r?.sent ? 'Delivered to I4C.' : 'Queued for retry.', 'OK', { duration: 4000 });
|
||||||
|
if (this.selectedComplaint?.id) {
|
||||||
|
this.openModal({ id: this.selectedComplaint.id });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.retryingOutboxId = null;
|
||||||
|
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
copyAck(text: string, id: string, event?: Event): void {
|
||||||
|
if (event) {
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
if (!text) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
this.copiedId = id;
|
||||||
|
this.snackBar.open(`Copied: ${text}`, 'OK', { duration: 2500 });
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.copiedId === id) {
|
||||||
|
this.copiedId = '';
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a section 6 status_code means, as I4C reads it. */
|
||||||
|
statusCodeMeaning(code: string): string {
|
||||||
|
switch (code) {
|
||||||
|
case '00':
|
||||||
|
return 'Success';
|
||||||
|
case '01':
|
||||||
|
return 'Record not found';
|
||||||
|
case '02':
|
||||||
|
return 'Invalid RRN';
|
||||||
|
case '70010':
|
||||||
|
return 'Not processed';
|
||||||
|
default:
|
||||||
|
return code ? 'Code ' + code : 'No code';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
statusCodeClass(code: string): string {
|
||||||
|
return code === '00' ? 'i4c-badge--green' : code === '01' || code === '02' ? 'i4c-badge--amber' : 'i4c-badge--red';
|
||||||
|
}
|
||||||
|
|
||||||
|
prettyJson(val: any): string {
|
||||||
|
if (!val) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = typeof val === 'string' ? JSON.parse(val) : val;
|
||||||
|
return JSON.stringify(parsed, null, 2);
|
||||||
|
} catch {
|
||||||
|
return String(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
statusClass(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'PROCESSED':
|
||||||
|
return 'i4c-badge--green';
|
||||||
|
case 'RETRY':
|
||||||
|
return 'i4c-badge--red';
|
||||||
|
case 'PROCESSING':
|
||||||
|
case 'RECEIVED':
|
||||||
|
return 'i4c-badge--amber';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
responseClass(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'SENT':
|
||||||
|
return 'i4c-badge--green';
|
||||||
|
case 'FAILED':
|
||||||
|
return 'i4c-badge--red';
|
||||||
|
case 'PENDING':
|
||||||
|
case 'SENDING':
|
||||||
|
return 'i4c-badge--amber';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
outcomeClass(outcome: string): string {
|
||||||
|
switch (outcome) {
|
||||||
|
case 'HELD':
|
||||||
|
return 'i4c-badge--green';
|
||||||
|
case 'SHORT':
|
||||||
|
return 'i4c-badge--amber';
|
||||||
|
case 'NOT_FOUND':
|
||||||
|
case 'FAILED':
|
||||||
|
return 'i4c-badge--red';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
holdFor(incidentId: number): any {
|
||||||
|
return (this.selectedComplaint?.holds || []).find((h: any) => h.incident_id === incidentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,682 @@
|
|||||||
|
<!--
|
||||||
|
Copyright since 2025 Mifos Initiative
|
||||||
|
|
||||||
|
This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<div class="i4c-page">
|
||||||
|
<div class="i4c-toolbar">
|
||||||
|
<div class="i4c-filter-chips">
|
||||||
|
<button
|
||||||
|
class="filter-chip"
|
||||||
|
[class.filter-chip--active]="role === 'BENEFICIARY_BANK'"
|
||||||
|
(click)="setRole('BENEFICIARY_BANK')"
|
||||||
|
>
|
||||||
|
<fa-icon icon="hand-holding-usd"></fa-icon> <span>Money held here (beneficiary bank)</span>
|
||||||
|
</button>
|
||||||
|
<button class="filter-chip" [class.filter-chip--active]="role === 'VICTIM_BANK'" (click)="setRole('VICTIM_BANK')">
|
||||||
|
<fa-icon icon="user"></fa-icon> <span>Our customer is the victim (victim bank)</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span class="i4c-toolbar__spacer"></span>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Layer 0 ack no.</mat-label>
|
||||||
|
<input matInput [(ngModel)]="victimAck" (keyup.enter)="openVictimCase()" />
|
||||||
|
</mat-form-field>
|
||||||
|
<button mat-stroked-button (click)="openVictimCase()" [disabled]="!victimAck.trim()">
|
||||||
|
<fa-icon icon="plus"></fa-icon> Open victim-bank case
|
||||||
|
</button>
|
||||||
|
<span class="count-tag">{{ total }} records</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-filter-bar">
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="search-input">
|
||||||
|
<mat-label>MR id / ack / victim / account / UTR</mat-label>
|
||||||
|
<input matInput [(ngModel)]="search" (keyup.enter)="load(true)" (blur)="load(true)" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Status</mat-label>
|
||||||
|
<mat-select [(ngModel)]="status" (selectionChange)="load(true)">
|
||||||
|
<mat-option value="OPEN">All open</mat-option>
|
||||||
|
<mat-option value="">All</mat-option>
|
||||||
|
@for (s of statuses; track s.value) {
|
||||||
|
<mat-option [value]="s.value">{{ s.label }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
@if (role === 'BENEFICIARY_BANK') {
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Bank action</mat-label>
|
||||||
|
<mat-select [(ngModel)]="bankAction" (selectionChange)="load(true)">
|
||||||
|
<mat-option value="">Any</mat-option>
|
||||||
|
<mat-option value="PENDING">Pending</mat-option>
|
||||||
|
@for (a of bankActions; track a.value) {
|
||||||
|
<mat-option [value]="a.value">{{ a.label }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
}
|
||||||
|
<mifosx-i4c-date-range label="Opened" [range]="dates" (rangeChange)="onDates($event)"></mifosx-i4c-date-range>
|
||||||
|
<button mat-stroked-button class="i4c-filter-bar__clear" (click)="clearFilters()" [disabled]="!hasFilters">
|
||||||
|
<fa-icon icon="times"></fa-icon> Clear filters
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (loading) {
|
||||||
|
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
|
||||||
|
}
|
||||||
|
@if (error) {
|
||||||
|
<div class="i4c-error">
|
||||||
|
<fa-icon icon="exclamation-circle"></fa-icon> <span>{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head">
|
||||||
|
<fa-icon icon="hand-holding-usd"></fa-icon>
|
||||||
|
<span>MRM Money Restoration</span>
|
||||||
|
<span class="head-hint"
|
||||||
|
>Assess a hold for MRM from the hold (Holds Register → hold → GRM / MRM). Click a row to work on it.</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="i4c-table-wrap">
|
||||||
|
<table class="i4c-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Opened</th>
|
||||||
|
<th>Ack / MR id</th>
|
||||||
|
@if (role === 'BENEFICIARY_BANK') {
|
||||||
|
<th>Account on hold</th>
|
||||||
|
<th>Basis</th>
|
||||||
|
<th class="num">Restorable (₹)</th>
|
||||||
|
}
|
||||||
|
<th>Victim</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Money</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (r of rows; track r.id) {
|
||||||
|
<tr class="clickable" (click)="open(r.id)">
|
||||||
|
<td>
|
||||||
|
{{ r.created_at | date: 'dd MMM yyyy' }}
|
||||||
|
<div class="sub">{{ r.created_by }}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<b>{{ r.acknowledgement_no }}</b>
|
||||||
|
<div class="sub">{{ r.mrm_request_no || 'no MR id yet' }}</div>
|
||||||
|
</td>
|
||||||
|
@if (role === 'BENEFICIARY_BANK') {
|
||||||
|
<td>
|
||||||
|
{{ r.hold_account_no }}
|
||||||
|
<div class="sub">{{ r.client_name }} · held ₹{{ r.amount_held | number: '1.2-2' }}</div>
|
||||||
|
</td>
|
||||||
|
<td>{{ label(bases, r.basis) }}</td>
|
||||||
|
<td class="num">{{ r.restorable_amount | number: '1.2-2' }}</td>
|
||||||
|
}
|
||||||
|
<td>
|
||||||
|
{{ r.victim_name || '-' }}
|
||||||
|
<div class="sub">{{ r.victim_account_no }} {{ r.victim_ifsc }}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
class="i4c-badge"
|
||||||
|
[class.i4c-badge--green]="r.status === 'RESTORED' || r.status === 'RECEIVED'"
|
||||||
|
[class.i4c-badge--amber]="
|
||||||
|
r.status === 'IN_POOL' || r.status === 'TRANSFER_PENDING' || r.status === 'AWAITING_APPROVAL'
|
||||||
|
"
|
||||||
|
>{{ label(statuses, r.status) }}</span
|
||||||
|
>
|
||||||
|
@if (r.bank_action && r.bank_action !== 'PENDING') {
|
||||||
|
<div class="sub">{{ r.bank_action }}</div>
|
||||||
|
}
|
||||||
|
@if (r.transfer_error) {
|
||||||
|
<div class="sub err-text">{{ r.transfer_error }}</div>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if (r.role === 'VICTIM_BANK') {
|
||||||
|
{{
|
||||||
|
r.received_amount
|
||||||
|
? '₹' + (r.received_amount | number: '1.2-2') + ' received'
|
||||||
|
: r.victim_status || '-'
|
||||||
|
}}
|
||||||
|
} @else {
|
||||||
|
{{ r.restored_amount ? '₹' + (r.restored_amount | number: '1.2-2') : '-' }}
|
||||||
|
<div class="sub">{{ r.utr || r.transfer_status || '' }}{{ r.mrm_updated ? ' · on MRM' : '' }}</div>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td [attr.colspan]="role === 'BENEFICIARY_BANK' ? 8 : 5" class="i4c-empty">
|
||||||
|
No restorations match these filters.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<mat-paginator
|
||||||
|
class="i4c-paginator"
|
||||||
|
[length]="total"
|
||||||
|
[pageIndex]="pageIndex"
|
||||||
|
[pageSize]="pageSize"
|
||||||
|
[pageSizeOptions]="pageSizes"
|
||||||
|
(page)="onPage($event)"
|
||||||
|
showFirstLastButtons
|
||||||
|
aria-label="Restoration pages"
|
||||||
|
></mat-paginator>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (selected; as r) {
|
||||||
|
<div class="i4c-modal-backdrop" (click)="close()">
|
||||||
|
<div class="i4c-modal-window i4c-modal-window--wide" (click)="$event.stopPropagation()">
|
||||||
|
<div class="i4c-modal-header">
|
||||||
|
<div class="i4c-modal-header__title">
|
||||||
|
<fa-icon icon="hand-holding-usd"></fa-icon>
|
||||||
|
<div>
|
||||||
|
<div class="modal-title-row">
|
||||||
|
<span
|
||||||
|
>{{ isVictim ? 'Victim-bank case' : 'Restoration' }} #{{ r.id }} · Ack
|
||||||
|
{{ r.acknowledgement_no }}</span
|
||||||
|
>
|
||||||
|
<span class="i4c-badge">{{ label(statuses, r.status) }}</span>
|
||||||
|
@if (r.mrm_updated) {
|
||||||
|
<span class="i4c-badge i4c-badge--green">ON MRM</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="i4c-modal-header__sub">
|
||||||
|
@if (isVictim) {
|
||||||
|
Our customer's account {{ r.victim_account_no }}
|
||||||
|
} @else {
|
||||||
|
Held on {{ r.hold_account_no }} ({{ r.client_name }}): ₹{{ r.amount_held | number: '1.2-2' }} ·
|
||||||
|
disputed ₹{{ r.disputed_amount | number: '1.2-2' }}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="i4c-modal-header__actions">
|
||||||
|
<button class="modal-close-btn" (click)="close()" aria-label="Close">
|
||||||
|
<fa-icon icon="times"></fa-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-modal-tabs">
|
||||||
|
<button class="modal-tab" [class.modal-tab--active]="tab === 'workflow'" (click)="tab = 'workflow'">
|
||||||
|
<fa-icon icon="tasks"></fa-icon> Next step
|
||||||
|
</button>
|
||||||
|
<button class="modal-tab" [class.modal-tab--active]="tab === 'details'" (click)="tab = 'details'">
|
||||||
|
<fa-icon icon="info-circle"></fa-icon> Details
|
||||||
|
</button>
|
||||||
|
<button class="modal-tab" [class.modal-tab--active]="tab === 'documents'" (click)="tab = 'documents'">
|
||||||
|
<fa-icon icon="paperclip"></fa-icon> Documents ({{ r.documents?.length || 0 }})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-modal-body">
|
||||||
|
@if (tab === 'workflow') {
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__body">
|
||||||
|
@if (!isVictim) {
|
||||||
|
@if (r.status === 'ASSESSED' || r.status === 'REQUESTED') {
|
||||||
|
<div class="step">
|
||||||
|
<div class="step__title">1. Assessment (what the bank updates on MRM)</div>
|
||||||
|
<div class="step__row">
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="step__wide">
|
||||||
|
<mat-label>Restoration</mat-label>
|
||||||
|
<mat-select [(ngModel)]="assessment.basis">
|
||||||
|
@for (b of bases; track b.value) {
|
||||||
|
<mat-option [value]="b.value">{{ b.label }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
@if (possible) {
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Restorable amount (₹)</mat-label>
|
||||||
|
<input matInput type="number" min="0" [(ngModel)]="assessment.restorableAmount" />
|
||||||
|
</mat-form-field>
|
||||||
|
<button mat-stroked-button (click)="loadProRata()">
|
||||||
|
<fa-icon icon="calculator"></fa-icon> Pro-rata share
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (proRata) {
|
||||||
|
<table class="i4c-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Complaint (ack)</th>
|
||||||
|
<th class="num">Disputed</th>
|
||||||
|
<th class="num">Held</th>
|
||||||
|
<th class="num">Pro-rata share</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (h of proRata.holds; track h.id) {
|
||||||
|
<tr [class.highlight]="h.id === r.hold_id">
|
||||||
|
<td>{{ h.acknowledgement_no }}{{ h.id === r.hold_id ? ' (this one)' : '' }}</td>
|
||||||
|
<td class="num">{{ h.disputed_amount | number: '1.2-2' }}</td>
|
||||||
|
<td class="num">{{ h.amount_held | number: '1.2-2' }}</td>
|
||||||
|
<td class="num">
|
||||||
|
<b>{{ h.share | number: '1.2-2' }}</b>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="sub">
|
||||||
|
₹{{ proRata.amount | number: '1.2-2' }} held on {{ proRata.accountNo }} split by disputed
|
||||||
|
amount (total ₹{{ proRata.totalDisputed | number: '1.2-2' }}).
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<mat-form-field appearance="outline" class="step__wide">
|
||||||
|
<mat-label>Remarks{{ possible ? '' : ' (why not possible) *' }}</mat-label>
|
||||||
|
<input matInput [(ngModel)]="assessment.remarks" maxlength="1000" />
|
||||||
|
</mat-form-field>
|
||||||
|
<div class="i4c-actions">
|
||||||
|
<button mat-stroked-button color="primary" (click)="saveAssessment()" [disabled]="busy">
|
||||||
|
<fa-icon icon="check"></fa-icon> Save assessment
|
||||||
|
</button>
|
||||||
|
@if (r.status === 'ASSESSED' && !r.mrm_updated) {
|
||||||
|
<button mat-button (click)="markMrm()" [disabled]="busy">Entered on MRM</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (r.status === 'ASSESSED' || r.status === 'REQUESTED' || r.status === 'IN_POOL') {
|
||||||
|
<div class="step">
|
||||||
|
<div class="step__title">
|
||||||
|
{{
|
||||||
|
r.status === 'IN_POOL'
|
||||||
|
? 'Refund account (correct it if the victim bank referred back)'
|
||||||
|
: "2. Victim's MRM request, as the IO sent it"
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<div class="step__row">
|
||||||
|
@if (r.status !== 'IN_POOL') {
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>MRM request id *</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
[(ngModel)]="request['mrmRequestNo']"
|
||||||
|
maxlength="40"
|
||||||
|
placeholder="MR20251208000003"
|
||||||
|
/>
|
||||||
|
</mat-form-field>
|
||||||
|
}
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Victim name *</mat-label>
|
||||||
|
<input matInput [(ngModel)]="request['victimName']" maxlength="200" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Refund account no. *</mat-label>
|
||||||
|
<input matInput [(ngModel)]="request['victimAccountNo']" maxlength="40" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>IFSC (blank = this bank)</mat-label>
|
||||||
|
<input matInput [(ngModel)]="request['victimIfsc']" maxlength="11" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Victim's bank</mat-label>
|
||||||
|
<input matInput [(ngModel)]="request['victimBankName']" maxlength="200" />
|
||||||
|
</mat-form-field>
|
||||||
|
@if (request['victimIfsc']) {
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Send by</mat-label>
|
||||||
|
<mat-select [(ngModel)]="request['transferChannel']">
|
||||||
|
<mat-option value="NEFT">NEFT</mat-option>
|
||||||
|
<mat-option value="IMPS">IMPS</mat-option>
|
||||||
|
<mat-option value="RTGS">RTGS</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (r.status !== 'IN_POOL') {
|
||||||
|
<div class="step__row">
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>IO name</mat-label>
|
||||||
|
<input matInput [(ngModel)]="request['ioName']" maxlength="200" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Police station</mat-label>
|
||||||
|
<input matInput [(ngModel)]="request['ioPoliceStation']" maxlength="200" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>BNSS 106 notice no.</mat-label>
|
||||||
|
<input matInput [(ngModel)]="request['bnssNoticeNo']" maxlength="100" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Notice date</mat-label>
|
||||||
|
<input matInput type="date" [(ngModel)]="request['bnssNoticeDate']" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-checkbox [(ngModel)]="request['indemnityBondReceived']"
|
||||||
|
>Indemnity bond received (Rs 10 stamp paper)</mat-checkbox
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="step__row">
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>FIR no. (needed above ₹{{ r.firThreshold }})</mat-label>
|
||||||
|
<input matInput [(ngModel)]="request['firNo']" maxlength="100" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>FIR date</mat-label>
|
||||||
|
<input matInput type="date" [(ngModel)]="request['firDate']" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Court order no.</mat-label>
|
||||||
|
<input matInput [(ngModel)]="request['courtOrderNo']" maxlength="100" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Court order date</mat-label>
|
||||||
|
<input matInput type="date" [(ngModel)]="request['courtOrderDate']" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Court</mat-label>
|
||||||
|
<input matInput [(ngModel)]="request['courtName']" maxlength="200" />
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="i4c-actions">
|
||||||
|
<button mat-stroked-button color="primary" (click)="saveRequest()" [disabled]="busy">
|
||||||
|
<fa-icon icon="check"></fa-icon> Save
|
||||||
|
{{ r.status === 'IN_POOL' ? 'refund account' : 'request' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (r.status === 'REQUESTED') {
|
||||||
|
<div class="step">
|
||||||
|
<div class="step__title">3. Bank action (as on MRM)</div>
|
||||||
|
<div class="step__row">
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="step__wide">
|
||||||
|
<mat-label>Action</mat-label>
|
||||||
|
<mat-select [(ngModel)]="action.action" (selectionChange)="action.reason = ''">
|
||||||
|
@for (a of bankActions; track a.value) {
|
||||||
|
<mat-option [value]="a.value">{{ a.label }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
@if (action.action === 'REFER_BACK_LEA') {
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="step__wide">
|
||||||
|
<mat-label>Reason</mat-label>
|
||||||
|
<mat-select [(ngModel)]="action.reason">
|
||||||
|
@for (x of referBackReasons; track x.value) {
|
||||||
|
<mat-option [value]="x.value">{{ x.label }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
} @else {
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="step__wide">
|
||||||
|
<mat-label>Remarks{{ action.action === 'COMPLIED' ? '' : ' *' }}</mat-label>
|
||||||
|
<input matInput [(ngModel)]="action.reason" maxlength="1000" />
|
||||||
|
</mat-form-field>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (action.action === 'COMPLIED') {
|
||||||
|
@if (papers.length) {
|
||||||
|
<div class="i4c-note">Still needed before compliance: {{ papers.join(', ') }}.</div>
|
||||||
|
}
|
||||||
|
<div class="sub">
|
||||||
|
₹{{ r.restorable_amount | number: '1.2-2' }} goes to {{ r.victim_name }} ({{
|
||||||
|
r.victim_account_no
|
||||||
|
}}{{ r.victim_ifsc ? ', ' + r.victim_ifsc : ', this bank' }}) once a second officer approves.
|
||||||
|
@if (r.victim_ifsc && !r.poolConfigured) {
|
||||||
|
<b class="err-text"
|
||||||
|
>No restoration pool account is configured, so an outward restoration cannot be
|
||||||
|
approved.</b
|
||||||
|
>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} @else if (action.action === 'NOT_COMPLIED' || action.action === 'REFER_BACK_LEA') {
|
||||||
|
<div class="sub">
|
||||||
|
MRM asks for the account's bank statement with this action; attach it on the Documents tab.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="i4c-actions">
|
||||||
|
<button
|
||||||
|
mat-flat-button
|
||||||
|
[color]="action.action === 'COMPLIED' ? 'warn' : 'primary'"
|
||||||
|
(click)="takeAction()"
|
||||||
|
[disabled]="busy"
|
||||||
|
>
|
||||||
|
<fa-icon [icon]="action.action === 'COMPLIED' ? 'hand-holding-usd' : 'check'"></fa-icon>
|
||||||
|
{{ action.action === 'COMPLIED' ? 'Raise restoration for approval' : 'Record bank action' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (r.status === 'AWAITING_APPROVAL') {
|
||||||
|
<div class="i4c-note">
|
||||||
|
Restoration request #{{ r.action_request_id }} is waiting for a second officer on the Approvals
|
||||||
|
page. On approval ₹{{ r.restorable_amount | number: '1.2-2' }} leaves {{ r.hold_account_no }} at
|
||||||
|
once.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (r.status === 'IN_POOL') {
|
||||||
|
<div class="step">
|
||||||
|
<div class="step__title">
|
||||||
|
₹{{ r.restored_amount | number: '1.2-2' }} is in the restoration pool
|
||||||
|
</div>
|
||||||
|
<div class="sub">
|
||||||
|
It left the suspect account on approval. The outward {{ r.transfer_channel }} to
|
||||||
|
{{ r.victim_account_no }} / {{ r.victim_ifsc }} has not gone through yet{{
|
||||||
|
r.transfer_error ? ': ' + r.transfer_error : '.'
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<div class="i4c-actions">
|
||||||
|
<button mat-flat-button color="primary" (click)="retryOutward()" [disabled]="busy">
|
||||||
|
<fa-icon icon="paper-plane"></fa-icon> Send to the victim's bank
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (r.status === 'TRANSFER_PENDING') {
|
||||||
|
<div class="i4c-note">
|
||||||
|
Outward transfer {{ r.fund_transfer_ref }} is {{ r.transfer_status }} (fund-transfer approval /
|
||||||
|
NEFT batch). The I4C Hold Review job marks it restored once it settles.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (r.status === 'RESTORED') {
|
||||||
|
<div class="step">
|
||||||
|
<div class="step__title">
|
||||||
|
Restored ₹{{ r.restored_amount | number: '1.2-2' }} on
|
||||||
|
{{ r.restored_at | date: 'dd MMM yyyy, HH:mm' }}
|
||||||
|
</div>
|
||||||
|
<div class="sub">
|
||||||
|
On MRM, choose "Compliance of restoration order" and enter amount ₹{{
|
||||||
|
r.restored_amount | number: '1.2-2'
|
||||||
|
}}, transaction id <b>{{ r.utr }}</b> and this date and time.
|
||||||
|
</div>
|
||||||
|
<div class="i4c-actions">
|
||||||
|
<button mat-flat-button color="primary" (click)="markMrm()" [disabled]="busy">
|
||||||
|
<fa-icon icon="check"></fa-icon> Entered on MRM (close)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
@if (r.status !== 'CLOSED') {
|
||||||
|
<div class="step">
|
||||||
|
<div class="step__title">Restored money received for our customer</div>
|
||||||
|
<div class="step__row">
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Amount (₹) *</mat-label>
|
||||||
|
<input matInput type="number" min="0" [(ngModel)]="receipt.amount" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>UTR / transaction id *</mat-label>
|
||||||
|
<input matInput [(ngModel)]="receipt.utr" maxlength="40" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Credited on *</mat-label>
|
||||||
|
<input matInput type="date" [(ngModel)]="receipt.receivedOn" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Credited to account</mat-label>
|
||||||
|
<input matInput [(ngModel)]="receipt.accountNo" maxlength="40" />
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
<div class="i4c-actions">
|
||||||
|
<button mat-flat-button color="primary" (click)="saveReceipt()" [disabled]="busy">
|
||||||
|
<fa-icon icon="check"></fa-icon> Record receipt
|
||||||
|
</button>
|
||||||
|
@if (r.status === 'RECEIVED') {
|
||||||
|
<button mat-stroked-button (click)="markMrm()" [disabled]="busy">
|
||||||
|
Entered on MRM (close)
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="step">
|
||||||
|
<div class="step__title">Or: why no (more) restoration is expected</div>
|
||||||
|
<div class="step__row">
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="step__wide">
|
||||||
|
<mat-label>Victim status</mat-label>
|
||||||
|
<mat-select [(ngModel)]="victim.status">
|
||||||
|
@for (v of victimStatuses; track v.value) {
|
||||||
|
<mat-option [value]="v.value">{{ v.label }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="step__wide">
|
||||||
|
<mat-label>Justification *</mat-label>
|
||||||
|
<input matInput [(ngModel)]="victim.remarks" maxlength="1000" />
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
<div class="i4c-actions">
|
||||||
|
<button
|
||||||
|
mat-stroked-button
|
||||||
|
(click)="saveVictimStatus()"
|
||||||
|
[disabled]="busy || !victim.remarks.trim()"
|
||||||
|
>
|
||||||
|
<fa-icon icon="check"></fa-icon> Record and close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="i4c-note">
|
||||||
|
Case closed{{ r.victim_status ? ': ' + label(victimStatuses, r.victim_status) : '' }}.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@if (!isVictim && r.status === 'CLOSED') {
|
||||||
|
<div class="i4c-note">
|
||||||
|
Closed{{
|
||||||
|
r.bank_action === 'NOT_COMPLIED' ? ': order not complied (' + r.bank_action_reason + ')' : ''
|
||||||
|
}}.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (tab === 'details') {
|
||||||
|
<div class="grid-2">
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head"><fa-icon icon="info-circle"></fa-icon> Request & papers</div>
|
||||||
|
<div class="i4c-card__body">
|
||||||
|
<dl class="i4c-kv">
|
||||||
|
<dt>MR id</dt>
|
||||||
|
<dd>{{ r.mrm_request_no || '-' }}</dd>
|
||||||
|
<dt>Basis</dt>
|
||||||
|
<dd>{{ label(bases, r.basis) }} {{ r.basis_remarks ? '(' + r.basis_remarks + ')' : '' }}</dd>
|
||||||
|
<dt>Restorable</dt>
|
||||||
|
<dd>{{ r.restorable_amount | number: '1.2-2' }}</dd>
|
||||||
|
<dt>Victim</dt>
|
||||||
|
<dd>
|
||||||
|
{{ r.victim_name }} · {{ r.victim_account_no }} {{ r.victim_ifsc }} {{ r.victim_bank_name }}
|
||||||
|
</dd>
|
||||||
|
<dt>IO</dt>
|
||||||
|
<dd>{{ r.io_name || '-' }} {{ r.io_police_station }}</dd>
|
||||||
|
<dt>BNSS 106</dt>
|
||||||
|
<dd>{{ r.bnss_notice_no || '-' }} {{ r.bnss_notice_date | date: 'dd MMM yyyy' }}</dd>
|
||||||
|
<dt>Indemnity bond</dt>
|
||||||
|
<dd>{{ r.indemnity_bond_received ? 'Received' : 'Not received' }}</dd>
|
||||||
|
<dt>FIR</dt>
|
||||||
|
<dd>{{ r.fir_no || '-' }} {{ r.fir_date | date: 'dd MMM yyyy' }}</dd>
|
||||||
|
<dt>Court order</dt>
|
||||||
|
<dd>
|
||||||
|
{{ r.court_order_no || '-' }} {{ r.court_order_date | date: 'dd MMM yyyy' }} {{ r.court_name }}
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head"><fa-icon icon="exchange-alt"></fa-icon> Money</div>
|
||||||
|
<div class="i4c-card__body">
|
||||||
|
<dl class="i4c-kv">
|
||||||
|
<dt>Bank action</dt>
|
||||||
|
<dd>{{ r.bank_action }} {{ r.bank_action_reason ? '(' + r.bank_action_reason + ')' : '' }}</dd>
|
||||||
|
<dt>Approval</dt>
|
||||||
|
<dd>
|
||||||
|
@if (r.actionRequest?.length) {
|
||||||
|
#{{ r.actionRequest[0].id }} {{ r.actionRequest[0].status }}
|
||||||
|
{{ r.actionRequest[0].checker_name || '' }}
|
||||||
|
{{ r.actionRequest[0].failure_message || r.actionRequest[0].rejection_reason || '' }}
|
||||||
|
} @else {
|
||||||
|
-
|
||||||
|
}
|
||||||
|
</dd>
|
||||||
|
<dt>Channel</dt>
|
||||||
|
<dd>{{ r.transfer_channel || '-' }} {{ r.transfer_status || '' }}</dd>
|
||||||
|
<dt>Pool transfer</dt>
|
||||||
|
<dd>{{ r.pool_transfer_id || '-' }}</dd>
|
||||||
|
<dt>Outward ref</dt>
|
||||||
|
<dd>{{ r.fund_transfer_ref || '-' }}</dd>
|
||||||
|
<dt>UTR / txn id</dt>
|
||||||
|
<dd>{{ r.utr || r.received_utr || '-' }}</dd>
|
||||||
|
<dt>Restored</dt>
|
||||||
|
<dd>
|
||||||
|
{{ r.restored_amount ? (r.restored_amount | number: '1.2-2') : '-' }}
|
||||||
|
{{ r.restored_at | date: 'dd MMM yyyy, HH:mm' }}
|
||||||
|
</dd>
|
||||||
|
<dt>On MRM</dt>
|
||||||
|
<dd>
|
||||||
|
{{
|
||||||
|
r.mrm_updated
|
||||||
|
? 'Yes, by ' + r.mrm_updated_by + ' ' + (r.mrm_updated_at | date: 'dd MMM yyyy')
|
||||||
|
: 'Not yet'
|
||||||
|
}}
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (tab === 'documents') {
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__body">
|
||||||
|
<mifosx-i4c-documents
|
||||||
|
ownerType="RESTORATION"
|
||||||
|
[ownerId]="r.id"
|
||||||
|
[suggested]="
|
||||||
|
isVictim
|
||||||
|
? ['BANK_STATEMENT', 'COURT_ORDER', 'OTHER']
|
||||||
|
: ['BNSS_106_NOTICE', 'INDEMNITY_BOND', 'FIR', 'COURT_ORDER', 'BANK_STATEMENT', 'PAN']
|
||||||
|
"
|
||||||
|
[documents]="r.documents"
|
||||||
|
[canUpload]="r.status !== 'CLOSED'"
|
||||||
|
(changed)="open(r.id)"
|
||||||
|
></mifosx-i4c-documents>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-modal-footer">
|
||||||
|
<button class="footer-btn-close" (click)="close()">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@use '../i4c' as *;
|
||||||
|
@use '../i4c-cases' as *;
|
||||||
|
|
||||||
|
@include i4c-skin;
|
||||||
|
@include i4c-case-page;
|
||||||
|
|
||||||
|
.highlight td {
|
||||||
|
background: #eff6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.err-text {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
|
import { DatePipe, DecimalPipe } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { ActivatedRoute } from '@angular/router';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
|
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||||
|
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||||
|
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||||
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
import { I4cService, i4cErrorMessage } from '../i4c.service';
|
||||||
|
import { ALL_DATES, I4cDateRange, I4cDateRangeComponent } from '../i4c-date-range/i4c-date-range.component';
|
||||||
|
import { I4cDocumentsComponent } from '../i4c-documents/i4c-documents.component';
|
||||||
|
|
||||||
|
export const MRM_BASES: { value: string; label: string }[] = [
|
||||||
|
{ value: 'ATTRIBUTION', label: 'Restoration possible (attribution)' },
|
||||||
|
{ value: 'PRO_RATA', label: 'Restoration possible (pro-rata distribution)' },
|
||||||
|
{ value: 'NOT_POSSIBLE_ALREADY_REFUNDED', label: 'Not possible (already refunded)' },
|
||||||
|
{ value: 'NOT_POSSIBLE_NO_BALANCE', label: 'Not possible (balance not available)' },
|
||||||
|
{ value: 'NOT_POSSIBLE_OTHERS', label: 'Not possible (others)' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const MRM_STATUSES: { value: string; label: string }[] = [
|
||||||
|
{ value: 'ASSESSED', label: 'Assessed' },
|
||||||
|
{ value: 'REQUESTED', label: 'Victim request received' },
|
||||||
|
{ value: 'AWAITING_APPROVAL', label: 'Awaiting checker' },
|
||||||
|
{ value: 'IN_POOL', label: 'In restoration pool' },
|
||||||
|
{ value: 'TRANSFER_PENDING', label: 'Outward transfer pending' },
|
||||||
|
{ value: 'RESTORED', label: 'Restored' },
|
||||||
|
{ value: 'AWAITED', label: 'Victim bank: awaited' },
|
||||||
|
{ value: 'RECEIVED', label: 'Victim bank: received' },
|
||||||
|
{ value: 'CLOSED', label: 'Closed' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const BANK_ACTIONS = [
|
||||||
|
{ value: 'COMPLIED', label: 'Compliance of restoration order (restore the money)' },
|
||||||
|
{ value: 'NOT_COMPLIED', label: 'Restoration order not complied' },
|
||||||
|
{ value: 'REFER_BACK_LEA', label: 'Refer back to LEA' },
|
||||||
|
{ value: 'REFER_BACK_VICTIM_BANK', label: 'Refer back to victim bank (wrong refund account)' },
|
||||||
|
{ value: 'CONSENSUS', label: 'Consensus request (several holds on the account)' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const REFER_BACK_REASONS = [
|
||||||
|
{ value: 'ISSUE_COURT_ORDER', label: 'Issue in court / police order' },
|
||||||
|
{ value: 'ISSUE_FIR', label: 'Issue in FIR details' },
|
||||||
|
{ value: 'ISSUE_ORDER_AND_FIR', label: 'Issue in order and FIR details' },
|
||||||
|
{ value: 'ISSUE_INDEMNITY_BOND', label: 'Issue in indemnity bond' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const VICTIM_STATUSES = [
|
||||||
|
{ value: 'ALREADY_REFUNDED_BANK', label: 'Already refunded through the bank' },
|
||||||
|
{ value: 'ALREADY_REFUNDED_POLICE_NOTICE', label: 'Already refunded (police notice)' },
|
||||||
|
{ value: 'ALREADY_REFUNDED_COURT_ORDER', label: 'Already refunded (court order)' },
|
||||||
|
{ value: 'DECLINED_REFUND', label: 'Victim declined to receive the refund' },
|
||||||
|
{ value: 'DECLINED_TO_PURSUE', label: 'Victim declined to pursue the refund' },
|
||||||
|
{ value: 'RECEIVED_THROUGH_COURT', label: 'Victim received refund through court order' },
|
||||||
|
{ value: 'NOT_REACHABLE', label: 'Victim not reachable' }
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MRM money restoration. As beneficiary bank: assess, record the victim's request and the IO's papers, take the bank
|
||||||
|
* action, and follow the money to the victim. As victim bank: record restored money received for our customer.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-i4c-restorations',
|
||||||
|
templateUrl: './i4c-restorations.component.html',
|
||||||
|
styleUrls: ['./i4c-restorations.component.scss'],
|
||||||
|
imports: [
|
||||||
|
DatePipe,
|
||||||
|
DecimalPipe,
|
||||||
|
FormsModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatCheckboxModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatSelectModule,
|
||||||
|
MatProgressBarModule,
|
||||||
|
MatPaginatorModule,
|
||||||
|
FaIconComponent,
|
||||||
|
I4cDateRangeComponent,
|
||||||
|
I4cDocumentsComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class I4cRestorationsComponent implements OnInit {
|
||||||
|
private i4c = inject(I4cService);
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
|
private snackBar = inject(MatSnackBar);
|
||||||
|
|
||||||
|
readonly bases = MRM_BASES;
|
||||||
|
readonly statuses = MRM_STATUSES;
|
||||||
|
readonly bankActions = BANK_ACTIONS;
|
||||||
|
readonly referBackReasons = REFER_BACK_REASONS;
|
||||||
|
readonly victimStatuses = VICTIM_STATUSES;
|
||||||
|
readonly pageSizes = [
|
||||||
|
10,
|
||||||
|
20,
|
||||||
|
50,
|
||||||
|
100
|
||||||
|
];
|
||||||
|
|
||||||
|
role = 'BENEFICIARY_BANK';
|
||||||
|
status = 'OPEN';
|
||||||
|
bankAction = '';
|
||||||
|
search = '';
|
||||||
|
dates: I4cDateRange = { ...ALL_DATES };
|
||||||
|
rows: any[] = [];
|
||||||
|
total = 0;
|
||||||
|
pageIndex = 0;
|
||||||
|
pageSize = 20;
|
||||||
|
loading = false;
|
||||||
|
error = '';
|
||||||
|
victimAck = '';
|
||||||
|
|
||||||
|
selected: any = null;
|
||||||
|
busy = false;
|
||||||
|
tab: 'workflow' | 'details' | 'documents' = 'workflow';
|
||||||
|
assessment = { basis: 'ATTRIBUTION', restorableAmount: null as number | null, remarks: '' };
|
||||||
|
request: Record<string, any> = {};
|
||||||
|
action = { action: 'COMPLIED', reason: '' };
|
||||||
|
receipt = { amount: null as number | null, utr: '', receivedOn: '', accountNo: '' };
|
||||||
|
victim = { status: 'ALREADY_REFUNDED_BANK', remarks: '' };
|
||||||
|
proRata: any = null;
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
const open = Number(this.route.snapshot.queryParamMap.get('open'));
|
||||||
|
this.load();
|
||||||
|
if (open) {
|
||||||
|
this.open(open);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
load(fromFirstPage = false): void {
|
||||||
|
if (fromFirstPage) {
|
||||||
|
this.pageIndex = 0;
|
||||||
|
}
|
||||||
|
this.loading = true;
|
||||||
|
this.error = '';
|
||||||
|
this.i4c
|
||||||
|
.restorations({
|
||||||
|
role: this.role,
|
||||||
|
status: this.status,
|
||||||
|
bankAction: this.bankAction,
|
||||||
|
search: this.search.trim(),
|
||||||
|
fromDate: this.dates.fromDate,
|
||||||
|
toDate: this.dates.toDate,
|
||||||
|
offset: this.pageIndex * this.pageSize,
|
||||||
|
limit: this.pageSize
|
||||||
|
})
|
||||||
|
.subscribe({
|
||||||
|
next: (page) => {
|
||||||
|
this.rows = page.pageItems;
|
||||||
|
this.total = page.totalFilteredRecords;
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.error = i4cErrorMessage(e);
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setRole(r: string): void {
|
||||||
|
this.role = r;
|
||||||
|
this.load(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
onPage(e: PageEvent): void {
|
||||||
|
this.pageIndex = e.pageIndex;
|
||||||
|
this.pageSize = e.pageSize;
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
onDates(range: I4cDateRange): void {
|
||||||
|
this.dates = range;
|
||||||
|
this.load(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasFilters(): boolean {
|
||||||
|
return !!(this.status !== 'OPEN' || this.bankAction || this.search || this.dates.preset !== 'ALL');
|
||||||
|
}
|
||||||
|
|
||||||
|
clearFilters(): void {
|
||||||
|
this.status = 'OPEN';
|
||||||
|
this.bankAction = '';
|
||||||
|
this.search = '';
|
||||||
|
this.dates = { ...ALL_DATES };
|
||||||
|
this.load(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
openVictimCase(): void {
|
||||||
|
if (!this.victimAck.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.i4c.openVictimCase(this.victimAck.trim()).subscribe({
|
||||||
|
next: (r) => {
|
||||||
|
this.victimAck = '';
|
||||||
|
this.role = 'VICTIM_BANK';
|
||||||
|
this.load(true);
|
||||||
|
this.open(r.resourceId);
|
||||||
|
},
|
||||||
|
error: (e) => this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 7000 })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
open(id: number): void {
|
||||||
|
this.i4c.restoration(id).subscribe({
|
||||||
|
next: (r) => {
|
||||||
|
const same = this.selected?.id === r.id;
|
||||||
|
this.selected = r;
|
||||||
|
if (!same) {
|
||||||
|
this.tab = 'workflow';
|
||||||
|
this.proRata = null;
|
||||||
|
this.assessment = {
|
||||||
|
basis: r.basis || 'ATTRIBUTION',
|
||||||
|
restorableAmount: r.restorable_amount ?? r.amount_held,
|
||||||
|
remarks: r.basis_remarks || ''
|
||||||
|
};
|
||||||
|
this.request = {
|
||||||
|
mrmRequestNo: r.mrm_request_no || '',
|
||||||
|
victimName: r.victim_name || '',
|
||||||
|
victimAccountNo: r.victim_account_no || '',
|
||||||
|
victimIfsc: r.victim_ifsc || '',
|
||||||
|
victimBankName: r.victim_bank_name || '',
|
||||||
|
transferChannel: r.transfer_channel && r.transfer_channel !== 'INTERNAL' ? r.transfer_channel : 'NEFT',
|
||||||
|
ioName: r.io_name || '',
|
||||||
|
ioPoliceStation: r.io_police_station || '',
|
||||||
|
firNo: r.fir_no || '',
|
||||||
|
firDate: r.fir_date || '',
|
||||||
|
courtOrderNo: r.court_order_no || '',
|
||||||
|
courtOrderDate: r.court_order_date || '',
|
||||||
|
courtName: r.court_name || '',
|
||||||
|
bnssNoticeNo: r.bnss_notice_no || '',
|
||||||
|
bnssNoticeDate: r.bnss_notice_date || '',
|
||||||
|
indemnityBondReceived: !!r.indemnity_bond_received
|
||||||
|
};
|
||||||
|
this.action = { action: 'COMPLIED', reason: '' };
|
||||||
|
this.receipt = { amount: null, utr: '', receivedOn: '', accountNo: r.victim_account_no || '' };
|
||||||
|
this.victim = { status: 'ALREADY_REFUNDED_BANK', remarks: '' };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (e) => this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.selected = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
label(list: { value: string; label: string }[], v: string): string {
|
||||||
|
return list.find((x) => x.value === v)?.label || v || '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
get isVictim(): boolean {
|
||||||
|
return this.selected?.role === 'VICTIM_BANK';
|
||||||
|
}
|
||||||
|
|
||||||
|
get possible(): boolean {
|
||||||
|
return this.assessment.basis === 'ATTRIBUTION' || this.assessment.basis === 'PRO_RATA';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Papers MRM needs for compliance, given what is recorded; mirrors the server's check. */
|
||||||
|
get papers(): string[] {
|
||||||
|
const r = this.selected;
|
||||||
|
if (!r) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const has = (t: string) => (r.documents || []).some((d: any) => d.doc_type === t);
|
||||||
|
const out: string[] = [];
|
||||||
|
if (r.court_order_no) {
|
||||||
|
if (!has('COURT_ORDER')) {
|
||||||
|
out.push('court order copy');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!r.bnss_notice_no) {
|
||||||
|
out.push('BNSS 106 notice no.');
|
||||||
|
} else if (!has('BNSS_106_NOTICE')) {
|
||||||
|
out.push('BNSS 106 notice copy');
|
||||||
|
}
|
||||||
|
if (!r.indemnity_bond_received) {
|
||||||
|
out.push('indemnity bond received');
|
||||||
|
} else if (!has('INDEMNITY_BOND')) {
|
||||||
|
out.push('indemnity bond copy');
|
||||||
|
}
|
||||||
|
if (Number(r.restorable_amount) > Number(r.firThreshold)) {
|
||||||
|
if (!r.fir_no) {
|
||||||
|
out.push('FIR no. (above ₹' + r.firThreshold + ')');
|
||||||
|
} else if (!has('FIR')) {
|
||||||
|
out.push('FIR copy');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private run(obs: any, done: string): void {
|
||||||
|
this.busy = true;
|
||||||
|
obs.subscribe({
|
||||||
|
next: (r: any) => {
|
||||||
|
this.busy = false;
|
||||||
|
this.snackBar.open(done, 'OK', { duration: 5000 });
|
||||||
|
this.open(r?.id ?? r?.resourceId ?? this.selected.id);
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
error: (e: any) => {
|
||||||
|
this.busy = false;
|
||||||
|
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 8000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
saveAssessment(): void {
|
||||||
|
this.run(
|
||||||
|
this.i4c.assessRestoration(this.selected.hold_id, {
|
||||||
|
basis: this.assessment.basis,
|
||||||
|
restorableAmount: this.possible ? this.assessment.restorableAmount : null,
|
||||||
|
remarks: this.assessment.remarks
|
||||||
|
}),
|
||||||
|
'Assessment saved. Enter the same on MRM (Money Distribution Process).'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveRequest(): void {
|
||||||
|
this.run(this.i4c.recordRestorationRequest(this.selected.id, this.request), 'Victim request recorded.');
|
||||||
|
}
|
||||||
|
|
||||||
|
takeAction(): void {
|
||||||
|
this.run(
|
||||||
|
this.i4c.restorationStep(this.selected.id, 'bank-action', this.action),
|
||||||
|
this.action.action === 'COMPLIED'
|
||||||
|
? 'Restoration raised. A second officer approves it on the Approvals page; the money then moves.'
|
||||||
|
: 'Bank action recorded. Enter the same on MRM.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
retryOutward(): void {
|
||||||
|
this.run(this.i4c.restorationStep(this.selected.id, 'retry-outward'), 'Outward transfer sent again.');
|
||||||
|
}
|
||||||
|
|
||||||
|
markMrm(): void {
|
||||||
|
this.run(this.i4c.restorationStep(this.selected.id, 'mrm-updated'), 'Marked as entered on MRM.');
|
||||||
|
}
|
||||||
|
|
||||||
|
saveReceipt(): void {
|
||||||
|
this.run(this.i4c.restorationStep(this.selected.id, 'receipt', this.receipt), 'Receipt recorded.');
|
||||||
|
}
|
||||||
|
|
||||||
|
saveVictimStatus(): void {
|
||||||
|
this.run(
|
||||||
|
this.i4c.restorationStep(this.selected.id, 'victim-status', this.victim),
|
||||||
|
'Victim status recorded; case closed.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadProRata(): void {
|
||||||
|
this.i4c.proRata(this.selected.hold_account_no).subscribe({
|
||||||
|
next: (p) => {
|
||||||
|
this.proRata = p;
|
||||||
|
const mine = (p.holds || []).find((h: any) => h.id === this.selected.hold_id);
|
||||||
|
if (mine) {
|
||||||
|
this.assessment.basis = 'PRO_RATA';
|
||||||
|
this.assessment.restorableAmount = Number(mine.share);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (e) => this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
<!--
|
||||||
|
Copyright since 2025 Mifos Initiative
|
||||||
|
|
||||||
|
This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<div class="i4c-shell">
|
||||||
|
<!-- Modern Navigation Bar with Live Status and Quick Actions -->
|
||||||
|
<header class="i4c-navbar">
|
||||||
|
<div class="i4c-navbar__brand">
|
||||||
|
<div class="i4c-navbar__icon-wrap">
|
||||||
|
<fa-icon icon="shield-alt"></fa-icon>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="i4c-navbar__title">
|
||||||
|
<span>I4C Portal</span>
|
||||||
|
<span class="live-pill">
|
||||||
|
<span class="live-pill__dot"></span>
|
||||||
|
Live MHA Gateway
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="i4c-navbar__sub">National Cyber Crime Reporting & Account Holds System</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Actions -->
|
||||||
|
<div class="i4c-navbar__actions">
|
||||||
|
<button class="quick-lookup-btn" (click)="openLookup()">
|
||||||
|
<fa-icon icon="search"></fa-icon>
|
||||||
|
<span>Quick Lookup</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Modern Pill-Style Subnavigation Bar -->
|
||||||
|
<nav class="i4c-subnav">
|
||||||
|
<div class="i4c-subnav__links">
|
||||||
|
@for (link of links; track link.path) {
|
||||||
|
<a class="i4c-subnav__link" [routerLink]="['./', link.path]" routerLinkActive="i4c-subnav__link--active">
|
||||||
|
<fa-icon [icon]="link.icon" class="link-ic"></fa-icon>
|
||||||
|
<span>{{ link.label }}</span>
|
||||||
|
@if (link.path === 'approvals' && pendingApprovals > 0) {
|
||||||
|
<span class="pill-badge pill-badge--amber">{{ pendingApprovals }}</span>
|
||||||
|
}
|
||||||
|
@if (link.path === 'holds' && activeHolds > 0) {
|
||||||
|
<span class="pill-badge pill-badge--blue">{{ activeHolds }}</span>
|
||||||
|
}
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Outlet for child views -->
|
||||||
|
<main class="i4c-content">
|
||||||
|
<router-outlet></router-outlet>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- ========================================================
|
||||||
|
MODERN POPUP WINDOW: QUICK LOOKUP MODAL
|
||||||
|
======================================================== -->
|
||||||
|
@if (showLookupModal) {
|
||||||
|
<div class="i4c-modal-backdrop" (click)="closeLookup()">
|
||||||
|
<div class="i4c-modal-window i4c-modal-window--wide" (click)="$event.stopPropagation()">
|
||||||
|
<div class="i4c-modal-header">
|
||||||
|
<div class="i4c-modal-header__title">
|
||||||
|
<fa-icon icon="search"></fa-icon>
|
||||||
|
<div>
|
||||||
|
<span>Quick Search & Lookup</span>
|
||||||
|
<div class="i4c-modal-header__sub">Instant search across I4C Complaints and Account Holds Register</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="modal-close-btn" (click)="closeLookup()" aria-label="Close">
|
||||||
|
<fa-icon icon="times"></fa-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-modal-body">
|
||||||
|
<div class="lookup-search-box">
|
||||||
|
<div class="search-input-wrap">
|
||||||
|
<fa-icon icon="search" class="search-icon"></fa-icon>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="modern-input"
|
||||||
|
placeholder="Enter acknowledgement no., customer account no., or job ID..."
|
||||||
|
[(ngModel)]="lookupQuery"
|
||||||
|
(keyup.enter)="performLookup()"
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
@if (lookupQuery) {
|
||||||
|
<button class="clear-btn" (click)="lookupQuery = ''">
|
||||||
|
<fa-icon icon="times"></fa-icon>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="search-submit-btn"
|
||||||
|
(click)="performLookup()"
|
||||||
|
[disabled]="lookupLoading || !lookupQuery.trim()"
|
||||||
|
>
|
||||||
|
@if (lookupLoading) {
|
||||||
|
<fa-icon icon="sync" animation="spin"></fa-icon>
|
||||||
|
<span>Searching...</span>
|
||||||
|
} @else {
|
||||||
|
<fa-icon icon="search"></fa-icon>
|
||||||
|
<span>Search</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (lookupError) {
|
||||||
|
<div class="i4c-error">{{ lookupError }}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Search Results -->
|
||||||
|
@if (lookupSearched) {
|
||||||
|
<div class="lookup-results">
|
||||||
|
<!-- Complaints Section -->
|
||||||
|
<div class="results-section">
|
||||||
|
<div class="results-section__head">
|
||||||
|
<fa-icon icon="inbox"></fa-icon>
|
||||||
|
<span>Complaints from I4C</span>
|
||||||
|
<span class="count-badge">{{ lookupResults.complaints.length }}</span>
|
||||||
|
</div>
|
||||||
|
@if (lookupResults.complaints.length > 0) {
|
||||||
|
<div class="results-list">
|
||||||
|
@for (c of lookupResults.complaints; track c.id) {
|
||||||
|
<div class="result-item" (click)="goToComplaint(c.id)">
|
||||||
|
<div class="result-item__main">
|
||||||
|
<div class="result-item__title">
|
||||||
|
<span class="ack-code">{{ c.acknowledgement_no }}</span>
|
||||||
|
<span class="status-pill status-pill--{{ c.status?.toLowerCase() }}">{{ c.status }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="result-item__sub">
|
||||||
|
<span
|
||||||
|
>Account: <b>{{ c.payer_account_number }}</b></span
|
||||||
|
>
|
||||||
|
<span>•</span>
|
||||||
|
<span>Job: {{ c.job_id }}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>Sub-cat: {{ c.sub_category }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="result-item__side">
|
||||||
|
<span class="held-amt">Held: ₹{{ c.amount_held | number: '1.2-2' }}</span>
|
||||||
|
<span class="open-link">Open Complaint <fa-icon icon="arrow-right"></fa-icon></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="no-res">No complaints matched "{{ lookupQuery }}".</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Holds Section -->
|
||||||
|
<div class="results-section">
|
||||||
|
<div class="results-section__head">
|
||||||
|
<fa-icon icon="lock"></fa-icon>
|
||||||
|
<span>Holds Register</span>
|
||||||
|
<span class="count-badge">{{ lookupResults.holds.length }}</span>
|
||||||
|
</div>
|
||||||
|
@if (lookupResults.holds.length > 0) {
|
||||||
|
<div class="results-list">
|
||||||
|
@for (h of lookupResults.holds; track h.id) {
|
||||||
|
<div class="result-item" (click)="goToHold(h.account_no)">
|
||||||
|
<div class="result-item__main">
|
||||||
|
<div class="result-item__title">
|
||||||
|
<span class="ack-code">Account: {{ h.account_no }}</span>
|
||||||
|
<span class="status-pill status-pill--{{ h.status?.toLowerCase() }}">{{ h.status }}</span>
|
||||||
|
@if (h.debit_frozen) {
|
||||||
|
<span class="freeze-tag">DEBIT FROZEN</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="result-item__sub">
|
||||||
|
<span
|
||||||
|
>Customer: <b>{{ h.client_name || 'N/A' }}</b></span
|
||||||
|
>
|
||||||
|
<span>•</span>
|
||||||
|
<span>Ack: {{ h.acknowledgement_no }}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>Layer: {{ h.layer }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="result-item__side">
|
||||||
|
<span class="held-amt">Held: ₹{{ h.amount_held | number: '1.2-2' }}</span>
|
||||||
|
<span class="open-link">View in Holds <fa-icon icon="arrow-right"></fa-icon></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="no-res">No holds matched "{{ lookupQuery }}".</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="lookup-hint">
|
||||||
|
<fa-icon icon="lightbulb" class="hint-icon"></fa-icon>
|
||||||
|
<span
|
||||||
|
>Type a 14-digit acknowledgement number, customer bank account number, or job ID to quickly locate
|
||||||
|
complaints or hold records.</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-modal-footer">
|
||||||
|
<button class="footer-btn-close" (click)="closeLookup()">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,473 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@use '../i4c' as *;
|
||||||
|
|
||||||
|
@include i4c-skin;
|
||||||
|
|
||||||
|
.i4c-shell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modern Top Header / Navbar */
|
||||||
|
.i4c-navbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: 0 10px 25px -5px rgb(15 23 42 / 20%);
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
&__brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__icon-wrap {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: linear-gradient(135deg, #2563eb, #3b82f6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 15px;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 4px 10px rgb(37 99 235 / 35%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__sub {
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: rgb(16 185 129 / 15%);
|
||||||
|
color: #34d399;
|
||||||
|
border: 1px solid rgb(16 185 129 / 30%);
|
||||||
|
|
||||||
|
&__dot {
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #10b981;
|
||||||
|
box-shadow: 0 0 6px #10b981;
|
||||||
|
animation: pulse-dot 1.8s infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-lookup-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 5px 12px;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: rgb(255 255 255 / 10%);
|
||||||
|
color: #fff;
|
||||||
|
border: 1px solid rgb(255 255 255 / 18%);
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgb(255 255 255 / 20%);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 10px rgb(0 0 0 / 15%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kbd-shortcut {
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgb(255 255 255 / 15%);
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #93c5fd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modern Pill Navigation Bar */
|
||||||
|
.i4c-subnav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 8px rgb(15 23 42 / 3%);
|
||||||
|
overflow-x: auto;
|
||||||
|
|
||||||
|
&__links {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #475569;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
.link-ic {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #0f172a;
|
||||||
|
background: #f1f5f9;
|
||||||
|
|
||||||
|
.link-ic {
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--active {
|
||||||
|
background: #2563eb;
|
||||||
|
color: #fff !important;
|
||||||
|
box-shadow: 0 3px 10px rgb(37 99 235 / 28%);
|
||||||
|
|
||||||
|
.link-ic {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill-badge {
|
||||||
|
background: rgb(255 255 255 / 25%);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill-badge {
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 9.5px;
|
||||||
|
font-weight: 800;
|
||||||
|
|
||||||
|
&--amber {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--blue {
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Quick Lookup Popup Modal styling */
|
||||||
|
.lookup-search-box {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.search-input-wrap {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.search-icon {
|
||||||
|
position: absolute;
|
||||||
|
left: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modern-input {
|
||||||
|
width: 100%;
|
||||||
|
height: 38px;
|
||||||
|
padding: 0 32px 0 34px;
|
||||||
|
border-radius: 9px;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
background: #f8fafc;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #0f172a;
|
||||||
|
outline: none;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
background: #fff;
|
||||||
|
border-color: #2563eb;
|
||||||
|
box-shadow: 0 0 0 3px rgb(37 99 235 / 15%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-btn {
|
||||||
|
position: absolute;
|
||||||
|
right: 10px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #94a3b8;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-submit-btn {
|
||||||
|
height: 38px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #2563eb;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: #1d4ed8;
|
||||||
|
box-shadow: 0 3px 10px rgb(37 99 235 / 30%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.lookup-hint {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px dashed #cbd5e1;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 11.5px;
|
||||||
|
|
||||||
|
.hint-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.lookup-results {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-section {
|
||||||
|
&__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1e293b;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 7px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: #e2e8f0;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #eff6ff;
|
||||||
|
border-color: #93c5fd;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
.ack-code {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__sub {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__side {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
.held-amt {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.open-link {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #2563eb;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-res {
|
||||||
|
padding: 16px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #475569;
|
||||||
|
|
||||||
|
&--processed,
|
||||||
|
&--active {
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--retry,
|
||||||
|
&--failed {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--processing,
|
||||||
|
&--received {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.freeze-tag {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-btn-close {
|
||||||
|
padding: 8px 18px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #334155;
|
||||||
|
border: none;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #cbd5e1;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-dot {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
opacity: 0.4;
|
||||||
|
transform: scale(0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
|
import { Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
import { I4cService, i4cErrorMessage } from '../i4c.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The live I4C (NCRP-CFCFRMS) integration: complaints I4C pushed to the bank, the holds placed for them, the
|
||||||
|
* approvals to release them, and every response sent back. Backed by /v1/i4c/admin on the CBS, never by the
|
||||||
|
* CFCFRMS demo mock.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-i4c-shell',
|
||||||
|
templateUrl: './i4c-shell.component.html',
|
||||||
|
styleUrls: ['./i4c-shell.component.scss'],
|
||||||
|
imports: [
|
||||||
|
FormsModule,
|
||||||
|
RouterLink,
|
||||||
|
RouterLinkActive,
|
||||||
|
RouterOutlet,
|
||||||
|
MatButtonModule,
|
||||||
|
FaIconComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class I4cShellComponent implements OnInit {
|
||||||
|
private i4c = inject(I4cService);
|
||||||
|
private router = inject(Router);
|
||||||
|
|
||||||
|
readonly links = [
|
||||||
|
{ path: 'dashboard', label: 'Dashboard', icon: 'chart-line' },
|
||||||
|
{ path: 'requests', label: 'Complaints from I4C', icon: 'inbox' },
|
||||||
|
{ path: 'holds', label: 'Holds Register', icon: 'lock' },
|
||||||
|
{ path: 'approvals', label: 'Approvals', icon: 'user-check' },
|
||||||
|
{ path: 'grievances', label: 'GRM Grievances', icon: 'balance-scale' },
|
||||||
|
{ path: 'restorations', label: 'MRM Restorations', icon: 'hand-holding-usd' },
|
||||||
|
{ path: 'mrm-report', label: 'MRM Balance Report', icon: 'file-csv' },
|
||||||
|
{ path: 'outbox', label: 'Responses & API Log', icon: 'paper-plane' },
|
||||||
|
{ path: 'bank-master', label: 'Bank Code Master', icon: 'university' },
|
||||||
|
{ path: 'intimation-settings', label: 'Intimation Settings', icon: 'cog' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Quick lookup popup modal state
|
||||||
|
showLookupModal = false;
|
||||||
|
lookupQuery = '';
|
||||||
|
lookupLoading = false;
|
||||||
|
lookupResults: { complaints: any[]; holds: any[] } = { complaints: [], holds: [] };
|
||||||
|
lookupSearched = false;
|
||||||
|
lookupError = '';
|
||||||
|
|
||||||
|
// Summary counts for pill badges
|
||||||
|
pendingApprovals = 0;
|
||||||
|
activeHolds = 0;
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.refreshSummary();
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshSummary(): void {
|
||||||
|
this.i4c.dashboard().subscribe({
|
||||||
|
next: (d) => {
|
||||||
|
this.pendingApprovals = d?.pendingApprovals || 0;
|
||||||
|
this.activeHolds = d?.activeHolds || 0;
|
||||||
|
},
|
||||||
|
error: () => {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
openLookup(): void {
|
||||||
|
this.showLookupModal = true;
|
||||||
|
this.lookupQuery = '';
|
||||||
|
this.lookupResults = { complaints: [], holds: [] };
|
||||||
|
this.lookupSearched = false;
|
||||||
|
this.lookupError = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
closeLookup(): void {
|
||||||
|
this.showLookupModal = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
performLookup(): void {
|
||||||
|
const q = this.lookupQuery.trim();
|
||||||
|
if (!q) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.lookupLoading = true;
|
||||||
|
this.lookupSearched = true;
|
||||||
|
this.lookupError = '';
|
||||||
|
this.lookupResults = { complaints: [], holds: [] };
|
||||||
|
|
||||||
|
let completed = 0;
|
||||||
|
const checkDone = () => {
|
||||||
|
completed++;
|
||||||
|
if (completed >= 2) {
|
||||||
|
this.lookupLoading = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.i4c.requests({ search: q, limit: 10 }).subscribe({
|
||||||
|
next: (rows) => {
|
||||||
|
this.lookupResults.complaints = rows?.pageItems || [];
|
||||||
|
checkDone();
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.lookupError = i4cErrorMessage(e);
|
||||||
|
checkDone();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.i4c.holds({ search: q, limit: 10 }).subscribe({
|
||||||
|
next: (rows) => {
|
||||||
|
this.lookupResults.holds = rows?.pageItems || [];
|
||||||
|
checkDone();
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
checkDone();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
goToComplaint(id: number): void {
|
||||||
|
this.closeLookup();
|
||||||
|
this.router.navigate([
|
||||||
|
'/cfcfrms/i4c/requests',
|
||||||
|
id
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
goToHold(accountNo: string): void {
|
||||||
|
this.closeLookup();
|
||||||
|
this.router.navigate(['/cfcfrms/i4c/holds'], { queryParams: { view: '' } });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
|
/** A hold the bank placed for an I4C complaint (m_i4c_hold). */
|
||||||
|
export interface I4cHold {
|
||||||
|
id: number;
|
||||||
|
request_id: number;
|
||||||
|
incident_id: number;
|
||||||
|
acknowledgement_no: string;
|
||||||
|
account_type: 'SAVINGS' | 'CURRENT';
|
||||||
|
account_id: number;
|
||||||
|
account_no: string;
|
||||||
|
client_id?: number;
|
||||||
|
client_name?: string;
|
||||||
|
office_name?: string;
|
||||||
|
layer: number;
|
||||||
|
disputed_amount: number;
|
||||||
|
amount_held: number;
|
||||||
|
shortfall: number;
|
||||||
|
status: 'ACTIVE' | 'RELEASED' | 'NOT_HOLDABLE';
|
||||||
|
debit_frozen: boolean;
|
||||||
|
held_at: string;
|
||||||
|
review_due_on: string;
|
||||||
|
intimation_due_by?: string;
|
||||||
|
customer_intimated_at?: string;
|
||||||
|
intimation_mode?: string;
|
||||||
|
intimation_reference?: string;
|
||||||
|
intimated_by?: string;
|
||||||
|
released_at?: string;
|
||||||
|
release_reason_type?: string;
|
||||||
|
release_reference?: string;
|
||||||
|
pending_actions?: number;
|
||||||
|
transactions?: any[];
|
||||||
|
actions?: any[];
|
||||||
|
/** Every notice sent or recorded for this hold, newest first. */
|
||||||
|
intimationLog?: IntimationLogEntry[];
|
||||||
|
/** Channels switched on for automatic intimation, in the order they are tried. */
|
||||||
|
automaticChannels?: string[];
|
||||||
|
grievances?: any[];
|
||||||
|
restorations?: any[];
|
||||||
|
documents?: I4cDocument[];
|
||||||
|
released_amount?: number;
|
||||||
|
restored_amount?: number;
|
||||||
|
auto_intimation_attempts?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IntimationLogEntry {
|
||||||
|
id: number;
|
||||||
|
channel: string;
|
||||||
|
mode: 'AUTO' | 'MANUAL' | 'RESEND';
|
||||||
|
status: string;
|
||||||
|
destination_masked?: string;
|
||||||
|
provider?: string;
|
||||||
|
provider_reference?: string;
|
||||||
|
failure_reason?: string;
|
||||||
|
actor?: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An automatic customer-intimation channel (m_i4c_intimation_channel). */
|
||||||
|
export interface IntimationChannel {
|
||||||
|
channel: 'EMAIL' | 'SMS' | 'WHATSAPP';
|
||||||
|
enabled: boolean;
|
||||||
|
priority: number;
|
||||||
|
subject?: string;
|
||||||
|
body: string;
|
||||||
|
updated_by?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IntimationSettings {
|
||||||
|
channels: IntimationChannel[];
|
||||||
|
placeholders: string[];
|
||||||
|
manualChannels: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IntimationResult {
|
||||||
|
outcome: 'SENT' | 'NO_CHANNEL_ENABLED' | 'NOT_DELIVERED' | 'ALREADY_INTIMATED' | 'NOTHING_HELD';
|
||||||
|
channel?: string;
|
||||||
|
destination?: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type I4cDocumentOwner = 'HOLD' | 'GRIEVANCE' | 'RESTORATION';
|
||||||
|
|
||||||
|
/** A document attached to a hold, grievance or restoration (content downloaded separately). */
|
||||||
|
export interface I4cDocument {
|
||||||
|
id: number;
|
||||||
|
doc_type: string;
|
||||||
|
file_name: string;
|
||||||
|
content_type: string;
|
||||||
|
size_bytes: number;
|
||||||
|
sha256: string;
|
||||||
|
remarks?: string;
|
||||||
|
uploaded_by: string;
|
||||||
|
uploaded_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DOCUMENT_TYPES: { value: string; label: string }[] = [
|
||||||
|
{ value: 'IO_ORDER', label: 'IO order' },
|
||||||
|
{ value: 'GRM_ORDER', label: 'GRM order (DGRO / SGRO)' },
|
||||||
|
{ value: 'COURT_ORDER', label: 'Court order' },
|
||||||
|
{ value: 'BNSS_106_NOTICE', label: 'Notice u/s 106 BNSS' },
|
||||||
|
{ value: 'INDEMNITY_BOND', label: 'Indemnity bond' },
|
||||||
|
{ value: 'FIR', label: 'FIR copy' },
|
||||||
|
{ value: 'KYC', label: 'KYC document' },
|
||||||
|
{ value: 'APPLICATION', label: "Customer's application" },
|
||||||
|
{ value: 'PHOTO', label: 'Photograph' },
|
||||||
|
{ value: 'BANK_STATEMENT', label: 'Bank statement' },
|
||||||
|
{ value: 'EDD_REPORT', label: 'EDD report' },
|
||||||
|
{ value: 'PAN', label: 'PAN card' },
|
||||||
|
{ value: 'OTHER', label: 'Other' }
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Save a downloaded file (CSV, document) under the given name. */
|
||||||
|
export function saveBlob(blob: Blob, fileName: string): void {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = fileName;
|
||||||
|
a.click();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One page of an officer list, as every /i4c/admin list endpoint returns it. */
|
||||||
|
export interface I4cPage<T> {
|
||||||
|
totalFilteredRecords: number;
|
||||||
|
pageItems: T[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Query-string filters of a list; empty values are left out of the request. */
|
||||||
|
export type I4cListParams = Record<string, string | number | boolean | null | undefined>;
|
||||||
|
|
||||||
|
export interface HoldActionRequest {
|
||||||
|
action: 'RELEASE' | 'FREEZE' | 'UNFREEZE' | 'UNFREEZE_AND_RELEASE';
|
||||||
|
/** RELEASE only: how much to release; empty releases all. */
|
||||||
|
amount?: number | null;
|
||||||
|
reasonType: string;
|
||||||
|
orderReference?: string;
|
||||||
|
orderDate?: string;
|
||||||
|
authority?: string;
|
||||||
|
remarks?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HOLD_REASON_TYPES: { value: string; label: string }[] = [
|
||||||
|
{ value: 'IO_ORDER', label: 'Investigating Officer order' },
|
||||||
|
{ value: 'GRM_DECISION', label: 'GRM decision (IO / DGRO / SGRO)' },
|
||||||
|
{ value: 'MRM_RESTORATION', label: 'MRM money restoration' },
|
||||||
|
{ value: 'COURT_ORDER', label: 'Court order' },
|
||||||
|
{ value: 'LEA_DIRECTION', label: 'LEA direction' },
|
||||||
|
{ value: 'REVIEW_90_DAYS', label: '90-day review (no direction received)' },
|
||||||
|
{ value: 'WRONG_ACCOUNT', label: 'Wrong account reported' },
|
||||||
|
{ value: 'OTHER', label: 'Other' }
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Officer API of the I4C (NCRP-CFCFRMS) integration: /v1/i4c/admin/* on the CBS backend. */
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class I4cService {
|
||||||
|
private http = inject(HttpClient);
|
||||||
|
private base = '/i4c/admin';
|
||||||
|
private cases = '/i4c/cases';
|
||||||
|
|
||||||
|
dashboard(): Observable<any> {
|
||||||
|
return this.http.get<any>(`${this.base}/dashboard`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Filters: status, search, subCategory, layer, response, fromDate, toDate, offset, limit. */
|
||||||
|
requests(query: I4cListParams = {}): Observable<I4cPage<any>> {
|
||||||
|
return this.http.get<I4cPage<any>>(`${this.base}/requests`, { params: this.params(query) });
|
||||||
|
}
|
||||||
|
|
||||||
|
request(id: number): Observable<any> {
|
||||||
|
return this.http.get<any>(`${this.base}/requests/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
reprocess(id: number): Observable<any> {
|
||||||
|
return this.http.post<any>(`${this.base}/requests/${id}/reprocess`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Filters: view, search, accountType, intimation, frozen, layer, fromDate, toDate, offset, limit. */
|
||||||
|
holds(query: I4cListParams = {}): Observable<I4cPage<I4cHold>> {
|
||||||
|
return this.http.get<I4cPage<I4cHold>>(`${this.base}/holds`, { params: this.params(query) });
|
||||||
|
}
|
||||||
|
|
||||||
|
hold(id: number): Observable<I4cHold> {
|
||||||
|
return this.http.get<I4cHold>(`${this.base}/holds/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
requestHoldAction(holdId: number, body: HoldActionRequest): Observable<any> {
|
||||||
|
return this.http.post<any>(`${this.base}/holds/${holdId}/actions`, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
recordIntimation(holdId: number, mode: string, reference?: string): Observable<any> {
|
||||||
|
return this.http.post<any>(`${this.base}/holds/${holdId}/intimation`, { mode, reference });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Filters: status, action, reasonType, search, fromDate, toDate, offset, limit. */
|
||||||
|
holdActions(query: I4cListParams = {}): Observable<I4cPage<any>> {
|
||||||
|
return this.http.get<I4cPage<any>>(`${this.base}/hold-actions`, { params: this.params(query) });
|
||||||
|
}
|
||||||
|
|
||||||
|
approveHoldAction(id: number): Observable<any> {
|
||||||
|
return this.http.post<any>(`${this.base}/hold-actions/${id}/approve`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
rejectHoldAction(id: number, reason: string): Observable<any> {
|
||||||
|
return this.http.post<any>(`${this.base}/hold-actions/${id}/reject`, { reason });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Filters: status, type (ACK / RESPONSE), search, fromDate, toDate, offset, limit. */
|
||||||
|
outbox(query: I4cListParams = {}): Observable<I4cPage<any>> {
|
||||||
|
return this.http.get<I4cPage<any>>(`${this.base}/outbox`, { params: this.params(query) });
|
||||||
|
}
|
||||||
|
|
||||||
|
retryOutbox(id: number): Observable<any> {
|
||||||
|
return this.http.post<any>(`${this.base}/outbox/${id}/retry`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Filters: direction, failures, httpStatus, responseCode, search, fromDate, toDate, offset, limit. */
|
||||||
|
apiLog(query: I4cListParams = {}): Observable<I4cPage<any>> {
|
||||||
|
return this.http.get<I4cPage<any>>(`${this.base}/api-log`, { params: this.params(query) });
|
||||||
|
}
|
||||||
|
|
||||||
|
apiLogEntry(id: number): Observable<any> {
|
||||||
|
return this.http.get<any>(`${this.base}/api-log/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send the hold notice now on the switched-on automatic channels. */
|
||||||
|
sendIntimation(holdId: number): Observable<IntimationResult> {
|
||||||
|
return this.http.post<IntimationResult>(`${this.base}/holds/${holdId}/intimation/send`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
intimationChannels(): Observable<IntimationSettings> {
|
||||||
|
return this.http.get<IntimationSettings>(`${this.base}/intimation-channels`);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateIntimationChannel(
|
||||||
|
channel: string,
|
||||||
|
body: { enabled?: boolean; priority?: number; subject?: string; body?: string }
|
||||||
|
): Observable<IntimationChannel[]> {
|
||||||
|
return this.http.put<IntimationChannel[]>(`${this.base}/intimation-channels/${channel}`, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
testIntimationChannel(channel: string, to: string): Observable<IntimationResult> {
|
||||||
|
return this.http.post<IntimationResult>(`${this.base}/intimation-channels/${channel}/test`, { to });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Filters: search, type, ifsc (KNOWN / UNKNOWN), offset, limit. */
|
||||||
|
bankMaster(query: I4cListParams = {}): Observable<I4cPage<any>> {
|
||||||
|
return this.http.get<I4cPage<any>>(`${this.base}/bank-master`, { params: this.params(query) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GRM / MRM (/v1/i4c/cases) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
grievances(query: I4cListParams = {}): Observable<I4cPage<any>> {
|
||||||
|
return this.http.get<I4cPage<any>>(`${this.cases}/grievances`, { params: this.params(query) });
|
||||||
|
}
|
||||||
|
|
||||||
|
grievance(id: number): Observable<any> {
|
||||||
|
return this.http.get<any>(`${this.cases}/grievances/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
createGrievance(
|
||||||
|
holdId: number,
|
||||||
|
body: { customerRequest: string; customerReason?: string }
|
||||||
|
): Observable<{ resourceId: number }> {
|
||||||
|
return this.http.post<{ resourceId: number }>(`${this.cases}/holds/${holdId}/grievances`, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** step: edd, submit, nodal, events, investigation, decision, escalate, withdraw */
|
||||||
|
grievanceStep(id: number, step: string, body: Record<string, any>): Observable<any> {
|
||||||
|
return this.http.post<any>(`${this.cases}/grievances/${id}/${step}`, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
raiseGrievanceAction(id: number): Observable<{ resourceId: number; actionRequestId: number | null }> {
|
||||||
|
return this.http.post<{ resourceId: number; actionRequestId: number | null }>(
|
||||||
|
`${this.cases}/grievances/${id}/action`,
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
restorations(query: I4cListParams = {}): Observable<I4cPage<any>> {
|
||||||
|
return this.http.get<I4cPage<any>>(`${this.cases}/restorations`, { params: this.params(query) });
|
||||||
|
}
|
||||||
|
|
||||||
|
restoration(id: number): Observable<any> {
|
||||||
|
return this.http.get<any>(`${this.cases}/restorations/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
assessRestoration(
|
||||||
|
holdId: number,
|
||||||
|
body: { basis: string; restorableAmount?: number | null; remarks?: string }
|
||||||
|
): Observable<{ resourceId: number }> {
|
||||||
|
return this.http.post<{ resourceId: number }>(`${this.cases}/holds/${holdId}/restorations`, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
recordRestorationRequest(id: number, body: Record<string, any>): Observable<any> {
|
||||||
|
return this.http.put<any>(`${this.cases}/restorations/${id}/request`, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** step: bank-action, retry-outward, mrm-updated, receipt, victim-status */
|
||||||
|
restorationStep(id: number, step: string, body: Record<string, any> = {}): Observable<any> {
|
||||||
|
return this.http.post<any>(`${this.cases}/restorations/${id}/${step}`, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
openVictimCase(acknowledgementNo: string): Observable<{ resourceId: number }> {
|
||||||
|
return this.http.post<{ resourceId: number }>(`${this.cases}/victim-cases`, { acknowledgementNo });
|
||||||
|
}
|
||||||
|
|
||||||
|
proRata(accountNo: string, amount?: number | null): Observable<any> {
|
||||||
|
return this.http.get<any>(`${this.cases}/pro-rata`, { params: this.params({ accountNo, amount }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
mrmBalanceReport(): Observable<any[]> {
|
||||||
|
return this.http.get<any[]>(`${this.cases}/mrm-balance-report`);
|
||||||
|
}
|
||||||
|
|
||||||
|
mrmBalanceReportCsv(): Observable<Blob> {
|
||||||
|
return this.http.get(`${this.cases}/mrm-balance-report.csv`, { responseType: 'blob' });
|
||||||
|
}
|
||||||
|
|
||||||
|
statementCsv(holdId: number, fromDate?: string, toDate?: string): Observable<Blob> {
|
||||||
|
return this.http.get(`${this.cases}/holds/${holdId}/statement.csv`, {
|
||||||
|
params: this.params({ fromDate, toDate }),
|
||||||
|
responseType: 'blob'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
documents(ownerType: I4cDocumentOwner, ownerId: number): Observable<I4cDocument[]> {
|
||||||
|
return this.http.get<I4cDocument[]>(`${this.cases}/documents`, { params: this.params({ ownerType, ownerId }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadDocument(body: {
|
||||||
|
ownerType: I4cDocumentOwner;
|
||||||
|
ownerId: number;
|
||||||
|
docType: string;
|
||||||
|
fileName: string;
|
||||||
|
contentType: string;
|
||||||
|
content: string;
|
||||||
|
remarks?: string;
|
||||||
|
}): Observable<{ resourceId: number }> {
|
||||||
|
return this.http.post<{ resourceId: number }>(`${this.cases}/documents`, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadDocument(id: number): Observable<Blob> {
|
||||||
|
return this.http.get(`${this.cases}/documents/${id}/content`, { responseType: 'blob' });
|
||||||
|
}
|
||||||
|
|
||||||
|
private params(values: Record<string, any>): HttpParams {
|
||||||
|
let params = new HttpParams();
|
||||||
|
Object.entries(values).forEach(
|
||||||
|
([
|
||||||
|
key,
|
||||||
|
value
|
||||||
|
]) => {
|
||||||
|
if (value !== undefined && value !== null && value !== '') {
|
||||||
|
params = params.set(key, String(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Backend errors come as Fineract's { defaultUserMessage, errors[] }; this picks the sentence a user should read. */
|
||||||
|
export function i4cErrorMessage(error: any): string {
|
||||||
|
const body = error?.error;
|
||||||
|
return (
|
||||||
|
body?.errors?.[0]?.defaultUserMessage ||
|
||||||
|
body?.defaultUserMessage ||
|
||||||
|
body?.developerMessage ||
|
||||||
|
error?.message ||
|
||||||
|
'Request failed'
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { HttpContextToken } from '@angular/common/http';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-request opt-out of the top progress bar. Set via `{ context: new HttpContext().set(NO_PROGRESS_BAR, true) }`
|
||||||
|
* on background calls the user did not ask for (such as the Approvals badge listening for changes), which would
|
||||||
|
* otherwise keep the bar running or make it flash on a screen that has not changed.
|
||||||
|
*/
|
||||||
|
export const NO_PROGRESS_BAR = new HttpContextToken<boolean>(() => false);
|
||||||
@@ -16,6 +16,7 @@ import { finalize } from 'rxjs/operators';
|
|||||||
|
|
||||||
/** Custom Services */
|
/** Custom Services */
|
||||||
import { ProgressBarService } from './progress-bar.service';
|
import { ProgressBarService } from './progress-bar.service';
|
||||||
|
import { NO_PROGRESS_BAR } from './no-progress.token';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Http Request interceptor to start/stop loading the progress bar.
|
* Http Request interceptor to start/stop loading the progress bar.
|
||||||
@@ -29,6 +30,9 @@ export class ProgressInterceptor implements HttpInterceptor {
|
|||||||
* and stop when a response or error is received.
|
* and stop when a response or error is received.
|
||||||
*/
|
*/
|
||||||
intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
|
intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
|
||||||
|
if (request.context.get(NO_PROGRESS_BAR)) {
|
||||||
|
return next.handle(request);
|
||||||
|
}
|
||||||
this.progressBarService.increase();
|
this.progressBarService.increase();
|
||||||
return next.handle(request).pipe(
|
return next.handle(request).pipe(
|
||||||
finalize(() => {
|
finalize(() => {
|
||||||
|
|||||||
@@ -131,6 +131,14 @@
|
|||||||
</svg>
|
</svg>
|
||||||
My Closure Requests
|
My Closure Requests
|
||||||
</a>
|
</a>
|
||||||
|
<a
|
||||||
|
class="cashier-nav__item cashier-nav__item--sec"
|
||||||
|
[class.cashier-nav__item--active]="currentUrl.includes('/clerk/voucher-correction')"
|
||||||
|
[routerLink]="['/clerk/voucher-correction']"
|
||||||
|
>
|
||||||
|
<fa-icon icon="undo"></fa-icon>
|
||||||
|
Voucher Correction
|
||||||
|
</a>
|
||||||
@if (clerkAccountLoaded) {
|
@if (clerkAccountLoaded) {
|
||||||
<div class="cashier-nav__item-wrap">
|
<div class="cashier-nav__item-wrap">
|
||||||
<a
|
<a
|
||||||
@@ -287,6 +295,13 @@
|
|||||||
<a class="cashier-nav__item" [routerLink]="['/cashier/my-work']" routerLinkActive="cashier-nav__item--active">
|
<a class="cashier-nav__item" [routerLink]="['/cashier/my-work']" routerLinkActive="cashier-nav__item--active">
|
||||||
<fa-icon icon="user-check"></fa-icon> My Vouchers
|
<fa-icon icon="user-check"></fa-icon> My Vouchers
|
||||||
</a>
|
</a>
|
||||||
|
<a
|
||||||
|
class="cashier-nav__item"
|
||||||
|
[routerLink]="['/cashier/voucher-correction']"
|
||||||
|
routerLinkActive="cashier-nav__item--active"
|
||||||
|
>
|
||||||
|
<fa-icon icon="undo"></fa-icon> Voucher Correction
|
||||||
|
</a>
|
||||||
<a
|
<a
|
||||||
class="cashier-nav__item"
|
class="cashier-nav__item"
|
||||||
[routerLink]="['/cashier/received-report']"
|
[routerLink]="['/cashier/received-report']"
|
||||||
@@ -450,6 +465,11 @@
|
|||||||
*mifosxHasPermission="'MENU_TASKS'"
|
*mifosxHasPermission="'MENU_TASKS'"
|
||||||
>
|
>
|
||||||
<fa-icon icon="check" class="ni-ico"></fa-icon>Approvals
|
<fa-icon icon="check" class="ni-ico"></fa-icon>Approvals
|
||||||
|
@if (approvalTotal > 0) {
|
||||||
|
<span class="ni-badge" title="Requests waiting for approval">{{
|
||||||
|
approvalTotal > 99 ? '99+' : approvalTotal
|
||||||
|
}}</span>
|
||||||
|
}
|
||||||
</a>
|
</a>
|
||||||
<a class="ni" tabindex="0" [matMenuTriggerFor]="cashierMenu" *mifosxHasPermission="'MENU_CASHIER'">
|
<a class="ni" tabindex="0" [matMenuTriggerFor]="cashierMenu" *mifosxHasPermission="'MENU_CASHIER'">
|
||||||
<fa-icon icon="money-bill" class="ni-ico"></fa-icon>Cashier
|
<fa-icon icon="money-bill" class="ni-ico"></fa-icon>Cashier
|
||||||
@@ -1164,6 +1184,9 @@
|
|||||||
<button mat-menu-item tabindex="0" [routerLink]="['/system/denomination-master']">
|
<button mat-menu-item tabindex="0" [routerLink]="['/system/denomination-master']">
|
||||||
<fa-icon icon="coins" class="menu-item-icon"></fa-icon> Cash Denomination
|
<fa-icon icon="coins" class="menu-item-icon"></fa-icon> Cash Denomination
|
||||||
</button>
|
</button>
|
||||||
|
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/voucher-correction']">
|
||||||
|
<fa-icon icon="undo" class="menu-item-icon"></fa-icon> Voucher Correction
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Transfers & Settlement -->
|
<!-- Transfers & Settlement -->
|
||||||
|
|||||||
@@ -1082,3 +1082,18 @@ $gray-700: #334155;
|
|||||||
width: $bar;
|
width: $bar;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// How many requests are waiting under Approvals.
|
||||||
|
.ni-badge {
|
||||||
|
min-width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin-left: 4px;
|
||||||
|
padding: 0 5px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f59e0b;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { TenantMenuAccessService } from '../../directives/has-permission/tenant-
|
|||||||
|
|
||||||
/** rxjs Imports */
|
/** rxjs Imports */
|
||||||
import { Observable, Subscription } from 'rxjs';
|
import { Observable, Subscription } from 'rxjs';
|
||||||
|
import { ApprovalCountsService } from '../../tasks/approval-counts.service';
|
||||||
import { map, filter } from 'rxjs/operators';
|
import { map, filter } from 'rxjs/operators';
|
||||||
|
|
||||||
/** Custom Services */
|
/** Custom Services */
|
||||||
@@ -68,6 +69,11 @@ export class ShellComponent implements OnInit, OnDestroy {
|
|||||||
private authService = inject(AuthenticationService);
|
private authService = inject(AuthenticationService);
|
||||||
private clerkUiStateService = inject(ClerkUiStateService);
|
private clerkUiStateService = inject(ClerkUiStateService);
|
||||||
private tenantMenuAccessService = inject(TenantMenuAccessService);
|
private tenantMenuAccessService = inject(TenantMenuAccessService);
|
||||||
|
private approvalCounts = inject(ApprovalCountsService);
|
||||||
|
|
||||||
|
/** Requests waiting across every Approval Center tab this user can see, shown on the Approvals menu. */
|
||||||
|
approvalTotal = 0;
|
||||||
|
private approvalTotalSub?: Subscription;
|
||||||
|
|
||||||
/** Subscription to breakpoint observer for handset. */
|
/** Subscription to breakpoint observer for handset. */
|
||||||
isHandset$: Observable<boolean> = this.breakpointObserver
|
isHandset$: Observable<boolean> = this.breakpointObserver
|
||||||
@@ -144,6 +150,13 @@ export class ShellComponent implements OnInit, OnDestroy {
|
|||||||
* Subscribes to progress bar to update its mode.
|
* Subscribes to progress bar to update its mode.
|
||||||
*/
|
*/
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
|
// The Approvals badge: counts refresh every minute while the app is open, and again whenever the Approval Center opens.
|
||||||
|
this.approvalTotalSub = this.approvalCounts.total$.subscribe((total) => {
|
||||||
|
this.approvalTotal = total;
|
||||||
|
this.cdr.markForCheck();
|
||||||
|
});
|
||||||
|
this.approvalCounts.start();
|
||||||
|
|
||||||
this.progressBar$ = this.progressBarService.updateProgressBar.subscribe((mode: string) => {
|
this.progressBar$ = this.progressBarService.updateProgressBar.subscribe((mode: string) => {
|
||||||
this.progressBarMode = mode;
|
this.progressBarMode = mode;
|
||||||
this.cdr.detectChanges();
|
this.cdr.detectChanges();
|
||||||
@@ -368,6 +381,8 @@ export class ShellComponent implements OnInit, OnDestroy {
|
|||||||
* Unsubscribes from progress bar.
|
* Unsubscribes from progress bar.
|
||||||
*/
|
*/
|
||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
|
this.approvalTotalSub?.unsubscribe();
|
||||||
|
this.approvalCounts.stop();
|
||||||
if (this.progressBar$) {
|
if (this.progressBar$) {
|
||||||
this.progressBar$.unsubscribe();
|
this.progressBar$.unsubscribe();
|
||||||
}
|
}
|
||||||
|
|||||||
+57
-4
@@ -331,12 +331,14 @@
|
|||||||
<span class="fdrc-maturity-row__value">{{ matchResult.appliedRate | number: '1.2-2' }}% p.a.</span>
|
<span class="fdrc-maturity-row__value">{{ matchResult.appliedRate | number: '1.2-2' }}% p.a.</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="fdrc-maturity-row fdrc-maturity-row--interest">
|
<div class="fdrc-maturity-row fdrc-maturity-row--interest">
|
||||||
<span class="fdrc-maturity-row__label">Interest Earned</span>
|
<span class="fdrc-maturity-row__label">{{
|
||||||
|
isSpecial ? 'Total Interest (paid out monthly)' : 'Interest Earned'
|
||||||
|
}}</span>
|
||||||
<span class="fdrc-maturity-row__value">₹ {{ matchResult.interestEarned | number: '1.2-2' }}</span>
|
<span class="fdrc-maturity-row__value">₹ {{ matchResult.interestEarned | number: '1.2-2' }}</span>
|
||||||
</div>
|
</div>
|
||||||
@if (isSpecial && matchResult.estimatedMonthlyPayout) {
|
@if (isSpecial && matchResult.estimatedMonthlyPayout) {
|
||||||
<div class="fdrc-maturity-row">
|
<div class="fdrc-maturity-row">
|
||||||
<span class="fdrc-maturity-row__label">Monthly Payout</span>
|
<span class="fdrc-maturity-row__label">Monthly Payout (approx., by days in the month)</span>
|
||||||
<span class="fdrc-maturity-row__value fdrc-maturity-row__value--highlight">
|
<span class="fdrc-maturity-row__value fdrc-maturity-row__value--highlight">
|
||||||
₹ {{ matchResult.estimatedMonthlyPayout | number: '1.2-2' }} / month
|
₹ {{ matchResult.estimatedMonthlyPayout | number: '1.2-2' }} / month
|
||||||
</span>
|
</span>
|
||||||
@@ -351,13 +353,64 @@
|
|||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
<span class="fdrc-maturity-row__value fdrc-maturity-row__value--big">
|
<span class="fdrc-maturity-row__value fdrc-maturity-row__value--big">
|
||||||
₹ {{ (isSpecial ? principalAmount : matchResult.maturityAmount) | number: '1.2-2' }}
|
₹ {{ matchResult.maturityAmount | number: '1.2-2' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
@if (isSpecial && specialPreview) {
|
||||||
|
<div class="fdrc-maturity-row">
|
||||||
|
<span class="fdrc-maturity-row__label">Maturity Date</span>
|
||||||
|
<span class="fdrc-maturity-row__value">{{
|
||||||
|
specialPreview.maturityDate | date: 'dd MMM yyyy'
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="fdrc-maturity-row">
|
||||||
|
<span class="fdrc-maturity-row__label">Monthly payouts</span>
|
||||||
|
<span class="fdrc-maturity-row__value">{{ specialPreview.numberOfPayouts }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="fdrc-maturity-row">
|
||||||
|
<span class="fdrc-maturity-row__label">Principal + all payouts</span>
|
||||||
|
<span class="fdrc-maturity-row__value"
|
||||||
|
>₹ {{ specialPreview.totalValueAtMaturity | number: '1.2-2' }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (isSpecial && specialPreview) {
|
||||||
|
<div class="fdrc-schedule">
|
||||||
|
<div class="fdrc-schedule__head">
|
||||||
|
<span><i class="fas fa-calendar-alt"></i> Monthly Interest Payout Schedule</span>
|
||||||
|
<button
|
||||||
|
mat-stroked-button
|
||||||
|
type="button"
|
||||||
|
class="fdrc-schedule__toggle"
|
||||||
|
(click)="openSpecialSchedule()"
|
||||||
|
>
|
||||||
|
View payout schedule ({{ specialPreview.numberOfPayouts }})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="fdrc-schedule__note">
|
||||||
|
From {{ specialPreview.startDate | date: 'dd MMM yyyy' }} (the day the account is opened) to
|
||||||
|
{{ specialPreview.maturityDate | date: 'dd MMM yyyy' }}, worked out by the account engine from the
|
||||||
|
product.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@if (!matchResult.maturityAmount) {
|
@if (specialPreviewLoading) {
|
||||||
|
<div class="fdrc-payout-hint">
|
||||||
|
<i class="fas fa-spinner fa-spin"></i>
|
||||||
|
Working out the maturity from the product…
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (specialPreviewError) {
|
||||||
|
<div class="fdrc-payout-hint fdrc-payout-hint--error">
|
||||||
|
<i class="fas fa-exclamation-triangle"></i>
|
||||||
|
{{ specialPreviewError }}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (!matchResult.maturityAmount && !specialPreviewLoading && !specialPreviewError) {
|
||||||
<div class="fdrc-payout-hint">
|
<div class="fdrc-payout-hint">
|
||||||
<i class="fas fa-lightbulb"></i>
|
<i class="fas fa-lightbulb"></i>
|
||||||
Enter a deposit amount above to see the maturity calculation.
|
Enter a deposit amount above to see the maturity calculation.
|
||||||
|
|||||||
+69
@@ -1229,3 +1229,72 @@ $red-light: #fee2e2;
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* FD Special: payout schedule from the backend preview */
|
||||||
|
.fdrc-schedule {
|
||||||
|
margin-top: 12px;
|
||||||
|
border: 1px solid #bbf7d0;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: #f0fdf4;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #166534;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__note {
|
||||||
|
padding: 6px 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__wrap {
|
||||||
|
max-height: 360px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-top: 1px solid #e2e8f0;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: #f8fafc;
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.num {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
tfoot td {
|
||||||
|
font-weight: 700;
|
||||||
|
background: #f0fdf4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fdrc-payout-hint--error {
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|||||||
+140
-79
@@ -36,12 +36,39 @@ import { FormsModule } from '@angular/forms';
|
|||||||
import { DateFormatPipe } from '../../../../pipes/date-format.pipe';
|
import { DateFormatPipe } from '../../../../pipes/date-format.pipe';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
import { FixedDepositsService } from '../../fixed-deposits.service';
|
import { FixedDepositsService } from '../../fixed-deposits.service';
|
||||||
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
|
import { InterestScheduleDialogComponent } from '../../fixed-deposit-account-view/custom-dialogs/interest-schedule-dialog/interest-schedule-dialog.component';
|
||||||
import { ClientsService } from 'app/clients/clients.service';
|
import { ClientsService } from 'app/clients/clients.service';
|
||||||
import { SettingsService } from 'app/settings/settings.service';
|
import { SettingsService } from 'app/settings/settings.service';
|
||||||
import { SENIOR_CITIZEN_AGE_THRESHOLD, deriveSeniorCitizenStatus } from 'app/clients/shared/client-age.util';
|
import { SENIOR_CITIZEN_AGE_THRESHOLD, deriveSeniorCitizenStatus } from 'app/clients/shared/client-age.util';
|
||||||
|
|
||||||
export type FdPeriodUnit = 'Days' | 'Months' | 'Years';
|
export type FdPeriodUnit = 'Days' | 'Months' | 'Years';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* yyyy-MM-dd of the calendar day as the user sees it. toISOString() gives the UTC day instead, which in India is the day
|
||||||
|
* before for any date at local midnight - so a deposit opened on the 21st was quoted from the 20th.
|
||||||
|
*/
|
||||||
|
function localIsoDate(d: Date): string {
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** FD quote from POST fixeddepositaccounts/account-preview (worked out by the account engine, every FD type). */
|
||||||
|
export interface SpecialPayoutPreview {
|
||||||
|
fdType: string;
|
||||||
|
depositAmount: number;
|
||||||
|
annualInterestRate: number;
|
||||||
|
seniorCitizen: boolean;
|
||||||
|
startDate: string;
|
||||||
|
maturityDate: string;
|
||||||
|
numberOfPayouts: number;
|
||||||
|
fullMonthPayout: number;
|
||||||
|
totalInterest: number;
|
||||||
|
maturityAmount: number;
|
||||||
|
totalValueAtMaturity: number;
|
||||||
|
periods: { period: number; fromDate: string; toDate: string; days: number; interestAmount: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface SlabMatchResult {
|
export interface SlabMatchResult {
|
||||||
slab: any;
|
slab: any;
|
||||||
slabIndex: number;
|
slabIndex: number;
|
||||||
@@ -87,6 +114,7 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
|
|||||||
private clientsService = inject(ClientsService);
|
private clientsService = inject(ClientsService);
|
||||||
private settingsService = inject(SettingsService);
|
private settingsService = inject(SettingsService);
|
||||||
private cdr = inject(ChangeDetectorRef);
|
private cdr = inject(ChangeDetectorRef);
|
||||||
|
private dialog = inject(MatDialog);
|
||||||
|
|
||||||
@Input() fixedDepositsAccountTemplate: any;
|
@Input() fixedDepositsAccountTemplate: any;
|
||||||
@Input() fixedDepositsAccountProductTemplate: any;
|
@Input() fixedDepositsAccountProductTemplate: any;
|
||||||
@@ -163,6 +191,10 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
|
|||||||
}
|
}
|
||||||
|
|
||||||
matchResult: SlabMatchResult | null = null;
|
matchResult: SlabMatchResult | null = null;
|
||||||
|
/** FD Special only: the backend's quote and payout schedule for the tenure and amount entered. */
|
||||||
|
specialPreview: SpecialPayoutPreview | null = null;
|
||||||
|
specialPreviewLoading = false;
|
||||||
|
specialPreviewError: string | null = null;
|
||||||
validationWarning: string | null = null;
|
validationWarning: string | null = null;
|
||||||
|
|
||||||
// ── FD Type labels ──────────────────────────────────────────────────────
|
// ── FD Type labels ──────────────────────────────────────────────────────
|
||||||
@@ -270,6 +302,8 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
|
|||||||
calculateRate() {
|
calculateRate() {
|
||||||
this.validationWarning = null;
|
this.validationWarning = null;
|
||||||
this.matchResult = null;
|
this.matchResult = null;
|
||||||
|
this.specialPreview = null;
|
||||||
|
this.specialPreviewError = null;
|
||||||
|
|
||||||
if (!this.tenureValue || this.tenureValue <= 0) {
|
if (!this.tenureValue || this.tenureValue <= 0) {
|
||||||
this.validationWarning = 'Please enter a valid tenure value greater than 0.';
|
this.validationWarning = 'Please enter a valid tenure value greater than 0.';
|
||||||
@@ -334,30 +368,6 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
|
|||||||
const frequencyId = this.resolveFrequencyId(this.tenurePeriodUnit);
|
const frequencyId = this.resolveFrequencyId(this.tenurePeriodUnit);
|
||||||
|
|
||||||
if (this.principalAmount && this.principalAmount > 0) {
|
if (this.principalAmount && this.principalAmount > 0) {
|
||||||
// Years and Months convert exactly; a Days tenure is sent as days and never converted.
|
|
||||||
let tenureInMonths: number | undefined;
|
|
||||||
let tenureInDays: number | undefined;
|
|
||||||
if (this.tenurePeriodUnit === 'Years') {
|
|
||||||
tenureInMonths = this.tenureValue * 12;
|
|
||||||
} else if (this.tenurePeriodUnit === 'Months') {
|
|
||||||
tenureInMonths = this.tenureValue;
|
|
||||||
} else {
|
|
||||||
// Sent as days, whatever the count. Converting to whole months silently dropped the
|
|
||||||
// remainder — a 200-day deposit quoted as six months lost 20 days of interest, and the
|
|
||||||
// screen still said "200 Days" above the figure. The backend takes days directly and
|
|
||||||
// works the calendar out itself.
|
|
||||||
tenureInDays = enteredDays;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map compounding period type id → months (0=Daily, 1=Daily, 4=Monthly, 5=Quarterly, 6=SemiAnnual, 7=Annual)
|
|
||||||
const compMap: Record<number, number> = { 1: 0, 4: 1, 5: 3, 6: 6, 7: 12 };
|
|
||||||
const compId = this.fixedDepositsAccountProductTemplate?.interestCompoundingPeriodType?.id ?? 4;
|
|
||||||
const compMonths = compMap[compId] ?? 1;
|
|
||||||
|
|
||||||
// Map posting period type id → months
|
|
||||||
const postId = this.fixedDepositsAccountProductTemplate?.interestPostingPeriodType?.id ?? 4;
|
|
||||||
const postMonths = compMap[postId] ?? 1;
|
|
||||||
|
|
||||||
const emitResult = () => {
|
const emitResult = () => {
|
||||||
if (frequencyId !== null) {
|
if (frequencyId !== null) {
|
||||||
this.tenureSelected.emit({
|
this.tenureSelected.emit({
|
||||||
@@ -375,7 +385,7 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
|
|||||||
// Get submittedOnDate — Input takes priority over template data
|
// Get submittedOnDate — Input takes priority over template data
|
||||||
let submittedOnDateStr: string;
|
let submittedOnDateStr: string;
|
||||||
if (this.submittedOnDate) {
|
if (this.submittedOnDate) {
|
||||||
submittedOnDateStr = this.submittedOnDate.toISOString().split('T')[0];
|
submittedOnDateStr = localIsoDate(this.submittedOnDate);
|
||||||
} else {
|
} else {
|
||||||
const submittedOnDateRaw =
|
const submittedOnDateRaw =
|
||||||
this.fixedDepositsAccountTemplate?.timeline?.submittedOnDate ??
|
this.fixedDepositsAccountTemplate?.timeline?.submittedOnDate ??
|
||||||
@@ -384,63 +394,18 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
|
|||||||
const d = Array.isArray(submittedOnDateRaw)
|
const d = Array.isArray(submittedOnDateRaw)
|
||||||
? new Date(submittedOnDateRaw[0], submittedOnDateRaw[1] - 1, submittedOnDateRaw[2])
|
? new Date(submittedOnDateRaw[0], submittedOnDateRaw[1] - 1, submittedOnDateRaw[2])
|
||||||
: new Date(submittedOnDateRaw);
|
: new Date(submittedOnDateRaw);
|
||||||
submittedOnDateStr = !isNaN(d.getTime())
|
submittedOnDateStr = !isNaN(d.getTime()) ? localIsoDate(d) : localIsoDate(new Date());
|
||||||
? d.toISOString().split('T')[0]
|
|
||||||
: new Date().toISOString().split('T')[0];
|
|
||||||
} else {
|
} else {
|
||||||
submittedOnDateStr = new Date().toISOString().split('T')[0];
|
submittedOnDateStr = localIsoDate(new Date());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const daysInYearVal =
|
// The whole quote - rate, maturity date, maturity amount and, for FD Special, the month-by-month payout
|
||||||
this.fixedDepositsAccountProductTemplate?.interestCalculationDaysInYearType?.id === 360 ? 360 : 365;
|
// schedule - comes from the backend, which builds the would-be account from the product exactly as Submit does
|
||||||
|
// (its posting periods, compounding, days in year, rate card, senior-citizen rate) and runs the account's own
|
||||||
// Always use backend API for accurate calculation (including daily compounding with 0)
|
// interest engine. Nothing is worked out here: on an error the quote is simply not shown, never estimated.
|
||||||
this.fixedDepositsService
|
if (frequencyId !== null) {
|
||||||
.calculateFixedDepositInterest({
|
this.loadAccountPreview(result, rate, submittedOnDateStr, frequencyId, emitResult);
|
||||||
principalAmount: this.principalAmount,
|
}
|
||||||
annualInterestRate: rate,
|
|
||||||
tenureInMonths,
|
|
||||||
tenureInDays,
|
|
||||||
interestCompoundingPeriodInMonths: compMonths, // 0 = daily, now supported by backend
|
|
||||||
interestPostingPeriodInMonths: postMonths,
|
|
||||||
submittedOnDate: submittedOnDateStr,
|
|
||||||
daysInYear: daysInYearVal
|
|
||||||
})
|
|
||||||
.subscribe({
|
|
||||||
next: (apiResult: any) => {
|
|
||||||
result.maturityAmount = apiResult.maturityAmount;
|
|
||||||
result.interestEarned =
|
|
||||||
apiResult.totalInterest ?? Math.round((apiResult.maturityAmount - this.principalAmount) * 100) / 100;
|
|
||||||
this.matchResult = { ...result };
|
|
||||||
emitResult();
|
|
||||||
},
|
|
||||||
error: () => {
|
|
||||||
// Fallback if API fails: frontend formula
|
|
||||||
const r = rate / 100;
|
|
||||||
if (compMonths === 0) {
|
|
||||||
// Daily compounding fallback
|
|
||||||
result.maturityAmount =
|
|
||||||
Math.round(this.principalAmount * Math.pow(1 + r / daysInYearVal, enteredDays) * 100) / 100;
|
|
||||||
} else {
|
|
||||||
const n = 12 / compMonths;
|
|
||||||
const effectiveMonths = tenureInMonths ?? enteredDays / 30;
|
|
||||||
const rPerCompounding = rate / (n * 100);
|
|
||||||
// Whole compounding periods, then simple interest on the days left over. Flooring the
|
|
||||||
// periods and stopping there dropped the remainder entirely: a 200-day deposit
|
|
||||||
// compounding monthly quoted only six months of interest and lost the other twenty
|
|
||||||
// days, which is the depositor's money.
|
|
||||||
const wholePeriods = Math.floor(effectiveMonths / compMonths);
|
|
||||||
const compounded = this.principalAmount * Math.pow(1 + rPerCompounding, wholePeriods);
|
|
||||||
const leftoverDays = enteredDays - wholePeriods * compMonths * 30;
|
|
||||||
const leftoverInterest =
|
|
||||||
leftoverDays > 0 ? (compounded * rate * leftoverDays) / (100 * daysInYearVal) : 0;
|
|
||||||
result.maturityAmount = Math.round((compounded + leftoverInterest) * 100) / 100;
|
|
||||||
}
|
|
||||||
result.interestEarned = Math.round((result.maturityAmount - this.principalAmount) * 100) / 100;
|
|
||||||
this.matchResult = { ...result };
|
|
||||||
emitResult();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
this.matchResult = result;
|
this.matchResult = result;
|
||||||
if (frequencyId !== null) {
|
if (frequencyId !== null) {
|
||||||
@@ -457,6 +422,96 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The backend's payout schedule in the same popup the account itself uses, in its quote (preview) mode. */
|
||||||
|
openSpecialSchedule(): void {
|
||||||
|
if (!this.specialPreview) return;
|
||||||
|
this.dialog.open(InterestScheduleDialogComponent, {
|
||||||
|
width: '760px',
|
||||||
|
data: {
|
||||||
|
preview: true,
|
||||||
|
currencyCode: this.fixedDepositsAccountProductTemplate?.currency?.code ?? 'INR',
|
||||||
|
note:
|
||||||
|
'Worked out by the account engine from the product, starting ' +
|
||||||
|
this.specialPreview.startDate +
|
||||||
|
' (the day the account is opened) and ending the day before maturity on ' +
|
||||||
|
this.specialPreview.maturityDate +
|
||||||
|
'. The first and last periods are part-months; each pays for the days it covers.',
|
||||||
|
schedule: this.specialPreview.periods.map((p) => ({
|
||||||
|
fromDate: p.fromDate,
|
||||||
|
toDate: p.toDate,
|
||||||
|
days: p.days,
|
||||||
|
interestAmount: Number(p.interestAmount),
|
||||||
|
paid: false
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asks the backend for the quote (POST fixeddepositaccounts/account-preview) with the same terms Submit sends: the
|
||||||
|
* product's own interest terms, and the senior-citizen rate the same way Submit overrides it. The rate, maturity
|
||||||
|
* amount and interest shown are the backend's, so they match the account once it is created.
|
||||||
|
*/
|
||||||
|
private loadAccountPreview(
|
||||||
|
result: SlabMatchResult,
|
||||||
|
rate: number,
|
||||||
|
submittedOnDateStr: string,
|
||||||
|
frequencyId: number,
|
||||||
|
emitResult: () => void
|
||||||
|
): void {
|
||||||
|
const pt = this.fixedDepositsAccountProductTemplate ?? {};
|
||||||
|
const body: any = {
|
||||||
|
clientId: pt.clientId ?? this.fixedDepositsAccountTemplate?.clientId,
|
||||||
|
productId: pt.depositProductId ?? pt.productId ?? this.fixedDepositsAccountTemplate?.productId,
|
||||||
|
submittedOnDate: submittedOnDateStr,
|
||||||
|
dateFormat: 'yyyy-MM-dd',
|
||||||
|
locale: 'en',
|
||||||
|
depositAmount: this.principalAmount,
|
||||||
|
depositPeriod: this.tenureValue,
|
||||||
|
depositPeriodFrequencyId: frequencyId
|
||||||
|
};
|
||||||
|
if (pt.nominalAnnualInterestRate != null) body.nominalAnnualInterestRate = pt.nominalAnnualInterestRate;
|
||||||
|
if (pt.interestCompoundingPeriodType?.id != null)
|
||||||
|
body.interestCompoundingPeriodType = pt.interestCompoundingPeriodType.id;
|
||||||
|
if (pt.interestPostingPeriodType?.id != null) body.interestPostingPeriodType = pt.interestPostingPeriodType.id;
|
||||||
|
if (pt.interestCalculationType?.id != null) body.interestCalculationType = pt.interestCalculationType.id;
|
||||||
|
if (pt.interestCalculationDaysInYearType?.id != null)
|
||||||
|
body.interestCalculationDaysInYearType = pt.interestCalculationDaysInYearType.id;
|
||||||
|
if (this.isSeniorCitizen && rate > 0) {
|
||||||
|
body.isSeniorCitizen = true;
|
||||||
|
body.seniorCitizenInterestRate = rate;
|
||||||
|
body.nominalAnnualInterestRate = rate;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.specialPreview = null;
|
||||||
|
this.specialPreviewError = null;
|
||||||
|
this.specialPreviewLoading = true;
|
||||||
|
this.fixedDepositsService.accountPreview(body).subscribe({
|
||||||
|
next: (preview: SpecialPayoutPreview) => {
|
||||||
|
this.specialPreviewLoading = false;
|
||||||
|
this.specialPreview = preview;
|
||||||
|
result.appliedRate = Number(preview.annualInterestRate);
|
||||||
|
result.interestEarned = Number(preview.totalInterest);
|
||||||
|
result.estimatedMonthlyPayout = preview.fullMonthPayout != null ? Number(preview.fullMonthPayout) : undefined;
|
||||||
|
result.maturityAmount = Number(preview.maturityAmount);
|
||||||
|
this.matchResult = { ...result };
|
||||||
|
emitResult();
|
||||||
|
this.cdr.markForCheck();
|
||||||
|
},
|
||||||
|
error: (e: any) => {
|
||||||
|
this.specialPreviewLoading = false;
|
||||||
|
this.specialPreviewError =
|
||||||
|
e?.error?.errors?.[0]?.defaultUserMessage ||
|
||||||
|
e?.error?.defaultUserMessage ||
|
||||||
|
'Could not work out the maturity. Check the deposit details and try again.';
|
||||||
|
result.maturityAmount = undefined;
|
||||||
|
result.interestEarned = undefined;
|
||||||
|
this.matchResult = { ...result };
|
||||||
|
this.cdr.markForCheck();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private resolveFrequencyId(unit: FdPeriodUnit): number | null {
|
private resolveFrequencyId(unit: FdPeriodUnit): number | null {
|
||||||
const options: any[] = this.fixedDepositsAccountProductTemplate?.periodFrequencyTypeOptions ?? [];
|
const options: any[] = this.fixedDepositsAccountProductTemplate?.periodFrequencyTypeOptions ?? [];
|
||||||
const unitLower = unit.toLowerCase();
|
const unitLower = unit.toLowerCase();
|
||||||
@@ -471,12 +526,16 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
|
|||||||
this.tenureValue = null;
|
this.tenureValue = null;
|
||||||
this.principalAmount = null;
|
this.principalAmount = null;
|
||||||
this.matchResult = null;
|
this.matchResult = null;
|
||||||
|
this.specialPreview = null;
|
||||||
|
this.specialPreviewError = null;
|
||||||
this.validationWarning = null;
|
this.validationWarning = null;
|
||||||
this.tenurePeriodUnit = this.allowedPeriodUnits[0];
|
this.tenurePeriodUnit = this.allowedPeriodUnits[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
onPeriodUnitChange() {
|
onPeriodUnitChange() {
|
||||||
this.matchResult = null;
|
this.matchResult = null;
|
||||||
|
this.specialPreview = null;
|
||||||
|
this.specialPreviewError = null;
|
||||||
this.validationWarning = null;
|
this.validationWarning = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -524,6 +583,8 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
|
|||||||
this.calculateRate();
|
this.calculateRate();
|
||||||
} else {
|
} else {
|
||||||
this.matchResult = null;
|
this.matchResult = null;
|
||||||
|
this.specialPreview = null;
|
||||||
|
this.specialPreviewError = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+7
-5
@@ -326,7 +326,7 @@
|
|||||||
<div class="preview-dorm-step__body">
|
<div class="preview-dorm-step__body">
|
||||||
<span class="preview-dorm-step__label">Inactive</span>
|
<span class="preview-dorm-step__label">Inactive</span>
|
||||||
<span class="preview-dorm-step__days">
|
<span class="preview-dorm-step__days">
|
||||||
@if (fixedDepositsAccountProductTemplate?.daysToInactive != null) {
|
@if ((fixedDepositsAccountProductTemplate?.daysToInactive ?? null) !== null) {
|
||||||
After {{ fixedDepositsAccountProductTemplate.daysToInactive }} days of no transaction
|
After {{ fixedDepositsAccountProductTemplate.daysToInactive }} days of no transaction
|
||||||
} @else {
|
} @else {
|
||||||
Not configured
|
Not configured
|
||||||
@@ -340,7 +340,7 @@
|
|||||||
<div class="preview-dorm-step__body">
|
<div class="preview-dorm-step__body">
|
||||||
<span class="preview-dorm-step__label">Dormant</span>
|
<span class="preview-dorm-step__label">Dormant</span>
|
||||||
<span class="preview-dorm-step__days">
|
<span class="preview-dorm-step__days">
|
||||||
@if (fixedDepositsAccountProductTemplate?.daysToDormancy != null) {
|
@if ((fixedDepositsAccountProductTemplate?.daysToDormancy ?? null) !== null) {
|
||||||
{{ fixedDepositsAccountProductTemplate.daysToDormancy }} days after inactive
|
{{ fixedDepositsAccountProductTemplate.daysToDormancy }} days after inactive
|
||||||
} @else {
|
} @else {
|
||||||
Not configured
|
Not configured
|
||||||
@@ -354,7 +354,7 @@
|
|||||||
<div class="preview-dorm-step__body">
|
<div class="preview-dorm-step__body">
|
||||||
<span class="preview-dorm-step__label">Escheat</span>
|
<span class="preview-dorm-step__label">Escheat</span>
|
||||||
<span class="preview-dorm-step__days">
|
<span class="preview-dorm-step__days">
|
||||||
@if (fixedDepositsAccountProductTemplate?.daysToEscheat != null) {
|
@if ((fixedDepositsAccountProductTemplate?.daysToEscheat ?? null) !== null) {
|
||||||
{{ fixedDepositsAccountProductTemplate.daysToEscheat }} days after dormant
|
{{ fixedDepositsAccountProductTemplate.daysToEscheat }} days after dormant
|
||||||
} @else {
|
} @else {
|
||||||
Not configured
|
Not configured
|
||||||
@@ -458,13 +458,15 @@
|
|||||||
<span class="preview-value">{{ maturitySummary.tenureLabel }}</span>
|
<span class="preview-value">{{ maturitySummary.tenureLabel }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="preview-field">
|
<div class="preview-field">
|
||||||
<span class="preview-label">Estimated Interest Earned</span>
|
<span class="preview-label">{{
|
||||||
|
isSpecial ? 'Total Interest (paid out monthly)' : 'Estimated Interest Earned'
|
||||||
|
}}</span>
|
||||||
<span class="preview-value" style="color: var(--theme-success, #28a745); font-weight: 500">
|
<span class="preview-value" style="color: var(--theme-success, #28a745); font-weight: 500">
|
||||||
+ {{ maturitySummary.totalInterest | formatNumber }} {{ currencyName }}
|
+ {{ maturitySummary.totalInterest | formatNumber }} {{ currencyName }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="preview-field" style="grid-column: span 2">
|
<div class="preview-field" style="grid-column: span 2">
|
||||||
<span class="preview-label">Projected Maturity Amount</span>
|
<span class="preview-label">{{ isSpecial ? 'Principal at Maturity' : 'Projected Maturity Amount' }}</span>
|
||||||
<span class="preview-value preview-mono" style="font-weight: 600; font-size: 1.25rem">
|
<span class="preview-value preview-mono" style="font-weight: 600; font-size: 1.25rem">
|
||||||
{{ maturitySummary.maturityAmount | formatNumber }} {{ currencyName }}
|
{{ maturitySummary.maturityAmount | formatNumber }} {{ currencyName }}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
+5
@@ -145,6 +145,11 @@ export class FixedDepositAccountPreviewStepComponent implements OnChanges {
|
|||||||
SPECIAL: 'FD Special (Monthly Interest Payout)'
|
SPECIAL: 'FD Special (Monthly Interest Payout)'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** FD Special pays its interest out monthly; the principal alone comes back at maturity. */
|
||||||
|
get isSpecial(): boolean {
|
||||||
|
return this.fixedDepositsAccountProductTemplate?.fdType === 'SPECIAL';
|
||||||
|
}
|
||||||
|
|
||||||
getFdTypeLabel(fdType: string): string {
|
getFdTypeLabel(fdType: string): string {
|
||||||
return this.fdTypeLabels[fdType] || fdType;
|
return this.fdTypeLabels[fdType] || fdType;
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-15
@@ -11,15 +11,25 @@
|
|||||||
<span class="isd-header__icon"><fa-icon icon="calendar-alt"></fa-icon></span>
|
<span class="isd-header__icon"><fa-icon icon="calendar-alt"></fa-icon></span>
|
||||||
<span class="isd-header__text">
|
<span class="isd-header__text">
|
||||||
<span class="isd-header__title">{{ 'labels.heading.Monthly Interest Payout Schedule' | translate }}</span>
|
<span class="isd-header__title">{{ 'labels.heading.Monthly Interest Payout Schedule' | translate }}</span>
|
||||||
<span class="isd-header__sub">{{ paidCount }} of {{ schedule.length }} periods paid so far</span>
|
<span class="isd-header__sub">
|
||||||
|
@if (isPreview) {
|
||||||
|
{{ schedule.length }} monthly payouts
|
||||||
|
} @else {
|
||||||
|
{{ paidCount }} of {{ schedule.length }} periods paid so far
|
||||||
|
}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div mat-dialog-content class="isd-content">
|
<div mat-dialog-content class="isd-content">
|
||||||
<p class="isd-note">
|
<p class="isd-note">
|
||||||
<fa-icon icon="info-circle" class="m-r-5"></fa-icon>
|
<fa-icon icon="info-circle" class="m-r-5"></fa-icon>
|
||||||
Computed by the actual interest engine for this account's full term -- the exact amount posted each month can vary
|
@if (data.note) {
|
||||||
slightly with the number of days in that month.
|
{{ data.note }}
|
||||||
|
} @else {
|
||||||
|
Computed by the actual interest engine for this account's full term -- the exact amount posted each month can
|
||||||
|
vary slightly with the number of days in that month.
|
||||||
|
}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="isd-scroll">
|
<div class="isd-scroll">
|
||||||
@@ -28,8 +38,13 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th>{{ 'labels.inputs.From Date' | translate }}</th>
|
<th>{{ 'labels.inputs.From Date' | translate }}</th>
|
||||||
<th>{{ 'labels.inputs.To Date' | translate }}</th>
|
<th>{{ 'labels.inputs.To Date' | translate }}</th>
|
||||||
|
@if (isPreview) {
|
||||||
|
<th class="isd-table__amount-col">Days</th>
|
||||||
|
}
|
||||||
<th class="isd-table__amount-col">{{ 'labels.inputs.Interest Amount' | translate }}</th>
|
<th class="isd-table__amount-col">{{ 'labels.inputs.Interest Amount' | translate }}</th>
|
||||||
<th class="isd-table__status-col">{{ 'labels.inputs.Status' | translate }}</th>
|
@if (!isPreview) {
|
||||||
|
<th class="isd-table__status-col">{{ 'labels.inputs.Status' | translate }}</th>
|
||||||
|
}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -37,28 +52,35 @@
|
|||||||
<tr [class.isd-table__row--paid]="period.paid">
|
<tr [class.isd-table__row--paid]="period.paid">
|
||||||
<td>{{ period.fromDate | dateFormat }}</td>
|
<td>{{ period.fromDate | dateFormat }}</td>
|
||||||
<td>{{ period.toDate | dateFormat }}</td>
|
<td>{{ period.toDate | dateFormat }}</td>
|
||||||
|
@if (isPreview) {
|
||||||
|
<td class="isd-table__amount-col">{{ period.days }}</td>
|
||||||
|
}
|
||||||
<td class="isd-table__amount-col">
|
<td class="isd-table__amount-col">
|
||||||
{{ period.interestAmount | currency: currencyCode : 'symbol-narrow' : '1.2-2' }}
|
{{ period.interestAmount | currency: currencyCode : 'symbol-narrow' : '1.2-2' }}
|
||||||
</td>
|
</td>
|
||||||
<td class="isd-table__status-col">
|
@if (!isPreview) {
|
||||||
@if (period.paid) {
|
<td class="isd-table__status-col">
|
||||||
<span class="isd-badge isd-badge--paid">
|
@if (period.paid) {
|
||||||
<fa-icon icon="check-circle" class="m-r-5"></fa-icon>Paid
|
<span class="isd-badge isd-badge--paid">
|
||||||
</span>
|
<fa-icon icon="check-circle" class="m-r-5"></fa-icon>Paid
|
||||||
} @else {
|
</span>
|
||||||
<span class="isd-badge isd-badge--pending">Pending</span>
|
} @else {
|
||||||
}
|
<span class="isd-badge isd-badge--pending">Pending</span>
|
||||||
</td>
|
}
|
||||||
|
</td>
|
||||||
|
}
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
<tfoot>
|
<tfoot>
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="2">{{ 'labels.inputs.Total Interest Payable' | translate }}</td>
|
<td [attr.colspan]="isPreview ? 3 : 2">{{ 'labels.inputs.Total Interest Payable' | translate }}</td>
|
||||||
<td class="isd-table__amount-col">
|
<td class="isd-table__amount-col">
|
||||||
{{ totalInterest | currency: currencyCode : 'symbol-narrow' : '1.2-2' }}
|
{{ totalInterest | currency: currencyCode : 'symbol-narrow' : '1.2-2' }}
|
||||||
</td>
|
</td>
|
||||||
<td></td>
|
@if (!isPreview) {
|
||||||
|
<td></td>
|
||||||
|
}
|
||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
+13
-2
@@ -26,6 +26,9 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
|||||||
* a 24+ month term makes for a long table that would otherwise push every other card on the tab down
|
* a 24+ month term makes for a long table that would otherwise push every other card on the tab down
|
||||||
* the page. Each row also shows whether that period's interest has actually posted yet, not just what
|
* the page. Each row also shows whether that period's interest has actually posted yet, not just what
|
||||||
* the engine projects it to be.
|
* the engine projects it to be.
|
||||||
|
*
|
||||||
|
* Also opened from the account-opening screen with `preview: true`: the backend's quote for an FD Special
|
||||||
|
* not opened yet, so there is no paid / pending status, and each period shows its number of days.
|
||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'mifosx-interest-schedule-dialog',
|
selector: 'mifosx-interest-schedule-dialog',
|
||||||
@@ -46,14 +49,22 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
|||||||
export class InterestScheduleDialogComponent {
|
export class InterestScheduleDialogComponent {
|
||||||
dialogRef = inject<MatDialogRef<InterestScheduleDialogComponent>>(MatDialogRef);
|
dialogRef = inject<MatDialogRef<InterestScheduleDialogComponent>>(MatDialogRef);
|
||||||
data: {
|
data: {
|
||||||
schedule: Array<{ fromDate: any; toDate: any; interestAmount: number; paid: boolean }>;
|
schedule: Array<{ fromDate: any; toDate: any; interestAmount: number; paid: boolean; days?: number }>;
|
||||||
currencyCode: string;
|
currencyCode: string;
|
||||||
|
/** Account-opening quote: no paid / pending status. */
|
||||||
|
preview?: boolean;
|
||||||
|
/** Shown above the table instead of the account note. */
|
||||||
|
note?: string;
|
||||||
} = inject(MAT_DIALOG_DATA);
|
} = inject(MAT_DIALOG_DATA);
|
||||||
|
|
||||||
get schedule(): Array<{ fromDate: any; toDate: any; interestAmount: number; paid: boolean }> {
|
get schedule(): Array<{ fromDate: any; toDate: any; interestAmount: number; paid: boolean; days?: number }> {
|
||||||
return this.data.schedule ?? [];
|
return this.data.schedule ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get isPreview(): boolean {
|
||||||
|
return !!this.data.preview;
|
||||||
|
}
|
||||||
|
|
||||||
get currencyCode(): string {
|
get currencyCode(): string {
|
||||||
return this.data.currencyCode;
|
return this.data.currencyCode;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,6 +154,16 @@ export class FixedDepositsService {
|
|||||||
return this.http.get(`/fixeddepositaccounts/calculate-fd-interest`, { params: httpParams });
|
return this.http.get(`/fixeddepositaccounts/calculate-fd-interest`, { params: httpParams });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FD quote while opening the account, for every FD type: the same body as create (clientId, productId,
|
||||||
|
* submittedOnDate, depositAmount, depositPeriod, depositPeriodFrequencyId, interest terms, senior citizen ...). The
|
||||||
|
* backend builds the would-be account from the product, without saving it, and returns the applied rate, maturity
|
||||||
|
* date and amount Submit would store, and the interest periods (FD Special: the monthly payout schedule).
|
||||||
|
*/
|
||||||
|
accountPreview(body: any): Observable<any> {
|
||||||
|
return this.http.post(`/fixeddepositaccounts/account-preview`, body);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* FD Dashboard's "Renew" action on a matured FD whose product has a renewal grace period
|
* FD Dashboard's "Renew" action on a matured FD whose product has a renewal grace period
|
||||||
* configured: backdated to the original maturity date within the grace window, or from today
|
* configured: backdated to the original maturity date within the grace window, or from today
|
||||||
|
|||||||
+13
@@ -129,12 +129,24 @@ export class FixedDepositProductTermsStepComponent implements OnInit, OnChanges
|
|||||||
* (see DepositAccountAssembler#assembleFrom on the backend), so the product itself is locked to the
|
* (see DepositAccountAssembler#assembleFrom on the backend), so the product itself is locked to the
|
||||||
* same values here to avoid an admin configuring a combination that the backend will silently override
|
* same values here to avoid an admin configuring a combination that the backend will silently override
|
||||||
* at account-creation time.
|
* at account-creation time.
|
||||||
|
*
|
||||||
|
* Its interest is paid out every month, so nothing compounds on the principal: the Interest Calculation Type is
|
||||||
|
* locked to Simple as well. The backend refuses an FD Special product with any other value for these three
|
||||||
|
* (DepositProductDataValidator), so a request from anywhere else cannot save them either.
|
||||||
*/
|
*/
|
||||||
private applyFdTypeConstraints() {
|
private applyFdTypeConstraints() {
|
||||||
if (!this.fixedDepositProductTermsForm) return;
|
if (!this.fixedDepositProductTermsForm) return;
|
||||||
const postingCtrl = this.fixedDepositProductTermsForm.get('interestPostingPeriodType');
|
const postingCtrl = this.fixedDepositProductTermsForm.get('interestPostingPeriodType');
|
||||||
const compoundingCtrl = this.fixedDepositProductTermsForm.get('interestCompoundingPeriodType');
|
const compoundingCtrl = this.fixedDepositProductTermsForm.get('interestCompoundingPeriodType');
|
||||||
|
const calculationTypeCtrl = this.fixedDepositProductTermsForm.get('interestCalculateSelector');
|
||||||
if (this.fdType === 'SPECIAL') {
|
if (this.fdType === 'SPECIAL') {
|
||||||
|
const simple = (this.interestCalculationTypeOptionData || []).find(
|
||||||
|
(o: any) => String(o.value ?? o.name ?? '').toLowerCase() === 'simple'
|
||||||
|
);
|
||||||
|
if (calculationTypeCtrl && simple) {
|
||||||
|
calculationTypeCtrl.setValue(simple.id);
|
||||||
|
calculationTypeCtrl.disable();
|
||||||
|
}
|
||||||
// Monthly = id 4 (MONTHLY in Fineract's SavingsPostingInterestPeriodType/SavingsCompoundingInterestPeriodType enums)
|
// Monthly = id 4 (MONTHLY in Fineract's SavingsPostingInterestPeriodType/SavingsCompoundingInterestPeriodType enums)
|
||||||
const findMonthly = (data: any) => data?.find((o: any) => o.value?.toLowerCase() === 'monthly' || o.id === 4);
|
const findMonthly = (data: any) => data?.find((o: any) => o.value?.toLowerCase() === 'monthly' || o.id === 4);
|
||||||
if (postingCtrl && this.interestPostingPeriodTypeData) {
|
if (postingCtrl && this.interestPostingPeriodTypeData) {
|
||||||
@@ -154,6 +166,7 @@ export class FixedDepositProductTermsStepComponent implements OnInit, OnChanges
|
|||||||
} else {
|
} else {
|
||||||
postingCtrl?.enable();
|
postingCtrl?.enable();
|
||||||
compoundingCtrl?.enable();
|
compoundingCtrl?.enable();
|
||||||
|
calculationTypeCtrl?.enable();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+20
-24
@@ -175,18 +175,13 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit {
|
|||||||
if (this.isCashOrAccrualAccounting()) {
|
if (this.isCashOrAccrualAccounting()) {
|
||||||
const glMappings = mappings.otherGlMappingsMap || mappings.otherGlMappings;
|
const glMappings = mappings.otherGlMappingsMap || mappings.otherGlMappings;
|
||||||
this.setOtherGlMappings('assetsOtherGlMappings', glMappings?.assets || [], this.assetAccountOptions);
|
this.setOtherGlMappings('assetsOtherGlMappings', glMappings?.assets || [], this.assetAccountOptions);
|
||||||
// Interest Paid is a liability clearing account (Dr Payable / Cr Paid, then Dr Paid / Cr RD), saved under
|
// Interest Paid sits under expenses, as on the fixed deposit and savings products (the backend finds it by name
|
||||||
// liabilities. Products saved before that kept it under expenses, so both groups are read.
|
// in any group). Products saved while it was under liabilities are read from there too, so the name is not
|
||||||
const fromLiabilities = this.extractNamedGlMapping(
|
// left behind as an "other" liability row; a liability GL chosen then has to be picked again from expenses.
|
||||||
this.extractNamedGlMapping(
|
const fromLiabilities = (glMappings?.liabilities || []).filter(
|
||||||
glMappings?.liabilities || [],
|
(mapping: any) =>
|
||||||
RECURRING_DEPOSIT_INTEREST_PAID_ACCOUNT_NAME,
|
mapping?.accountName !== RECURRING_DEPOSIT_INTEREST_PAID_ACCOUNT_NAME &&
|
||||||
'savingsInterestPaidAccountId',
|
mapping?.accountName !== SAVINGS_INTEREST_PAID_ACCOUNT_NAME
|
||||||
this.liabilityAccountOptions
|
|
||||||
),
|
|
||||||
SAVINGS_INTEREST_PAID_ACCOUNT_NAME,
|
|
||||||
'savingsInterestPaidAccountId',
|
|
||||||
this.liabilityAccountOptions
|
|
||||||
);
|
);
|
||||||
this.setOtherGlMappings('liabilitiesOtherGlMappings', fromLiabilities, this.liabilityAccountOptions);
|
this.setOtherGlMappings('liabilitiesOtherGlMappings', fromLiabilities, this.liabilityAccountOptions);
|
||||||
const fromExpenses = this.extractNamedGlMapping(
|
const fromExpenses = this.extractNamedGlMapping(
|
||||||
@@ -277,15 +272,15 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit {
|
|||||||
|
|
||||||
const otherGlMappings = {
|
const otherGlMappings = {
|
||||||
assets: map('assetsOtherGlMappings', this.assetAccountOptions),
|
assets: map('assetsOtherGlMappings', this.assetAccountOptions),
|
||||||
liabilities: [
|
liabilities: map('liabilitiesOtherGlMappings', this.liabilityAccountOptions),
|
||||||
|
expenses: [
|
||||||
...namedAsOtherGlMapping(
|
...namedAsOtherGlMapping(
|
||||||
'savingsInterestPaidAccountId',
|
'savingsInterestPaidAccountId',
|
||||||
RECURRING_DEPOSIT_INTEREST_PAID_ACCOUNT_NAME,
|
RECURRING_DEPOSIT_INTEREST_PAID_ACCOUNT_NAME,
|
||||||
this.liabilityAccountOptions
|
this.expenseAccountOptions
|
||||||
),
|
),
|
||||||
...map('liabilitiesOtherGlMappings', this.liabilityAccountOptions)
|
...map('expensesOtherGlMappings', this.expenseAccountOptions)
|
||||||
],
|
],
|
||||||
expenses: map('expensesOtherGlMappings', this.expenseAccountOptions),
|
|
||||||
income: map('incomeOtherGlMappings', this.incomeAccountOptions)
|
income: map('incomeOtherGlMappings', this.incomeAccountOptions)
|
||||||
};
|
};
|
||||||
return Object.values(otherGlMappings).some((v) => v.length) ? otherGlMappings : null;
|
return Object.values(otherGlMappings).some((v) => v.length) ? otherGlMappings : null;
|
||||||
@@ -393,7 +388,8 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit {
|
|||||||
},
|
},
|
||||||
// Offered on cash as well as accrual: the interest credited at maturity or premature closure is routed
|
// Offered on cash as well as accrual: the interest credited at maturity or premature closure is routed
|
||||||
// Dr Interest On Recurring Deposits / Cr Payable, Dr Payable / Cr Interest Paid, Dr Interest Paid / Cr
|
// Dr Interest On Recurring Deposits / Cr Payable, Dr Payable / Cr Interest Paid, Dr Interest Paid / Cr
|
||||||
// the RD. Optional - a product naming neither posts straight to the control account as before.
|
// the RD. Optional - a product naming neither posts straight to the control account as before. Interest
|
||||||
|
// Paid itself is under Expenses.
|
||||||
{
|
{
|
||||||
key: 'interestPayableAccountId',
|
key: 'interestPayableAccountId',
|
||||||
label: 'Interest Payable Account',
|
label: 'Interest Payable Account',
|
||||||
@@ -401,13 +397,6 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit {
|
|||||||
controlName: 'interestPayableAccountId',
|
controlName: 'interestPayableAccountId',
|
||||||
options: this.liabilityAccountOptions
|
options: this.liabilityAccountOptions
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'savingsInterestPaidAccountId',
|
|
||||||
label: 'Recurring Deposit Interest Paid',
|
|
||||||
inputLabel: 'Recurring deposit interest paid',
|
|
||||||
controlName: 'savingsInterestPaidAccountId',
|
|
||||||
options: this.liabilityAccountOptions
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'escheatLiabilityId',
|
key: 'escheatLiabilityId',
|
||||||
label: 'Escheat Liability Account',
|
label: 'Escheat Liability Account',
|
||||||
@@ -433,6 +422,13 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit {
|
|||||||
controlName: 'interestOnSavingsAccountId',
|
controlName: 'interestOnSavingsAccountId',
|
||||||
options: this.expenseAccountOptions,
|
options: this.expenseAccountOptions,
|
||||||
required: true
|
required: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'savingsInterestPaidAccountId',
|
||||||
|
label: 'Recurring Deposit Interest Paid',
|
||||||
|
inputLabel: 'Recurring deposit interest paid',
|
||||||
|
controlName: 'savingsInterestPaidAccountId',
|
||||||
|
options: this.expenseAccountOptions
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
otherMappingsControlName: 'expensesOtherGlMappings',
|
otherMappingsControlName: 'expensesOtherGlMappings',
|
||||||
|
|||||||
@@ -11,10 +11,12 @@ import { Injectable, inject } from '@angular/core';
|
|||||||
import { ActivatedRouteSnapshot } from '@angular/router';
|
import { ActivatedRouteSnapshot } from '@angular/router';
|
||||||
|
|
||||||
/** rxjs Imports */
|
/** rxjs Imports */
|
||||||
import { Observable } from 'rxjs';
|
import { Observable, of } from 'rxjs';
|
||||||
|
import { catchError, map, switchMap } from 'rxjs/operators';
|
||||||
|
|
||||||
/** Custom Services */
|
/** Custom Services */
|
||||||
import { SavingsService } from '../savings.service';
|
import { SavingsService } from '../savings.service';
|
||||||
|
import { VoucherCorrectionService } from '../../cashier/voucher-correction.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Savings Account data resolver.
|
* Savings Account data resolver.
|
||||||
@@ -22,14 +24,29 @@ import { SavingsService } from '../savings.service';
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class SavingsAccountViewResolver {
|
export class SavingsAccountViewResolver {
|
||||||
private savingsService = inject(SavingsService);
|
private savingsService = inject(SavingsService);
|
||||||
|
private voucherCorrectionService = inject(VoucherCorrectionService);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the Savings Account data.
|
* Returns the Savings Account data, without the deposits a completed voucher correction took off this account:
|
||||||
|
* the passbook and the transaction list read their rows from here, and a deposit that was posted to the wrong
|
||||||
|
* account and moved should not appear on it at all.
|
||||||
* @param {ActivatedRouteSnapshot} route Route Snapshot
|
* @param {ActivatedRouteSnapshot} route Route Snapshot
|
||||||
* @returns {Observable<any>}
|
* @returns {Observable<any>}
|
||||||
*/
|
*/
|
||||||
resolve(route: ActivatedRouteSnapshot): Observable<any> {
|
resolve(route: ActivatedRouteSnapshot): Observable<any> {
|
||||||
const savingAccountId = route.paramMap.get('savingAccountId');
|
const savingAccountId = route.paramMap.get('savingAccountId');
|
||||||
return this.savingsService.getSavingsAccountData(savingAccountId);
|
return this.savingsService.getSavingsAccountData(savingAccountId).pipe(
|
||||||
|
switchMap((account: any) =>
|
||||||
|
this.voucherCorrectionService.voidedTransactions(Number(savingAccountId)).pipe(
|
||||||
|
catchError(() => of([] as number[])),
|
||||||
|
map((voided: number[]) => {
|
||||||
|
if (voided.length && Array.isArray(account?.transactions)) {
|
||||||
|
account.transactions = account.transactions.filter((txn: any) => !voided.includes(txn.id));
|
||||||
|
}
|
||||||
|
return account;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable, OnDestroy, inject } from '@angular/core';
|
||||||
|
import { HttpClient, HttpContext, HttpDownloadProgressEvent, HttpEvent, HttpEventType } from '@angular/common/http';
|
||||||
|
import { BehaviorSubject, Subscription } from 'rxjs';
|
||||||
|
import { AuthenticationService } from '../core/authentication/authentication.service';
|
||||||
|
import { SILENT_HTTP_ERROR } from '../core/http/silent-error.token';
|
||||||
|
import { NO_PROGRESS_BAR } from '../core/progress-bar/no-progress.token';
|
||||||
|
|
||||||
|
/** What the server pushes: the total and, for each Approval Center tab with something waiting, its number. */
|
||||||
|
interface ApprovalCounts {
|
||||||
|
total: number;
|
||||||
|
counts: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** After a stream drops, the first wait before reconnecting; it doubles up to the cap. */
|
||||||
|
const RETRY_MS = 2_000;
|
||||||
|
const RETRY_MAX_MS = 60_000;
|
||||||
|
|
||||||
|
/** A stream that ends sooner than this after opening is treated as a failure. */
|
||||||
|
const MIN_STREAM_MS = 3_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many requests wait on each Approval Center tab, and the total for the Approvals menu.
|
||||||
|
*
|
||||||
|
* The numbers are pushed, not polled: the service opens one Server-Sent Events stream, GET /approvals/counts/stream, and
|
||||||
|
* the server writes a `counts` event the moment the numbers change, so a request raised or actioned by anyone moves the
|
||||||
|
* badge within about a second with no reloads and no traffic while nothing changes. The server answers numbers only
|
||||||
|
* (no rows); a tab still loads its own list when it is opened, and applies its own table, status, office scope and
|
||||||
|
* permission, so a tab the login may not see is absent.
|
||||||
|
*
|
||||||
|
* The stream goes through HttpClient (not EventSource, which cannot send the login's headers), so the usual
|
||||||
|
* authentication and tenant headers apply. It is silent (see SILENT_HTTP_ERROR, NO_PROGRESS_BAR): no error popup and no
|
||||||
|
* progress bar. The server ends a stream every few minutes and the service reconnects at once; after a failure it keeps
|
||||||
|
* the last numbers and retries with a growing wait.
|
||||||
|
*
|
||||||
|
* A browser tab that is hidden keeps its stream: it is one idle connection, and the badge is right when it is shown.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class ApprovalCountsService implements OnDestroy {
|
||||||
|
private http = inject(HttpClient);
|
||||||
|
private authenticationService = inject(AuthenticationService);
|
||||||
|
|
||||||
|
private readonly countsSubject = new BehaviorSubject<Record<string, number>>({});
|
||||||
|
private readonly totalSubject = new BehaviorSubject<number>(0);
|
||||||
|
/** Pending count per tab key; a tab with nothing waiting is absent. */
|
||||||
|
readonly counts$ = this.countsSubject.asObservable();
|
||||||
|
/** Everything waiting across all the tabs the signed-in user can see. */
|
||||||
|
readonly total$ = this.totalSubject.asObservable();
|
||||||
|
|
||||||
|
private active = false;
|
||||||
|
private failures = 0;
|
||||||
|
private stream: Subscription | null = null;
|
||||||
|
private retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
/** Whether this login reaches the Approval Center at all, so nothing is asked for everyone else. */
|
||||||
|
get canSeeApprovals(): boolean {
|
||||||
|
const permissions = this.authenticationService.getCredentials()?.permissions ?? [];
|
||||||
|
return permissions.includes('ALL_FUNCTIONS') || permissions.includes('MENU_TASKS');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opens the stream (once; a second call changes nothing). */
|
||||||
|
start(): void {
|
||||||
|
if (this.active || !this.canSeeApprovals) return;
|
||||||
|
this.active = true;
|
||||||
|
// After a page refresh the last numbers show at once; the stream's first event (a moment later) makes them exact.
|
||||||
|
this.restore();
|
||||||
|
this.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
this.active = false;
|
||||||
|
this.stream?.unsubscribe();
|
||||||
|
this.stream = null;
|
||||||
|
if (this.retryTimer) clearTimeout(this.retryTimer);
|
||||||
|
this.retryTimer = null;
|
||||||
|
this.failures = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
private connect(): void {
|
||||||
|
if (!this.active) return;
|
||||||
|
// Text already handled, so each progress event is only read from where the last one ended.
|
||||||
|
let consumed = 0;
|
||||||
|
const startedAt = Date.now();
|
||||||
|
this.stream = this.http
|
||||||
|
.get('/approvals/counts/stream', {
|
||||||
|
observe: 'events',
|
||||||
|
reportProgress: true,
|
||||||
|
responseType: 'text',
|
||||||
|
headers: { Accept: 'text/event-stream' },
|
||||||
|
context: new HttpContext().set(SILENT_HTTP_ERROR, true).set(NO_PROGRESS_BAR, true)
|
||||||
|
})
|
||||||
|
.subscribe({
|
||||||
|
next: (event: HttpEvent<string>) => {
|
||||||
|
if (event.type !== HttpEventType.DownloadProgress) return;
|
||||||
|
const text = (event as HttpDownloadProgressEvent).partialText ?? '';
|
||||||
|
consumed = this.read(text, consumed);
|
||||||
|
},
|
||||||
|
// The server closes a stream after a few minutes: connect again straight away. One that ended almost at once
|
||||||
|
// was not a stream at all (a proxy answering for it, say), so that is a failure and backs off.
|
||||||
|
complete: () => (Date.now() - startedAt < MIN_STREAM_MS ? this.retryLater() : this.connect()),
|
||||||
|
error: () => this.retryLater()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private retryLater(): void {
|
||||||
|
this.failures += 1;
|
||||||
|
// While the stream is down (an older server without it, a proxy that blocks it) the badge is still kept right by a
|
||||||
|
// plain read each time the retry comes round; the stream takes over again as soon as it connects.
|
||||||
|
this.http
|
||||||
|
.get<ApprovalCounts>('/approvals/counts', {
|
||||||
|
params: { fresh: 'true' },
|
||||||
|
context: new HttpContext().set(SILENT_HTTP_ERROR, true).set(NO_PROGRESS_BAR, true)
|
||||||
|
})
|
||||||
|
.subscribe({
|
||||||
|
next: (answer) => {
|
||||||
|
this.countsSubject.next(answer.counts ?? {});
|
||||||
|
this.totalSubject.next(answer.total ?? 0);
|
||||||
|
this.remember(answer);
|
||||||
|
},
|
||||||
|
error: () => undefined
|
||||||
|
});
|
||||||
|
const wait = Math.min(RETRY_MAX_MS, RETRY_MS * 2 ** (this.failures - 1));
|
||||||
|
this.retryTimer = setTimeout(() => this.connect(), wait);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads every complete event after `from` (events end with a blank line) and returns how far it got. */
|
||||||
|
private read(text: string, from: number): number {
|
||||||
|
let position = from;
|
||||||
|
let end = text.indexOf('\n\n', position);
|
||||||
|
while (end !== -1) {
|
||||||
|
this.handle(text.slice(position, end));
|
||||||
|
position = end + 2;
|
||||||
|
end = text.indexOf('\n\n', position);
|
||||||
|
}
|
||||||
|
return position;
|
||||||
|
}
|
||||||
|
|
||||||
|
private handle(block: string): void {
|
||||||
|
let name = 'message';
|
||||||
|
const data: string[] = [];
|
||||||
|
for (const line of block.split('\n')) {
|
||||||
|
if (line.startsWith('event:')) name = line.slice(6).trim();
|
||||||
|
else if (line.startsWith('data:')) data.push(line.slice(5).trimStart());
|
||||||
|
}
|
||||||
|
// Comment lines (the server's keep-alive pings) carry no event and are ignored.
|
||||||
|
if (name !== 'counts' || data.length === 0) return;
|
||||||
|
try {
|
||||||
|
const answer = JSON.parse(data.join('\n')) as ApprovalCounts;
|
||||||
|
this.failures = 0;
|
||||||
|
this.countsSubject.next(answer.counts ?? {});
|
||||||
|
this.totalSubject.next(answer.total ?? 0);
|
||||||
|
this.remember(answer);
|
||||||
|
} catch {
|
||||||
|
// A damaged event is skipped; the next change sends the numbers again.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Storage is per login, so one user's numbers are never shown to the next person on the same browser. */
|
||||||
|
private storageKey(): string {
|
||||||
|
return `approvalCounts:${this.authenticationService.getCredentials()?.username ?? ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private remember(answer: ApprovalCounts): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(this.storageKey(), JSON.stringify({ total: answer.total, counts: answer.counts }));
|
||||||
|
} catch {
|
||||||
|
// Storage can be blocked or full; the badge simply starts empty after a refresh.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private restore(): void {
|
||||||
|
try {
|
||||||
|
const saved = JSON.parse(localStorage.getItem(this.storageKey()) ?? 'null') as ApprovalCounts | null;
|
||||||
|
if (saved && typeof saved.total === 'number' && saved.counts) {
|
||||||
|
this.countsSubject.next(saved.counts);
|
||||||
|
this.totalSubject.next(saved.total);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Unreadable or missing: start empty and wait for the first event.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,9 @@
|
|||||||
>
|
>
|
||||||
<fa-icon icon="inbox" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="inbox" class="ac-tab__ic"></fa-icon>
|
||||||
<span>Checker Inbox</span>
|
<span>Checker Inbox</span>
|
||||||
|
@if (count('checker-inbox')) {
|
||||||
|
<span class="ac-tab__count" title="Waiting for action">{{ count('checker-inbox') }}</span>
|
||||||
|
}
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
class="ac-tab"
|
class="ac-tab"
|
||||||
@@ -40,10 +43,16 @@
|
|||||||
>
|
>
|
||||||
<fa-icon icon="user-check" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="user-check" class="ac-tab__ic"></fa-icon>
|
||||||
<span>Client Approval</span>
|
<span>Client Approval</span>
|
||||||
|
@if (count('client-approval')) {
|
||||||
|
<span class="ac-tab__count" title="Waiting for action">{{ count('client-approval') }}</span>
|
||||||
|
}
|
||||||
</a>
|
</a>
|
||||||
<a class="ac-tab" routerLink="./loan-approval" routerLinkActive="ac-tab--active" *mifosxHasPermission="'READ_LOAN'">
|
<a class="ac-tab" routerLink="./loan-approval" routerLinkActive="ac-tab--active" *mifosxHasPermission="'READ_LOAN'">
|
||||||
<fa-icon icon="hand-holding-usd" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="hand-holding-usd" class="ac-tab__ic"></fa-icon>
|
||||||
<span>Loan Approval</span>
|
<span>Loan Approval</span>
|
||||||
|
@if (count('loan-approval')) {
|
||||||
|
<span class="ac-tab__count" title="Waiting for action">{{ count('loan-approval') }}</span>
|
||||||
|
}
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
class="ac-tab"
|
class="ac-tab"
|
||||||
@@ -53,6 +62,9 @@
|
|||||||
>
|
>
|
||||||
<fa-icon icon="money-bill-wave" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="money-bill-wave" class="ac-tab__ic"></fa-icon>
|
||||||
<span>Loan Disbursal</span>
|
<span>Loan Disbursal</span>
|
||||||
|
@if (count('loan-disbursal')) {
|
||||||
|
<span class="ac-tab__count" title="Waiting for action">{{ count('loan-disbursal') }}</span>
|
||||||
|
}
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
class="ac-tab"
|
class="ac-tab"
|
||||||
@@ -62,6 +74,9 @@
|
|||||||
>
|
>
|
||||||
<fa-icon icon="calendar-alt" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="calendar-alt" class="ac-tab__ic"></fa-icon>
|
||||||
<span>Reschedule Loan</span>
|
<span>Reschedule Loan</span>
|
||||||
|
@if (count('reschedule-loan')) {
|
||||||
|
<span class="ac-tab__count" title="Waiting for action">{{ count('reschedule-loan') }}</span>
|
||||||
|
}
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
class="ac-tab"
|
class="ac-tab"
|
||||||
@@ -71,6 +86,9 @@
|
|||||||
>
|
>
|
||||||
<fa-icon icon="hourglass-half" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="hourglass-half" class="ac-tab__ic"></fa-icon>
|
||||||
<span>Pending Account</span>
|
<span>Pending Account</span>
|
||||||
|
@if (count('pending-account')) {
|
||||||
|
<span class="ac-tab__count" title="Waiting for action">{{ count('pending-account') }}</span>
|
||||||
|
}
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
class="ac-tab"
|
class="ac-tab"
|
||||||
@@ -80,6 +98,9 @@
|
|||||||
>
|
>
|
||||||
<fa-icon icon="hourglass-half" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="hourglass-half" class="ac-tab__ic"></fa-icon>
|
||||||
<span>Pending Share Accounts</span>
|
<span>Pending Share Accounts</span>
|
||||||
|
@if (count('pending-share-accounts')) {
|
||||||
|
<span class="ac-tab__count" title="Waiting for action">{{ count('pending-share-accounts') }}</span>
|
||||||
|
}
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
class="ac-tab"
|
class="ac-tab"
|
||||||
@@ -89,6 +110,9 @@
|
|||||||
>
|
>
|
||||||
<fa-icon icon="piggy-bank" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="piggy-bank" class="ac-tab__ic"></fa-icon>
|
||||||
<span>Product Approval</span>
|
<span>Product Approval</span>
|
||||||
|
@if (count('product-approval')) {
|
||||||
|
<span class="ac-tab__count" title="Waiting for action">{{ count('product-approval') }}</span>
|
||||||
|
}
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
class="ac-tab"
|
class="ac-tab"
|
||||||
@@ -98,6 +122,9 @@
|
|||||||
>
|
>
|
||||||
<fa-icon icon="edit" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="edit" class="ac-tab__ic"></fa-icon>
|
||||||
<span>Pending Account Edits</span>
|
<span>Pending Account Edits</span>
|
||||||
|
@if (count('pending-savings-account-edits')) {
|
||||||
|
<span class="ac-tab__count" title="Waiting for action">{{ count('pending-savings-account-edits') }}</span>
|
||||||
|
}
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
class="ac-tab"
|
class="ac-tab"
|
||||||
@@ -107,6 +134,9 @@
|
|||||||
>
|
>
|
||||||
<fa-icon icon="edit" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="edit" class="ac-tab__ic"></fa-icon>
|
||||||
<span>Pending Client Edits</span>
|
<span>Pending Client Edits</span>
|
||||||
|
@if (count('pending-client-edits')) {
|
||||||
|
<span class="ac-tab__count" title="Waiting for action">{{ count('pending-client-edits') }}</span>
|
||||||
|
}
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
class="ac-tab"
|
class="ac-tab"
|
||||||
@@ -116,6 +146,9 @@
|
|||||||
>
|
>
|
||||||
<fa-icon icon="user-cog" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="user-cog" class="ac-tab__ic"></fa-icon>
|
||||||
<span>Pending User Approvals</span>
|
<span>Pending User Approvals</span>
|
||||||
|
@if (count('pending-user-edits')) {
|
||||||
|
<span class="ac-tab__count" title="Waiting for action">{{ count('pending-user-edits') }}</span>
|
||||||
|
}
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
class="ac-tab"
|
class="ac-tab"
|
||||||
@@ -143,6 +176,33 @@
|
|||||||
>
|
>
|
||||||
<fa-icon icon="money-bill-wave" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="money-bill-wave" class="ac-tab__ic"></fa-icon>
|
||||||
<span>Withdrawal Requests</span>
|
<span>Withdrawal Requests</span>
|
||||||
|
@if (count('withdrawal-requests')) {
|
||||||
|
<span class="ac-tab__count" title="Waiting for action">{{ count('withdrawal-requests') }}</span>
|
||||||
|
}
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
class="ac-tab"
|
||||||
|
routerLink="./cash-transfers"
|
||||||
|
routerLinkActive="ac-tab--active"
|
||||||
|
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
|
||||||
|
>
|
||||||
|
<fa-icon icon="exchange" class="ac-tab__ic"></fa-icon>
|
||||||
|
<span>Cash Transfers</span>
|
||||||
|
@if (count('cash-transfers')) {
|
||||||
|
<span class="ac-tab__count" title="Waiting for action">{{ count('cash-transfers') }}</span>
|
||||||
|
}
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
class="ac-tab"
|
||||||
|
routerLink="./voucher-corrections"
|
||||||
|
routerLinkActive="ac-tab--active"
|
||||||
|
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
|
||||||
|
>
|
||||||
|
<fa-icon icon="undo" class="ac-tab__ic"></fa-icon>
|
||||||
|
<span>Voucher Corrections</span>
|
||||||
|
@if (count('voucher-corrections')) {
|
||||||
|
<span class="ac-tab__count" title="Waiting for action">{{ count('voucher-corrections') }}</span>
|
||||||
|
}
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
class="ac-tab"
|
class="ac-tab"
|
||||||
@@ -152,6 +212,9 @@
|
|||||||
>
|
>
|
||||||
<fa-icon icon="hand-holding-usd" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="hand-holding-usd" class="ac-tab__ic"></fa-icon>
|
||||||
<span>Share Withdrawal Requests</span>
|
<span>Share Withdrawal Requests</span>
|
||||||
|
@if (count('share-withdrawal-requests')) {
|
||||||
|
<span class="ac-tab__count" title="Waiting for action">{{ count('share-withdrawal-requests') }}</span>
|
||||||
|
}
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
class="ac-tab"
|
class="ac-tab"
|
||||||
@@ -161,6 +224,9 @@
|
|||||||
>
|
>
|
||||||
<fa-icon icon="coins" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="coins" class="ac-tab__ic"></fa-icon>
|
||||||
<span>Dividend Requests</span>
|
<span>Dividend Requests</span>
|
||||||
|
@if (count('dividend-requests')) {
|
||||||
|
<span class="ac-tab__count" title="Waiting for action">{{ count('dividend-requests') }}</span>
|
||||||
|
}
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
class="ac-tab"
|
class="ac-tab"
|
||||||
@@ -170,6 +236,9 @@
|
|||||||
>
|
>
|
||||||
<fa-icon icon="money-check-alt" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="money-check-alt" class="ac-tab__ic"></fa-icon>
|
||||||
<span>Share Addition Requests</span>
|
<span>Share Addition Requests</span>
|
||||||
|
@if (count('share-addition-requests')) {
|
||||||
|
<span class="ac-tab__count" title="Waiting for action">{{ count('share-addition-requests') }}</span>
|
||||||
|
}
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
class="ac-tab"
|
class="ac-tab"
|
||||||
@@ -179,6 +248,9 @@
|
|||||||
>
|
>
|
||||||
<fa-icon icon="hand-holding-usd" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="hand-holding-usd" class="ac-tab__ic"></fa-icon>
|
||||||
<span>Deposit Requests</span>
|
<span>Deposit Requests</span>
|
||||||
|
@if (count('deposit-requests')) {
|
||||||
|
<span class="ac-tab__count" title="Waiting for action">{{ count('deposit-requests') }}</span>
|
||||||
|
}
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
class="ac-tab"
|
class="ac-tab"
|
||||||
@@ -188,10 +260,16 @@
|
|||||||
>
|
>
|
||||||
<fa-icon icon="money-bill-wave" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="money-bill-wave" class="ac-tab__ic"></fa-icon>
|
||||||
<span>Disbursement & Transfers</span>
|
<span>Disbursement & Transfers</span>
|
||||||
|
@if (count('disbursement-requests')) {
|
||||||
|
<span class="ac-tab__count" title="Waiting for action">{{ count('disbursement-requests') }}</span>
|
||||||
|
}
|
||||||
</a>
|
</a>
|
||||||
<a class="ac-tab" routerLink="./dds-approvals" routerLinkActive="ac-tab--active">
|
<a class="ac-tab" routerLink="./dds-approvals" routerLinkActive="ac-tab--active">
|
||||||
<fa-icon icon="coins" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="coins" class="ac-tab__ic"></fa-icon>
|
||||||
<span>DDS Approvals</span>
|
<span>DDS Approvals</span>
|
||||||
|
@if (count('dds-approvals')) {
|
||||||
|
<span class="ac-tab__count" title="Waiting for action">{{ count('dds-approvals') }}</span>
|
||||||
|
}
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
class="ac-tab"
|
class="ac-tab"
|
||||||
@@ -201,6 +279,9 @@
|
|||||||
>
|
>
|
||||||
<fa-icon icon="money-check" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="money-check" class="ac-tab__ic"></fa-icon>
|
||||||
<span>Cheque Book Requests</span>
|
<span>Cheque Book Requests</span>
|
||||||
|
@if (count('chequebook-requests')) {
|
||||||
|
<span class="ac-tab__count" title="Waiting for action">{{ count('chequebook-requests') }}</span>
|
||||||
|
}
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
class="ac-tab"
|
class="ac-tab"
|
||||||
@@ -210,26 +291,41 @@
|
|||||||
>
|
>
|
||||||
<fa-icon icon="lock" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="lock" class="ac-tab__ic"></fa-icon>
|
||||||
<span>Closure Requests</span>
|
<span>Closure Requests</span>
|
||||||
|
@if (count('closure-requests')) {
|
||||||
|
<span class="ac-tab__count" title="Waiting for action">{{ count('closure-requests') }}</span>
|
||||||
|
}
|
||||||
</a>
|
</a>
|
||||||
<a class="ac-tab" routerLink="./dd-issue-approvals" routerLinkActive="ac-tab--active">
|
<a class="ac-tab" routerLink="./dd-issue-approvals" routerLinkActive="ac-tab--active">
|
||||||
<fa-icon icon="receipt" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="receipt" class="ac-tab__ic"></fa-icon>
|
||||||
<span>DD Approvals</span>
|
<span>DD Approvals</span>
|
||||||
|
@if (count('dd-issue-approvals')) {
|
||||||
|
<span class="ac-tab__count" title="Waiting for action">{{ count('dd-issue-approvals') }}</span>
|
||||||
|
}
|
||||||
</a>
|
</a>
|
||||||
<a class="ac-tab" routerLink="./dd-reprint-approvals" routerLinkActive="ac-tab--active">
|
<a class="ac-tab" routerLink="./dd-reprint-approvals" routerLinkActive="ac-tab--active">
|
||||||
<fa-icon icon="print" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="print" class="ac-tab__ic"></fa-icon>
|
||||||
<span>DD Reprint Requests</span>
|
<span>DD Reprint Requests</span>
|
||||||
|
@if (count('dd-reprint-approvals')) {
|
||||||
|
<span class="ac-tab__count" title="Waiting for action">{{ count('dd-reprint-approvals') }}</span>
|
||||||
|
}
|
||||||
</a>
|
</a>
|
||||||
<!-- Beside the reprint queue because the two arrive together from the counter, but they are
|
<!-- Beside the reprint queue because the two arrive together from the counter, but they are
|
||||||
different decisions: a reprint re-prints the same leaflet, a revoke spends a fresh one. -->
|
different decisions: a reprint re-prints the same leaflet, a revoke spends a fresh one. -->
|
||||||
<a class="ac-tab" routerLink="./dd-revoke-approvals" routerLinkActive="ac-tab--active">
|
<a class="ac-tab" routerLink="./dd-revoke-approvals" routerLinkActive="ac-tab--active">
|
||||||
<fa-icon icon="ban" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="ban" class="ac-tab__ic"></fa-icon>
|
||||||
<span>DD Revoke Requests</span>
|
<span>DD Revoke Requests</span>
|
||||||
|
@if (count('dd-revoke-approvals')) {
|
||||||
|
<span class="ac-tab__count" title="Waiting for action">{{ count('dd-revoke-approvals') }}</span>
|
||||||
|
}
|
||||||
</a>
|
</a>
|
||||||
<!-- Raised from the Risk Review Report. Approving moves the customer's KYC rating and their
|
<!-- Raised from the Risk Review Report. Approving moves the customer's KYC rating and their
|
||||||
re-KYC due date together, so it belongs with the other maker-checker queues. -->
|
re-KYC due date together, so it belongs with the other maker-checker queues. -->
|
||||||
<a class="ac-tab" routerLink="./risk-change-approvals" routerLinkActive="ac-tab--active">
|
<a class="ac-tab" routerLink="./risk-change-approvals" routerLinkActive="ac-tab--active">
|
||||||
<fa-icon icon="shield-alt" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="shield-alt" class="ac-tab__ic"></fa-icon>
|
||||||
<span>Risk Category Changes</span>
|
<span>Risk Category Changes</span>
|
||||||
|
@if (count('risk-change-approvals')) {
|
||||||
|
<span class="ac-tab__count" title="Waiting for action">{{ count('risk-change-approvals') }}</span>
|
||||||
|
}
|
||||||
</a>
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ $green: #10b981;
|
|||||||
$amber: #f59e0b;
|
$amber: #f59e0b;
|
||||||
|
|
||||||
.ac-container {
|
.ac-container {
|
||||||
padding: 20px 24px;
|
padding: 12px 20px;
|
||||||
max-width: 1440px;
|
max-width: 1440px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
@@ -25,40 +25,40 @@ $amber: #f59e0b;
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 10px;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|
||||||
&__left {
|
&__left {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__icon {
|
&__icon {
|
||||||
width: 44px;
|
width: 32px;
|
||||||
height: 44px;
|
height: 32px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
border-radius: 12px;
|
border-radius: 9px;
|
||||||
background: linear-gradient(135deg, $blue 0%, $purple 100%);
|
background: linear-gradient(135deg, $blue 0%, $purple 100%);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 18px;
|
font-size: 14px;
|
||||||
box-shadow: 0 2px 8px rgb(29 78 216 / 25%);
|
box-shadow: 0 2px 8px rgb(29 78 216 / 25%);
|
||||||
}
|
}
|
||||||
|
|
||||||
&__title {
|
&__title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 20px;
|
font-size: 16px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: $navy;
|
color: $navy;
|
||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__sub {
|
&__sub {
|
||||||
margin-top: 2px;
|
margin-top: 1px;
|
||||||
font-size: 12.5px;
|
font-size: 11px;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -68,26 +68,26 @@ $amber: #f59e0b;
|
|||||||
hides most of them behind prev/next pagination arrows. Wrapping keeps every tab visible and one
|
hides most of them behind prev/next pagination arrows. Wrapping keeps every tab visible and one
|
||||||
click away instead of a scroll-and-hunt. */
|
click away instead of a scroll-and-hunt. */
|
||||||
.ac-tabs {
|
.ac-tabs {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 12px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #e2e8f0;
|
||||||
border-radius: 14px;
|
border-radius: 10px;
|
||||||
padding: 6px;
|
padding: 4px;
|
||||||
box-shadow: 0 1px 3px rgb(15 23 42 / 4%);
|
box-shadow: 0 1px 3px rgb(15 23 42 / 4%);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 4px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ac-tab {
|
.ac-tab {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
height: 38px;
|
height: 28px;
|
||||||
padding: 0 18px;
|
padding: 0 11px;
|
||||||
border-radius: 10px;
|
border-radius: 8px;
|
||||||
font-family: Inter, 'DM Sans', Roboto, sans-serif;
|
font-family: Inter, 'DM Sans', Roboto, sans-serif;
|
||||||
font-size: 13px;
|
font-size: 11.5px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.01em;
|
letter-spacing: 0.01em;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
@@ -99,15 +99,34 @@ $amber: #f59e0b;
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
&__ic {
|
&__ic {
|
||||||
font-size: 13px;
|
font-size: 11px;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// How many requests wait on the tab.
|
||||||
|
&__count {
|
||||||
|
min-width: 17px;
|
||||||
|
height: 17px;
|
||||||
|
padding: 0 5px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f59e0b;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 17px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
&:hover:not(.ac-tab--active) {
|
&:hover:not(.ac-tab--active) {
|
||||||
background: #f1f5f9;
|
background: #f1f5f9;
|
||||||
color: $navy;
|
color: $navy;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.ac-tab--active .ac-tab__count {
|
||||||
|
background: #fff;
|
||||||
|
color: $blue-d;
|
||||||
|
}
|
||||||
|
|
||||||
&.ac-tab--active {
|
&.ac-tab--active {
|
||||||
background: linear-gradient(135deg, $blue 0%, $blue-d 100%);
|
background: linear-gradient(135deg, $blue 0%, $blue-d 100%);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
@@ -118,7 +137,7 @@ $amber: #f59e0b;
|
|||||||
|
|
||||||
@media (width <= 768px) {
|
@media (width <= 768px) {
|
||||||
.ac-tab {
|
.ac-tab {
|
||||||
padding: 0 14px;
|
padding: 0 10px;
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/** Angular Imports */
|
/** Angular Imports */
|
||||||
import { Component } from '@angular/core';
|
import { Component, OnDestroy, OnInit, inject } from '@angular/core';
|
||||||
import { RouterLinkActive, RouterOutlet } from '@angular/router';
|
import { RouterLinkActive, RouterOutlet } from '@angular/router';
|
||||||
|
import { Subscription } from 'rxjs';
|
||||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
import { ApprovalCountsService } from '../approval-counts.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Approval Center — checker inbox + client/loan approval workflows.
|
* Approval Center — checker inbox + client/loan approval workflows.
|
||||||
@@ -31,6 +33,24 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
|||||||
FaIconComponent
|
FaIconComponent
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class CheckerInboxAndTasksComponent {
|
export class CheckerInboxAndTasksComponent implements OnInit, OnDestroy {
|
||||||
constructor() {}
|
private counts = inject(ApprovalCountsService);
|
||||||
|
|
||||||
|
/** How many requests wait on each tab, by the tab's route segment; a tab with none is absent. */
|
||||||
|
pending: Record<string, number> = {};
|
||||||
|
private subscriptions = new Subscription();
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.subscriptions.add(this.counts.counts$.subscribe((c) => (this.pending = c)));
|
||||||
|
// The same live counts the Approvals menu badge listens to; they move as requests are raised or actioned.
|
||||||
|
this.counts.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
count(key: string): number {
|
||||||
|
return this.pending[key] ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.subscriptions.unsubscribe();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -194,6 +194,22 @@ const routes: Routes = [
|
|||||||
component: ShareWithdrawalRequestsComponent,
|
component: ShareWithdrawalRequestsComponent,
|
||||||
data: { title: 'Share Withdrawal Requests' }
|
data: { title: 'Share Withdrawal Requests' }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'cash-transfers',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('../cashier/cash-transfer-approvals/cash-transfer-approvals.component').then(
|
||||||
|
(m) => m.CashTransferApprovalsComponent
|
||||||
|
),
|
||||||
|
data: { title: 'Cash Transfers' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'voucher-corrections',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('../cashier/voucher-correction-approvals/voucher-correction-approvals.component').then(
|
||||||
|
(m) => m.VoucherCorrectionApprovalsComponent
|
||||||
|
),
|
||||||
|
data: { title: 'Voucher Corrections' }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'dividend-requests',
|
path: 'dividend-requests',
|
||||||
component: DividendRequestsComponent,
|
component: DividendRequestsComponent,
|
||||||
|
|||||||
@@ -45,15 +45,17 @@
|
|||||||
"hasMask": false,
|
"hasMask": false,
|
||||||
"ao": 0,
|
"ao": 0,
|
||||||
"ks": { "a": { "a": 0, "k": [
|
"ks": { "a": { "a": 0, "k": [
|
||||||
-17.052704811096184,
|
0,
|
||||||
-15.0467209815979
|
0,
|
||||||
|
0
|
||||||
], "ix": 1 }, "s": { "a": 0, "k": [
|
], "ix": 1 }, "s": { "a": 0, "k": [
|
||||||
362.228,
|
394,
|
||||||
362.228,
|
394,
|
||||||
100
|
100
|
||||||
], "ix": 6 }, "sk": { "a": 0, "k": 0 }, "p": { "a": 0, "k": [
|
], "ix": 6 }, "sk": { "a": 0, "k": 0 }, "p": { "a": 0, "k": [
|
||||||
188.2303284168625,
|
250,
|
||||||
195.49656352277756
|
250,
|
||||||
|
0
|
||||||
], "ix": 2 }, "r": { "a": 0, "k": 0, "ix": 10 }, "sa": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100, "ix": 11 } },
|
], "ix": 2 }, "r": { "a": 0, "k": 0, "ix": 10 }, "sa": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100, "ix": 11 } },
|
||||||
"shapes": [
|
"shapes": [
|
||||||
{
|
{
|
||||||
@@ -341,16 +343,16 @@
|
|||||||
"g": { "p": 3, "k": { "a": 0, "k": [
|
"g": { "p": 3, "k": { "a": 0, "k": [
|
||||||
0,
|
0,
|
||||||
1,
|
1,
|
||||||
0.4470588235294118,
|
0.6,
|
||||||
0,
|
0.2,
|
||||||
0.485,
|
0.485,
|
||||||
0.9725490196078431,
|
|
||||||
0.2235294117647059,
|
|
||||||
0.5019607843137255,
|
|
||||||
1,
|
1,
|
||||||
0.9411764705882353,
|
0.6,
|
||||||
0,
|
0.2,
|
||||||
1
|
1,
|
||||||
|
1,
|
||||||
|
0.6,
|
||||||
|
0.2
|
||||||
], "ix": 8 } },
|
], "ix": 8 } },
|
||||||
"t": 1,
|
"t": 1,
|
||||||
"a": { "a": 0, "k": 0 },
|
"a": { "a": 0, "k": 0 },
|
||||||
@@ -363,7 +365,7 @@
|
|||||||
"lj": 1,
|
"lj": 1,
|
||||||
"ml": 4,
|
"ml": 4,
|
||||||
"o": { "a": 0, "k": 100, "ix": 9 },
|
"o": { "a": 0, "k": 100, "ix": 9 },
|
||||||
"w": { "a": 0, "k": 2, "ix": 10 }
|
"w": { "a": 0, "k": 2.5, "ix": 10 }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ty": "fl",
|
"ty": "fl",
|
||||||
@@ -402,8 +404,8 @@
|
|||||||
"o": {
|
"o": {
|
||||||
"a": 1,
|
"a": 1,
|
||||||
"k": [
|
"k": [
|
||||||
{ "o": { "x": 0.167, "y": 0.167 }, "i": { "x": 0.833, "y": 0.833 }, "s": [0], "t": 0 },
|
{ "o": { "x": 0.167, "y": 0.167 }, "i": { "x": 0.833, "y": 0.833 }, "s": [28], "t": 0 },
|
||||||
{ "s": [360], "t": 90.0000036657751 }
|
{ "s": [388], "t": 90.0000036657751 }
|
||||||
],
|
],
|
||||||
"ix": 3
|
"ix": 3
|
||||||
},
|
},
|
||||||
@@ -437,7 +439,7 @@
|
|||||||
250,
|
250,
|
||||||
250,
|
250,
|
||||||
0
|
0
|
||||||
], "ix": 2 }, "r": { "a": 0, "k": 60, "ix": 10 }, "sa": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100, "ix": 11 } },
|
], "ix": 2 }, "r": { "a": 0, "k": 0, "ix": 10 }, "sa": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100, "ix": 11 } },
|
||||||
"shapes": [
|
"shapes": [
|
||||||
{
|
{
|
||||||
"ty": "gr",
|
"ty": "gr",
|
||||||
@@ -723,16 +725,16 @@
|
|||||||
], "ix": 5 },
|
], "ix": 5 },
|
||||||
"g": { "p": 3, "k": { "a": 0, "k": [
|
"g": { "p": 3, "k": { "a": 0, "k": [
|
||||||
0,
|
0,
|
||||||
0,
|
1,
|
||||||
0.7764705882352941,
|
1,
|
||||||
1,
|
1,
|
||||||
0.485,
|
0.485,
|
||||||
0.24705882352941178,
|
|
||||||
0.38823529411764707,
|
|
||||||
1,
|
1,
|
||||||
1,
|
1,
|
||||||
0.49411764705882355,
|
1,
|
||||||
0,
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
1
|
1
|
||||||
], "ix": 8 } },
|
], "ix": 8 } },
|
||||||
"t": 1,
|
"t": 1,
|
||||||
@@ -746,7 +748,7 @@
|
|||||||
"lj": 1,
|
"lj": 1,
|
||||||
"ml": 4,
|
"ml": 4,
|
||||||
"o": { "a": 0, "k": 100, "ix": 9 },
|
"o": { "a": 0, "k": 100, "ix": 9 },
|
||||||
"w": { "a": 0, "k": 0.5, "ix": 10 }
|
"w": { "a": 0, "k": 2.5, "ix": 10 }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ty": "fl",
|
"ty": "fl",
|
||||||
@@ -813,14 +815,14 @@
|
|||||||
0,
|
0,
|
||||||
0
|
0
|
||||||
], "ix": 1 }, "s": { "a": 0, "k": [
|
], "ix": 1 }, "s": { "a": 0, "k": [
|
||||||
362.228,
|
330,
|
||||||
362.228,
|
330,
|
||||||
100
|
100
|
||||||
], "ix": 6 }, "sk": { "a": 0, "k": 0 }, "p": { "a": 0, "k": [
|
], "ix": 6 }, "sk": { "a": 0, "k": 0 }, "p": { "a": 0, "k": [
|
||||||
250,
|
250,
|
||||||
250,
|
250,
|
||||||
0
|
0
|
||||||
], "ix": 2 }, "r": { "a": 0, "k": 30, "ix": 10 }, "sa": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100, "ix": 11 } },
|
], "ix": 2 }, "r": { "a": 0, "k": 0, "ix": 10 }, "sa": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100, "ix": 11 } },
|
||||||
"shapes": [
|
"shapes": [
|
||||||
{
|
{
|
||||||
"ty": "gr",
|
"ty": "gr",
|
||||||
@@ -1106,17 +1108,17 @@
|
|||||||
], "ix": 5 },
|
], "ix": 5 },
|
||||||
"g": { "p": 3, "k": { "a": 0, "k": [
|
"g": { "p": 3, "k": { "a": 0, "k": [
|
||||||
0,
|
0,
|
||||||
1,
|
0.0784,
|
||||||
0.8235294117647058,
|
0.7059,
|
||||||
0,
|
0.2118,
|
||||||
0.485,
|
0.485,
|
||||||
0.5019607843137255,
|
0.0784,
|
||||||
0.8117647058823529,
|
0.7059,
|
||||||
0.5019607843137255,
|
0.2118,
|
||||||
1,
|
1,
|
||||||
0,
|
0.0784,
|
||||||
0.8,
|
0.7059,
|
||||||
1
|
0.2118
|
||||||
], "ix": 8 } },
|
], "ix": 8 } },
|
||||||
"t": 1,
|
"t": 1,
|
||||||
"a": { "a": 0, "k": 0 },
|
"a": { "a": 0, "k": 0 },
|
||||||
@@ -1129,7 +1131,7 @@
|
|||||||
"lj": 1,
|
"lj": 1,
|
||||||
"ml": 4,
|
"ml": 4,
|
||||||
"o": { "a": 0, "k": 100, "ix": 9 },
|
"o": { "a": 0, "k": 100, "ix": 9 },
|
||||||
"w": { "a": 0, "k": 0.5, "ix": 10 }
|
"w": { "a": 0, "k": 2.5, "ix": 10 }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ty": "fl",
|
"ty": "fl",
|
||||||
@@ -1168,8 +1170,8 @@
|
|||||||
"o": {
|
"o": {
|
||||||
"a": 1,
|
"a": 1,
|
||||||
"k": [
|
"k": [
|
||||||
{ "o": { "x": 0.167, "y": 0.167 }, "i": { "x": 0.833, "y": 0.833 }, "s": [0], "t": 0 },
|
{ "o": { "x": 0.167, "y": 0.167 }, "i": { "x": 0.833, "y": 0.833 }, "s": [-28], "t": 0 },
|
||||||
{ "s": [360], "t": 90.0000036657751 }
|
{ "s": [332], "t": 90.0000036657751 }
|
||||||
],
|
],
|
||||||
"ix": 3
|
"ix": 3
|
||||||
},
|
},
|
||||||
|
|||||||
+6
-6
@@ -325,10 +325,10 @@
|
|||||||
};
|
};
|
||||||
document.head.appendChild(script);
|
document.head.appendChild(script);
|
||||||
|
|
||||||
// Progress simulation stages (Guaranteed ~2 seconds duration on refresh)
|
// Progress simulation stages (Guaranteed ~4 seconds duration on refresh: 2% every 90ms reaches 90% in about 4s)
|
||||||
var interval = setInterval(function () {
|
var interval = setInterval(function () {
|
||||||
if (progress < 90) {
|
if (progress < 90) {
|
||||||
progress += 5;
|
progress += 2;
|
||||||
} else if (angularReady && progress < 100) {
|
} else if (angularReady && progress < 100) {
|
||||||
progress += 5;
|
progress += 5;
|
||||||
}
|
}
|
||||||
@@ -351,7 +351,7 @@
|
|||||||
}
|
}
|
||||||
}, 90);
|
}, 90);
|
||||||
|
|
||||||
// Enforce minimum hold time (2000ms = 2 seconds) so refresh always shows the animation
|
// Enforce minimum hold time (4000ms = 4 seconds) so refresh always shows the animation
|
||||||
setTimeout(function () {
|
setTimeout(function () {
|
||||||
minTimePassed = true;
|
minTimePassed = true;
|
||||||
if (angularReady) {
|
if (angularReady) {
|
||||||
@@ -359,7 +359,7 @@
|
|||||||
if (bar) bar.style.width = '100%';
|
if (bar) bar.style.width = '100%';
|
||||||
dismissSplash();
|
dismissSplash();
|
||||||
}
|
}
|
||||||
}, 2000);
|
}, 4000);
|
||||||
|
|
||||||
function dismissSplash() {
|
function dismissSplash() {
|
||||||
if (dismissed) return;
|
if (dismissed) return;
|
||||||
@@ -387,12 +387,12 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Safety fallback (Max 7s in case of backend network failure)
|
// Safety fallback (Max 9s in case of backend network failure)
|
||||||
setTimeout(function () {
|
setTimeout(function () {
|
||||||
angularReady = true;
|
angularReady = true;
|
||||||
minTimePassed = true;
|
minTimePassed = true;
|
||||||
dismissSplash();
|
dismissSplash();
|
||||||
}, 7000);
|
}, 9000);
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user