Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fca550f4c4 | ||
|
|
3e5cc26e49 | ||
|
|
c3713f8a90 | ||
|
|
f804d356d7 | ||
|
|
1daa8e60a0 | ||
|
|
55f5c1cf32 | ||
|
|
6507e68203 | ||
|
|
4d205672df | ||
|
|
ab8ed7e67e | ||
|
|
7ee230e58a |
@@ -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>
|
||||
<p class="pg-sub">Move cash denomination-wise from one party to another (e.g. Vault → Cashier for opening).</p>
|
||||
</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>
|
||||
|
||||
<div class="pg-toolbar pg-toolbar--mat">
|
||||
@@ -155,7 +163,56 @@
|
||||
<div class="pg-total-bar">
|
||||
Total to transfer: <strong>₹{{ total | number: '1.2-2' }}</strong>
|
||||
<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>
|
||||
</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;
|
||||
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/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
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 { MatFormFieldModule } from '@angular/material/form-field';
|
||||
@@ -39,6 +40,7 @@ interface Denom {
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatIconModule,
|
||||
MatDialogModule,
|
||||
MatSnackBarModule,
|
||||
MatFormFieldModule,
|
||||
MatSelectModule,
|
||||
@@ -48,6 +50,10 @@ interface Denom {
|
||||
export class CashTransferComponent implements OnInit {
|
||||
private service = inject(CashierService);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
private dialog = inject(MatDialog);
|
||||
|
||||
@ViewChild('requestsDialog') requestsDialog!: TemplateRef<unknown>;
|
||||
private requestsRef: MatDialogRef<unknown> | null = null;
|
||||
|
||||
notes: Denom[] = [];
|
||||
coins: Denom[] = [];
|
||||
@@ -82,7 +88,34 @@ export class CashTransferComponent implements OnInit {
|
||||
vaultHasBreakdown = true;
|
||||
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 {
|
||||
this.loadMyRequests();
|
||||
const d = businessToday();
|
||||
this.transferDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
this.service.getActiveDenominations().subscribe({
|
||||
@@ -245,16 +278,11 @@ export class CashTransferComponent implements OnInit {
|
||||
.filter((d) => this.countFor(d) > 0)
|
||||
.map((d) => ({ denominationId: d.id, denominationValue: d.value, count: this.countFor(d) }))
|
||||
};
|
||||
this.service.createCashTransfer(payload).subscribe({
|
||||
next: (res: any) => {
|
||||
if (res?.glWarning) {
|
||||
this.snackBar.open(`Transfer saved (GL warning: ${res.glWarning})`, 'Close', {
|
||||
duration: 8000,
|
||||
panelClass: ['snack-warn']
|
||||
});
|
||||
} else {
|
||||
this.snackBar.open('Cash transfer recorded', 'Close', { duration: 3000 });
|
||||
}
|
||||
this.service.createCashTransferRequest(payload).subscribe({
|
||||
next: () => {
|
||||
// Nothing has moved yet: a manager approves the allocation first, and only then does the cash leave the vault.
|
||||
this.snackBar.open('Cash transfer sent for manager approval', 'Close', { duration: 5000 });
|
||||
this.loadMyRequests();
|
||||
this.clear();
|
||||
this.note = '';
|
||||
this.fromParty = '';
|
||||
|
||||
@@ -83,6 +83,13 @@ const routes: Routes = [
|
||||
),
|
||||
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',
|
||||
loadComponent: () =>
|
||||
@@ -291,6 +298,12 @@ const routes: Routes = [
|
||||
component: ReconciliationComponent,
|
||||
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',
|
||||
component: CashHistoryComponent,
|
||||
|
||||
@@ -47,8 +47,21 @@ export class CashierService {
|
||||
}
|
||||
|
||||
// ── Cash Transfer ──
|
||||
createCashTransfer(data: any): Observable<any> {
|
||||
return this.http.post('/cashtransfer', data);
|
||||
/** Cash leaves the vault only when a different person approves, so the screen raises a request, not a transfer. */
|
||||
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> {
|
||||
const params = new HttpParams().set('offset', offset).set('limit', limit);
|
||||
|
||||
@@ -202,6 +202,7 @@
|
||||
<th class="num">Amount</th>
|
||||
<th>Payment</th>
|
||||
<th>Status</th>
|
||||
<th>Correction</th>
|
||||
<th>By</th>
|
||||
<th>Approved By</th>
|
||||
<th>Time</th>
|
||||
@@ -231,6 +232,18 @@
|
||||
<td>
|
||||
<span [class]="statusClass(r.status)">{{ r.status }}</span>
|
||||
</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.approvedBy ?? '—' }}
|
||||
@@ -256,7 +269,7 @@
|
||||
—
|
||||
}
|
||||
</td>
|
||||
<td colspan="5"></td>
|
||||
<td colspan="6"></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Router } from '@angular/router';
|
||||
import { catchError, of } from 'rxjs';
|
||||
import { CashierService } from '../cashier.service';
|
||||
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.
|
||||
@@ -26,6 +27,11 @@ type DayBookSection = 'CASHIER' | 'CLERK' | 'SYSTEM';
|
||||
|
||||
interface DayBookRow {
|
||||
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;
|
||||
side: 'CR' | 'DR';
|
||||
kind: string;
|
||||
@@ -58,7 +64,8 @@ interface DayBookRow {
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatIconModule
|
||||
MatIconModule,
|
||||
VoucherStatusComponent
|
||||
]
|
||||
})
|
||||
export class DayBookComponent implements OnInit {
|
||||
|
||||
@@ -146,6 +146,7 @@
|
||||
<th class="num">Amount</th>
|
||||
<th>My part</th>
|
||||
<th>Status</th>
|
||||
<th>Correction</th>
|
||||
<th>Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -203,6 +204,18 @@
|
||||
{{ statusLabel(r.verificationStatus) }}
|
||||
</span>
|
||||
</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>
|
||||
</tr>
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { catchError, finalize, of } from 'rxjs';
|
||||
import { CashierService } from '../cashier.service';
|
||||
import { VoucherDetailDialogComponent } from './voucher-detail-dialog.component';
|
||||
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. */
|
||||
export type MyRole = 'RAISED' | 'VERIFIED' | 'RAISED_AND_VERIFIED';
|
||||
@@ -24,6 +25,11 @@ export type VoucherSide = 'CR' | 'DR' | 'JV';
|
||||
|
||||
interface MyWorkRow {
|
||||
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;
|
||||
voucherType: string | null;
|
||||
amount: number | null;
|
||||
@@ -76,7 +82,8 @@ interface MyWorkReport {
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatIconModule,
|
||||
MatDialogModule
|
||||
MatDialogModule,
|
||||
VoucherStatusComponent
|
||||
]
|
||||
})
|
||||
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 { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { VoucherStatusComponent, VoucherStatus } from '../voucher-status/voucher-status.component';
|
||||
|
||||
export interface VoucherDetailDialogData {
|
||||
/** 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. */
|
||||
detail: {
|
||||
voucherNo: string;
|
||||
voucherStatus?: VoucherStatus;
|
||||
revokedByVoucherNo?: string | null;
|
||||
replacesVoucherNo?: string | null;
|
||||
correctionNote?: string | null;
|
||||
voucherType: string | null;
|
||||
amount: number | null;
|
||||
branchName: string | null;
|
||||
@@ -58,7 +63,8 @@ export interface VoucherDetailDialogData {
|
||||
CommonModule,
|
||||
MatDialogModule,
|
||||
MatButtonModule,
|
||||
MatIconModule
|
||||
MatIconModule,
|
||||
VoucherStatusComponent
|
||||
],
|
||||
template: `
|
||||
<div class="vd-shell">
|
||||
@@ -66,7 +72,15 @@ export interface VoucherDetailDialogData {
|
||||
<div class="vd-header__title">
|
||||
<mat-icon class="vd-icon">receipt_long</mat-icon>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -129,11 +129,16 @@
|
||||
</div>
|
||||
<div class="ws-f ws-f--branch">
|
||||
<label class="ws-f__lbl" title="Branch">BRANCH</label>
|
||||
<select class="ws-f__ctl" [(ngModel)]="row.br" (click)="$event.stopPropagation()">
|
||||
<option [ngValue]="1">001 · Main</option>
|
||||
<option [ngValue]="2">002 · M.G. Road</option>
|
||||
<option [ngValue]="14">014 · Station Rd</option>
|
||||
<option [ngValue]="27">027 · Industrial</option>
|
||||
<select
|
||||
class="ws-f__ctl"
|
||||
[(ngModel)]="row.br"
|
||||
[disabled]="branches.length <= 1"
|
||||
[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>
|
||||
</div>
|
||||
@if (row.typ === 'DD') {
|
||||
@@ -179,7 +184,7 @@
|
||||
class="ws-f__ctl"
|
||||
[(ngModel)]="row.acTyp"
|
||||
(click)="$event.stopPropagation()"
|
||||
(change)="isGlCreditOnly(row) && (row.dbCr = 'Cr')"
|
||||
(change)="isCreditOnly(row) && (row.dbCr = 'Cr')"
|
||||
>
|
||||
@for (a of acTypOptionsFor(row); track a) {
|
||||
<option [value]="a">{{ a }}</option>
|
||||
@@ -262,7 +267,7 @@
|
||||
type="button"
|
||||
class="ws-seg__btn ws-seg__btn--dr"
|
||||
[class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'"
|
||||
[disabled]="isGlCreditOnly(row)"
|
||||
[disabled]="isCreditOnly(row)"
|
||||
(click)="row.dbCr = 'Dr'"
|
||||
>
|
||||
DR
|
||||
@@ -1584,11 +1589,16 @@
|
||||
</div>
|
||||
<div class="ws-f ws-f--branch">
|
||||
<label class="ws-f__lbl">BRANCH</label>
|
||||
<select class="ws-f__ctl" [(ngModel)]="row.br" (click)="$event.stopPropagation()">
|
||||
<option [ngValue]="1">001 · Main</option>
|
||||
<option [ngValue]="2">002 · M.G. Road</option>
|
||||
<option [ngValue]="14">014 · Station Rd</option>
|
||||
<option [ngValue]="27">027 · Industrial</option>
|
||||
<select
|
||||
class="ws-f__ctl"
|
||||
[(ngModel)]="row.br"
|
||||
[disabled]="branches.length <= 1"
|
||||
[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>
|
||||
</div>
|
||||
@if (row.typ === 'DD') {
|
||||
@@ -1634,7 +1644,7 @@
|
||||
class="ws-f__ctl"
|
||||
[(ngModel)]="row.acTyp"
|
||||
(click)="$event.stopPropagation()"
|
||||
(change)="isGlCreditOnly(row) && (row.dbCr = 'Cr')"
|
||||
(change)="isCreditOnly(row) && (row.dbCr = 'Cr')"
|
||||
>
|
||||
@for (a of acTypOptionsFor(row); track a) {
|
||||
<option [value]="a">{{ a }}</option>
|
||||
@@ -1705,7 +1715,7 @@
|
||||
type="button"
|
||||
class="ws-seg__btn ws-seg__btn--dr"
|
||||
[class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'"
|
||||
[disabled]="isGlCreditOnly(row)"
|
||||
[disabled]="isCreditOnly(row)"
|
||||
(click)="$event.stopPropagation(); row.dbCr = 'Dr'"
|
||||
>
|
||||
DR
|
||||
|
||||
@@ -1877,6 +1877,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
ngOnInit(): void {
|
||||
const d = businessToday();
|
||||
this.today = `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`;
|
||||
this.loadBranches();
|
||||
this.addRow();
|
||||
this.loadDenominations();
|
||||
this.loadOpeningPosition();
|
||||
@@ -2309,7 +2310,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
// anyway, just without the extra click to get there.
|
||||
const row: TxnRow = {
|
||||
typ: this.isClerk ? 'Withdrawal Slip' : 'Receipt',
|
||||
br: 1,
|
||||
br: this.defaultBranchId,
|
||||
trTp: '',
|
||||
acTyp: 'SD',
|
||||
acNo: '',
|
||||
@@ -2343,6 +2344,20 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 */
|
||||
/**
|
||||
* A cashier row that was filled from an approved token (withdrawal / account-closure) is cash
|
||||
@@ -2655,14 +2670,47 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
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 {
|
||||
const map: Record<number, string> = {
|
||||
1: '001 · Main',
|
||||
2: '002 · M.G. Road',
|
||||
14: '014 · Station Rd',
|
||||
27: '027 · Industrial'
|
||||
};
|
||||
return map[branchId] || `Branch ${branchId}`;
|
||||
return this.branches.find((b) => b.id === branchId)?.label ?? `Branch ${branchId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -4012,8 +4060,11 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((res: any) => {
|
||||
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;
|
||||
// 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
|
||||
// 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());
|
||||
@@ -4043,7 +4094,18 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
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}`;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4107,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
|
||||
* for a transaction; a Header can never receive a posting, so it should never be offered. */
|
||||
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();
|
||||
if (t === 'LA') {
|
||||
@@ -4187,6 +4253,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
return {
|
||||
list: '/savingsaccounts',
|
||||
detail: '/fixeddepositaccounts',
|
||||
depositTypeId: 200,
|
||||
nominee: 'FD',
|
||||
search: 'fixeddeposit',
|
||||
siAccountType: 2,
|
||||
@@ -4204,6 +4271,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
return {
|
||||
list: '/savingsaccounts',
|
||||
detail: '/recurringdepositaccounts',
|
||||
depositTypeId: 300,
|
||||
nominee: 'RD',
|
||||
search: 'recurringdeposit',
|
||||
siAccountType: 2,
|
||||
@@ -4221,6 +4289,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
return {
|
||||
list: '/savingsaccounts',
|
||||
detail: '/savingsaccounts',
|
||||
depositTypeId: 100,
|
||||
nominee: 'SB',
|
||||
search: 'saving',
|
||||
siAccountType: 2,
|
||||
@@ -5238,6 +5307,20 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
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
|
||||
// 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.
|
||||
@@ -5315,7 +5398,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
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([
|
||||
'SD',
|
||||
'CA',
|
||||
@@ -5323,13 +5408,13 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
]);
|
||||
const invalidAccounts = [
|
||||
...drRows,
|
||||
...crRows
|
||||
...crRows.filter((r) => r.acTyp !== 'FD')
|
||||
].filter((r) => !allowedTypes.has(r.acTyp));
|
||||
if (invalidAccounts.length > 0) {
|
||||
this.showAlert('error', 'Invalid Account Type', [
|
||||
`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.',
|
||||
'GL, FD and SHR accounts are not allowed — use Disbursement/Collection instead.',
|
||||
'Debit side: only Savings (SD) and Current (CA) accounts. Credit side: SD, CA or a Fixed Deposit (FD).',
|
||||
'GL and SHR accounts are not allowed — use Disbursement/Collection instead.',
|
||||
'RD installments are paid through a cashier Receipt or Auto Debit.'
|
||||
]);
|
||||
return;
|
||||
|
||||
+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>Credit</th>
|
||||
<th class="num">Amount</th>
|
||||
<th>Correction</th>
|
||||
<th>User</th>
|
||||
<!-- Two dates, not one, on the system register.
|
||||
A voucher's timestamp is the minute it was written; the posting behind it carries
|
||||
@@ -217,6 +218,18 @@
|
||||
</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>
|
||||
@if (systemOnly) {
|
||||
<td class="vr-ledgerdate" [class.vr-ledgerdate--prior]="row.postedAfterClose">
|
||||
|
||||
@@ -92,3 +92,8 @@
|
||||
text-transform: uppercase;
|
||||
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 { CashierService } from '../cashier.service';
|
||||
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.
|
||||
@@ -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. */
|
||||
interface VoucherRow {
|
||||
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;
|
||||
amount: number | null;
|
||||
createdAt: string | null;
|
||||
@@ -82,7 +88,8 @@ interface VoucherReport {
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatIconModule
|
||||
MatIconModule,
|
||||
VoucherStatusComponent
|
||||
]
|
||||
})
|
||||
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 { FmrSubmissionComponent } from './fmr-submission/fmr-submission.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 = [
|
||||
Route.withShell([
|
||||
@@ -76,6 +88,67 @@ const routes: Routes = [
|
||||
path: 'cfr-lookup',
|
||||
component: CfrLookupComponent,
|
||||
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>
|
||||
<span>Dashboard</span>
|
||||
</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
|
||||
mat-tab-link
|
||||
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 */
|
||||
import { ProgressBarService } from './progress-bar.service';
|
||||
import { NO_PROGRESS_BAR } from './no-progress.token';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
|
||||
if (request.context.get(NO_PROGRESS_BAR)) {
|
||||
return next.handle(request);
|
||||
}
|
||||
this.progressBarService.increase();
|
||||
return next.handle(request).pipe(
|
||||
finalize(() => {
|
||||
|
||||
@@ -131,6 +131,14 @@
|
||||
</svg>
|
||||
My Closure Requests
|
||||
</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) {
|
||||
<div class="cashier-nav__item-wrap">
|
||||
<a
|
||||
@@ -287,6 +295,13 @@
|
||||
<a class="cashier-nav__item" [routerLink]="['/cashier/my-work']" routerLinkActive="cashier-nav__item--active">
|
||||
<fa-icon icon="user-check"></fa-icon> My Vouchers
|
||||
</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
|
||||
class="cashier-nav__item"
|
||||
[routerLink]="['/cashier/received-report']"
|
||||
@@ -450,6 +465,11 @@
|
||||
*mifosxHasPermission="'MENU_TASKS'"
|
||||
>
|
||||
<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 class="ni" tabindex="0" [matMenuTriggerFor]="cashierMenu" *mifosxHasPermission="'MENU_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']">
|
||||
<fa-icon icon="coins" class="menu-item-icon"></fa-icon> Cash Denomination
|
||||
</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>
|
||||
|
||||
<!-- Transfers & Settlement -->
|
||||
|
||||
@@ -1082,3 +1082,18 @@ $gray-700: #334155;
|
||||
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 */
|
||||
import { Observable, Subscription } from 'rxjs';
|
||||
import { ApprovalCountsService } from '../../tasks/approval-counts.service';
|
||||
import { map, filter } from 'rxjs/operators';
|
||||
|
||||
/** Custom Services */
|
||||
@@ -68,6 +69,11 @@ export class ShellComponent implements OnInit, OnDestroy {
|
||||
private authService = inject(AuthenticationService);
|
||||
private clerkUiStateService = inject(ClerkUiStateService);
|
||||
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. */
|
||||
isHandset$: Observable<boolean> = this.breakpointObserver
|
||||
@@ -144,6 +150,13 @@ export class ShellComponent implements OnInit, OnDestroy {
|
||||
* Subscribes to progress bar to update its mode.
|
||||
*/
|
||||
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.progressBarMode = mode;
|
||||
this.cdr.detectChanges();
|
||||
@@ -368,6 +381,8 @@ export class ShellComponent implements OnInit, OnDestroy {
|
||||
* Unsubscribes from progress bar.
|
||||
*/
|
||||
ngOnDestroy() {
|
||||
this.approvalTotalSub?.unsubscribe();
|
||||
this.approvalCounts.stop();
|
||||
if (this.progressBar$) {
|
||||
this.progressBar$.unsubscribe();
|
||||
}
|
||||
|
||||
+57
-4
@@ -331,12 +331,14 @@
|
||||
<span class="fdrc-maturity-row__value">{{ matchResult.appliedRate | number: '1.2-2' }}% p.a.</span>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
@if (isSpecial && matchResult.estimatedMonthlyPayout) {
|
||||
<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">
|
||||
₹ {{ matchResult.estimatedMonthlyPayout | number: '1.2-2' }} / month
|
||||
</span>
|
||||
@@ -351,13 +353,64 @@
|
||||
}
|
||||
</span>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
@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">
|
||||
<i class="fas fa-lightbulb"></i>
|
||||
Enter a deposit amount above to see the maturity calculation.
|
||||
|
||||
+69
@@ -1229,3 +1229,72 @@ $red-light: #fee2e2;
|
||||
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 { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
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 { SettingsService } from 'app/settings/settings.service';
|
||||
import { SENIOR_CITIZEN_AGE_THRESHOLD, deriveSeniorCitizenStatus } from 'app/clients/shared/client-age.util';
|
||||
|
||||
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 {
|
||||
slab: any;
|
||||
slabIndex: number;
|
||||
@@ -87,6 +114,7 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
|
||||
private clientsService = inject(ClientsService);
|
||||
private settingsService = inject(SettingsService);
|
||||
private cdr = inject(ChangeDetectorRef);
|
||||
private dialog = inject(MatDialog);
|
||||
|
||||
@Input() fixedDepositsAccountTemplate: any;
|
||||
@Input() fixedDepositsAccountProductTemplate: any;
|
||||
@@ -163,6 +191,10 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// ── FD Type labels ──────────────────────────────────────────────────────
|
||||
@@ -270,6 +302,8 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
|
||||
calculateRate() {
|
||||
this.validationWarning = null;
|
||||
this.matchResult = null;
|
||||
this.specialPreview = null;
|
||||
this.specialPreviewError = null;
|
||||
|
||||
if (!this.tenureValue || this.tenureValue <= 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);
|
||||
|
||||
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 = () => {
|
||||
if (frequencyId !== null) {
|
||||
this.tenureSelected.emit({
|
||||
@@ -375,7 +385,7 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
|
||||
// Get submittedOnDate — Input takes priority over template data
|
||||
let submittedOnDateStr: string;
|
||||
if (this.submittedOnDate) {
|
||||
submittedOnDateStr = this.submittedOnDate.toISOString().split('T')[0];
|
||||
submittedOnDateStr = localIsoDate(this.submittedOnDate);
|
||||
} else {
|
||||
const submittedOnDateRaw =
|
||||
this.fixedDepositsAccountTemplate?.timeline?.submittedOnDate ??
|
||||
@@ -384,63 +394,18 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
|
||||
const d = Array.isArray(submittedOnDateRaw)
|
||||
? new Date(submittedOnDateRaw[0], submittedOnDateRaw[1] - 1, submittedOnDateRaw[2])
|
||||
: new Date(submittedOnDateRaw);
|
||||
submittedOnDateStr = !isNaN(d.getTime())
|
||||
? d.toISOString().split('T')[0]
|
||||
: new Date().toISOString().split('T')[0];
|
||||
submittedOnDateStr = !isNaN(d.getTime()) ? localIsoDate(d) : localIsoDate(new Date());
|
||||
} else {
|
||||
submittedOnDateStr = new Date().toISOString().split('T')[0];
|
||||
submittedOnDateStr = localIsoDate(new Date());
|
||||
}
|
||||
}
|
||||
const daysInYearVal =
|
||||
this.fixedDepositsAccountProductTemplate?.interestCalculationDaysInYearType?.id === 360 ? 360 : 365;
|
||||
|
||||
// Always use backend API for accurate calculation (including daily compounding with 0)
|
||||
this.fixedDepositsService
|
||||
.calculateFixedDepositInterest({
|
||||
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();
|
||||
}
|
||||
});
|
||||
// The whole quote - rate, maturity date, maturity amount and, for FD Special, the month-by-month payout
|
||||
// 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
|
||||
// interest engine. Nothing is worked out here: on an error the quote is simply not shown, never estimated.
|
||||
if (frequencyId !== null) {
|
||||
this.loadAccountPreview(result, rate, submittedOnDateStr, frequencyId, emitResult);
|
||||
}
|
||||
} else {
|
||||
this.matchResult = result;
|
||||
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 {
|
||||
const options: any[] = this.fixedDepositsAccountProductTemplate?.periodFrequencyTypeOptions ?? [];
|
||||
const unitLower = unit.toLowerCase();
|
||||
@@ -471,12 +526,16 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
|
||||
this.tenureValue = null;
|
||||
this.principalAmount = null;
|
||||
this.matchResult = null;
|
||||
this.specialPreview = null;
|
||||
this.specialPreviewError = null;
|
||||
this.validationWarning = null;
|
||||
this.tenurePeriodUnit = this.allowedPeriodUnits[0];
|
||||
}
|
||||
|
||||
onPeriodUnitChange() {
|
||||
this.matchResult = null;
|
||||
this.specialPreview = null;
|
||||
this.specialPreviewError = null;
|
||||
this.validationWarning = null;
|
||||
}
|
||||
|
||||
@@ -524,6 +583,8 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
|
||||
this.calculateRate();
|
||||
} else {
|
||||
this.matchResult = null;
|
||||
this.specialPreview = null;
|
||||
this.specialPreviewError = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+7
-5
@@ -326,7 +326,7 @@
|
||||
<div class="preview-dorm-step__body">
|
||||
<span class="preview-dorm-step__label">Inactive</span>
|
||||
<span class="preview-dorm-step__days">
|
||||
@if (fixedDepositsAccountProductTemplate?.daysToInactive != null) {
|
||||
@if ((fixedDepositsAccountProductTemplate?.daysToInactive ?? null) !== null) {
|
||||
After {{ fixedDepositsAccountProductTemplate.daysToInactive }} days of no transaction
|
||||
} @else {
|
||||
Not configured
|
||||
@@ -340,7 +340,7 @@
|
||||
<div class="preview-dorm-step__body">
|
||||
<span class="preview-dorm-step__label">Dormant</span>
|
||||
<span class="preview-dorm-step__days">
|
||||
@if (fixedDepositsAccountProductTemplate?.daysToDormancy != null) {
|
||||
@if ((fixedDepositsAccountProductTemplate?.daysToDormancy ?? null) !== null) {
|
||||
{{ fixedDepositsAccountProductTemplate.daysToDormancy }} days after inactive
|
||||
} @else {
|
||||
Not configured
|
||||
@@ -354,7 +354,7 @@
|
||||
<div class="preview-dorm-step__body">
|
||||
<span class="preview-dorm-step__label">Escheat</span>
|
||||
<span class="preview-dorm-step__days">
|
||||
@if (fixedDepositsAccountProductTemplate?.daysToEscheat != null) {
|
||||
@if ((fixedDepositsAccountProductTemplate?.daysToEscheat ?? null) !== null) {
|
||||
{{ fixedDepositsAccountProductTemplate.daysToEscheat }} days after dormant
|
||||
} @else {
|
||||
Not configured
|
||||
@@ -458,13 +458,15 @@
|
||||
<span class="preview-value">{{ maturitySummary.tenureLabel }}</span>
|
||||
</div>
|
||||
<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">
|
||||
+ {{ maturitySummary.totalInterest | formatNumber }} {{ currencyName }}
|
||||
</span>
|
||||
</div>
|
||||
<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">
|
||||
{{ maturitySummary.maturityAmount | formatNumber }} {{ currencyName }}
|
||||
</span>
|
||||
|
||||
+5
@@ -145,6 +145,11 @@ export class FixedDepositAccountPreviewStepComponent implements OnChanges {
|
||||
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 {
|
||||
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__text">
|
||||
<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>
|
||||
</h2>
|
||||
|
||||
<div mat-dialog-content class="isd-content">
|
||||
<p class="isd-note">
|
||||
<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
|
||||
slightly with the number of days in that month.
|
||||
@if (data.note) {
|
||||
{{ 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>
|
||||
|
||||
<div class="isd-scroll">
|
||||
@@ -28,8 +38,13 @@
|
||||
<tr>
|
||||
<th>{{ 'labels.inputs.From 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__status-col">{{ 'labels.inputs.Status' | translate }}</th>
|
||||
@if (!isPreview) {
|
||||
<th class="isd-table__status-col">{{ 'labels.inputs.Status' | translate }}</th>
|
||||
}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -37,28 +52,35 @@
|
||||
<tr [class.isd-table__row--paid]="period.paid">
|
||||
<td>{{ period.fromDate | dateFormat }}</td>
|
||||
<td>{{ period.toDate | dateFormat }}</td>
|
||||
@if (isPreview) {
|
||||
<td class="isd-table__amount-col">{{ period.days }}</td>
|
||||
}
|
||||
<td class="isd-table__amount-col">
|
||||
{{ period.interestAmount | currency: currencyCode : 'symbol-narrow' : '1.2-2' }}
|
||||
</td>
|
||||
<td class="isd-table__status-col">
|
||||
@if (period.paid) {
|
||||
<span class="isd-badge isd-badge--paid">
|
||||
<fa-icon icon="check-circle" class="m-r-5"></fa-icon>Paid
|
||||
</span>
|
||||
} @else {
|
||||
<span class="isd-badge isd-badge--pending">Pending</span>
|
||||
}
|
||||
</td>
|
||||
@if (!isPreview) {
|
||||
<td class="isd-table__status-col">
|
||||
@if (period.paid) {
|
||||
<span class="isd-badge isd-badge--paid">
|
||||
<fa-icon icon="check-circle" class="m-r-5"></fa-icon>Paid
|
||||
</span>
|
||||
} @else {
|
||||
<span class="isd-badge isd-badge--pending">Pending</span>
|
||||
}
|
||||
</td>
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<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">
|
||||
{{ totalInterest | currency: currencyCode : 'symbol-narrow' : '1.2-2' }}
|
||||
</td>
|
||||
<td></td>
|
||||
@if (!isPreview) {
|
||||
<td></td>
|
||||
}
|
||||
</tr>
|
||||
</tfoot>
|
||||
</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
|
||||
* the page. Each row also shows whether that period's interest has actually posted yet, not just what
|
||||
* 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({
|
||||
selector: 'mifosx-interest-schedule-dialog',
|
||||
@@ -46,14 +49,22 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
export class InterestScheduleDialogComponent {
|
||||
dialogRef = inject<MatDialogRef<InterestScheduleDialogComponent>>(MatDialogRef);
|
||||
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;
|
||||
/** Account-opening quote: no paid / pending status. */
|
||||
preview?: boolean;
|
||||
/** Shown above the table instead of the account note. */
|
||||
note?: string;
|
||||
} = 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 ?? [];
|
||||
}
|
||||
|
||||
get isPreview(): boolean {
|
||||
return !!this.data.preview;
|
||||
}
|
||||
|
||||
get currencyCode(): string {
|
||||
return this.data.currencyCode;
|
||||
}
|
||||
|
||||
@@ -154,6 +154,16 @@ export class FixedDepositsService {
|
||||
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
|
||||
* 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
|
||||
* same values here to avoid an admin configuring a combination that the backend will silently override
|
||||
* 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() {
|
||||
if (!this.fixedDepositProductTermsForm) return;
|
||||
const postingCtrl = this.fixedDepositProductTermsForm.get('interestPostingPeriodType');
|
||||
const compoundingCtrl = this.fixedDepositProductTermsForm.get('interestCompoundingPeriodType');
|
||||
const calculationTypeCtrl = this.fixedDepositProductTermsForm.get('interestCalculateSelector');
|
||||
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)
|
||||
const findMonthly = (data: any) => data?.find((o: any) => o.value?.toLowerCase() === 'monthly' || o.id === 4);
|
||||
if (postingCtrl && this.interestPostingPeriodTypeData) {
|
||||
@@ -154,6 +166,7 @@ export class FixedDepositProductTermsStepComponent implements OnInit, OnChanges
|
||||
} else {
|
||||
postingCtrl?.enable();
|
||||
compoundingCtrl?.enable();
|
||||
calculationTypeCtrl?.enable();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+20
-24
@@ -175,18 +175,13 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit {
|
||||
if (this.isCashOrAccrualAccounting()) {
|
||||
const glMappings = mappings.otherGlMappingsMap || mappings.otherGlMappings;
|
||||
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
|
||||
// liabilities. Products saved before that kept it under expenses, so both groups are read.
|
||||
const fromLiabilities = this.extractNamedGlMapping(
|
||||
this.extractNamedGlMapping(
|
||||
glMappings?.liabilities || [],
|
||||
RECURRING_DEPOSIT_INTEREST_PAID_ACCOUNT_NAME,
|
||||
'savingsInterestPaidAccountId',
|
||||
this.liabilityAccountOptions
|
||||
),
|
||||
SAVINGS_INTEREST_PAID_ACCOUNT_NAME,
|
||||
'savingsInterestPaidAccountId',
|
||||
this.liabilityAccountOptions
|
||||
// Interest Paid sits under expenses, as on the fixed deposit and savings products (the backend finds it by name
|
||||
// in any group). Products saved while it was under liabilities are read from there too, so the name is not
|
||||
// left behind as an "other" liability row; a liability GL chosen then has to be picked again from expenses.
|
||||
const fromLiabilities = (glMappings?.liabilities || []).filter(
|
||||
(mapping: any) =>
|
||||
mapping?.accountName !== RECURRING_DEPOSIT_INTEREST_PAID_ACCOUNT_NAME &&
|
||||
mapping?.accountName !== SAVINGS_INTEREST_PAID_ACCOUNT_NAME
|
||||
);
|
||||
this.setOtherGlMappings('liabilitiesOtherGlMappings', fromLiabilities, this.liabilityAccountOptions);
|
||||
const fromExpenses = this.extractNamedGlMapping(
|
||||
@@ -277,15 +272,15 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit {
|
||||
|
||||
const otherGlMappings = {
|
||||
assets: map('assetsOtherGlMappings', this.assetAccountOptions),
|
||||
liabilities: [
|
||||
liabilities: map('liabilitiesOtherGlMappings', this.liabilityAccountOptions),
|
||||
expenses: [
|
||||
...namedAsOtherGlMapping(
|
||||
'savingsInterestPaidAccountId',
|
||||
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)
|
||||
};
|
||||
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
|
||||
// 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',
|
||||
label: 'Interest Payable Account',
|
||||
@@ -401,13 +397,6 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit {
|
||||
controlName: 'interestPayableAccountId',
|
||||
options: this.liabilityAccountOptions
|
||||
},
|
||||
{
|
||||
key: 'savingsInterestPaidAccountId',
|
||||
label: 'Recurring Deposit Interest Paid',
|
||||
inputLabel: 'Recurring deposit interest paid',
|
||||
controlName: 'savingsInterestPaidAccountId',
|
||||
options: this.liabilityAccountOptions
|
||||
},
|
||||
{
|
||||
key: 'escheatLiabilityId',
|
||||
label: 'Escheat Liability Account',
|
||||
@@ -433,6 +422,13 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit {
|
||||
controlName: 'interestOnSavingsAccountId',
|
||||
options: this.expenseAccountOptions,
|
||||
required: true
|
||||
},
|
||||
{
|
||||
key: 'savingsInterestPaidAccountId',
|
||||
label: 'Recurring Deposit Interest Paid',
|
||||
inputLabel: 'Recurring deposit interest paid',
|
||||
controlName: 'savingsInterestPaidAccountId',
|
||||
options: this.expenseAccountOptions
|
||||
}
|
||||
],
|
||||
otherMappingsControlName: 'expensesOtherGlMappings',
|
||||
|
||||
@@ -11,10 +11,12 @@ import { Injectable, inject } from '@angular/core';
|
||||
import { ActivatedRouteSnapshot } from '@angular/router';
|
||||
|
||||
/** rxjs Imports */
|
||||
import { Observable } from 'rxjs';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { catchError, map, switchMap } from 'rxjs/operators';
|
||||
|
||||
/** Custom Services */
|
||||
import { SavingsService } from '../savings.service';
|
||||
import { VoucherCorrectionService } from '../../cashier/voucher-correction.service';
|
||||
|
||||
/**
|
||||
* Savings Account data resolver.
|
||||
@@ -22,14 +24,29 @@ import { SavingsService } from '../savings.service';
|
||||
@Injectable()
|
||||
export class SavingsAccountViewResolver {
|
||||
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
|
||||
* @returns {Observable<any>}
|
||||
*/
|
||||
resolve(route: ActivatedRouteSnapshot): Observable<any> {
|
||||
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>
|
||||
<span>Checker Inbox</span>
|
||||
@if (count('checker-inbox')) {
|
||||
<span class="ac-tab__count" title="Waiting for action">{{ count('checker-inbox') }}</span>
|
||||
}
|
||||
</a>
|
||||
<a
|
||||
class="ac-tab"
|
||||
@@ -40,10 +43,16 @@
|
||||
>
|
||||
<fa-icon icon="user-check" class="ac-tab__ic"></fa-icon>
|
||||
<span>Client Approval</span>
|
||||
@if (count('client-approval')) {
|
||||
<span class="ac-tab__count" title="Waiting for action">{{ count('client-approval') }}</span>
|
||||
}
|
||||
</a>
|
||||
<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>
|
||||
<span>Loan Approval</span>
|
||||
@if (count('loan-approval')) {
|
||||
<span class="ac-tab__count" title="Waiting for action">{{ count('loan-approval') }}</span>
|
||||
}
|
||||
</a>
|
||||
<a
|
||||
class="ac-tab"
|
||||
@@ -53,6 +62,9 @@
|
||||
>
|
||||
<fa-icon icon="money-bill-wave" class="ac-tab__ic"></fa-icon>
|
||||
<span>Loan Disbursal</span>
|
||||
@if (count('loan-disbursal')) {
|
||||
<span class="ac-tab__count" title="Waiting for action">{{ count('loan-disbursal') }}</span>
|
||||
}
|
||||
</a>
|
||||
<a
|
||||
class="ac-tab"
|
||||
@@ -62,6 +74,9 @@
|
||||
>
|
||||
<fa-icon icon="calendar-alt" class="ac-tab__ic"></fa-icon>
|
||||
<span>Reschedule Loan</span>
|
||||
@if (count('reschedule-loan')) {
|
||||
<span class="ac-tab__count" title="Waiting for action">{{ count('reschedule-loan') }}</span>
|
||||
}
|
||||
</a>
|
||||
<a
|
||||
class="ac-tab"
|
||||
@@ -71,6 +86,9 @@
|
||||
>
|
||||
<fa-icon icon="hourglass-half" class="ac-tab__ic"></fa-icon>
|
||||
<span>Pending Account</span>
|
||||
@if (count('pending-account')) {
|
||||
<span class="ac-tab__count" title="Waiting for action">{{ count('pending-account') }}</span>
|
||||
}
|
||||
</a>
|
||||
<a
|
||||
class="ac-tab"
|
||||
@@ -80,6 +98,9 @@
|
||||
>
|
||||
<fa-icon icon="hourglass-half" class="ac-tab__ic"></fa-icon>
|
||||
<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
|
||||
class="ac-tab"
|
||||
@@ -89,6 +110,9 @@
|
||||
>
|
||||
<fa-icon icon="piggy-bank" class="ac-tab__ic"></fa-icon>
|
||||
<span>Product Approval</span>
|
||||
@if (count('product-approval')) {
|
||||
<span class="ac-tab__count" title="Waiting for action">{{ count('product-approval') }}</span>
|
||||
}
|
||||
</a>
|
||||
<a
|
||||
class="ac-tab"
|
||||
@@ -98,6 +122,9 @@
|
||||
>
|
||||
<fa-icon icon="edit" class="ac-tab__ic"></fa-icon>
|
||||
<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
|
||||
class="ac-tab"
|
||||
@@ -107,6 +134,9 @@
|
||||
>
|
||||
<fa-icon icon="edit" class="ac-tab__ic"></fa-icon>
|
||||
<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
|
||||
class="ac-tab"
|
||||
@@ -116,6 +146,9 @@
|
||||
>
|
||||
<fa-icon icon="user-cog" class="ac-tab__ic"></fa-icon>
|
||||
<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
|
||||
class="ac-tab"
|
||||
@@ -143,6 +176,33 @@
|
||||
>
|
||||
<fa-icon icon="money-bill-wave" class="ac-tab__ic"></fa-icon>
|
||||
<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
|
||||
class="ac-tab"
|
||||
@@ -152,6 +212,9 @@
|
||||
>
|
||||
<fa-icon icon="hand-holding-usd" class="ac-tab__ic"></fa-icon>
|
||||
<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
|
||||
class="ac-tab"
|
||||
@@ -161,6 +224,9 @@
|
||||
>
|
||||
<fa-icon icon="coins" class="ac-tab__ic"></fa-icon>
|
||||
<span>Dividend Requests</span>
|
||||
@if (count('dividend-requests')) {
|
||||
<span class="ac-tab__count" title="Waiting for action">{{ count('dividend-requests') }}</span>
|
||||
}
|
||||
</a>
|
||||
<a
|
||||
class="ac-tab"
|
||||
@@ -170,6 +236,9 @@
|
||||
>
|
||||
<fa-icon icon="money-check-alt" class="ac-tab__ic"></fa-icon>
|
||||
<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
|
||||
class="ac-tab"
|
||||
@@ -179,6 +248,9 @@
|
||||
>
|
||||
<fa-icon icon="hand-holding-usd" class="ac-tab__ic"></fa-icon>
|
||||
<span>Deposit Requests</span>
|
||||
@if (count('deposit-requests')) {
|
||||
<span class="ac-tab__count" title="Waiting for action">{{ count('deposit-requests') }}</span>
|
||||
}
|
||||
</a>
|
||||
<a
|
||||
class="ac-tab"
|
||||
@@ -188,10 +260,16 @@
|
||||
>
|
||||
<fa-icon icon="money-bill-wave" class="ac-tab__ic"></fa-icon>
|
||||
<span>Disbursement & Transfers</span>
|
||||
@if (count('disbursement-requests')) {
|
||||
<span class="ac-tab__count" title="Waiting for action">{{ count('disbursement-requests') }}</span>
|
||||
}
|
||||
</a>
|
||||
<a class="ac-tab" routerLink="./dds-approvals" routerLinkActive="ac-tab--active">
|
||||
<fa-icon icon="coins" class="ac-tab__ic"></fa-icon>
|
||||
<span>DDS Approvals</span>
|
||||
@if (count('dds-approvals')) {
|
||||
<span class="ac-tab__count" title="Waiting for action">{{ count('dds-approvals') }}</span>
|
||||
}
|
||||
</a>
|
||||
<a
|
||||
class="ac-tab"
|
||||
@@ -201,6 +279,9 @@
|
||||
>
|
||||
<fa-icon icon="money-check" class="ac-tab__ic"></fa-icon>
|
||||
<span>Cheque Book Requests</span>
|
||||
@if (count('chequebook-requests')) {
|
||||
<span class="ac-tab__count" title="Waiting for action">{{ count('chequebook-requests') }}</span>
|
||||
}
|
||||
</a>
|
||||
<a
|
||||
class="ac-tab"
|
||||
@@ -210,26 +291,41 @@
|
||||
>
|
||||
<fa-icon icon="lock" class="ac-tab__ic"></fa-icon>
|
||||
<span>Closure Requests</span>
|
||||
@if (count('closure-requests')) {
|
||||
<span class="ac-tab__count" title="Waiting for action">{{ count('closure-requests') }}</span>
|
||||
}
|
||||
</a>
|
||||
<a class="ac-tab" routerLink="./dd-issue-approvals" routerLinkActive="ac-tab--active">
|
||||
<fa-icon icon="receipt" class="ac-tab__ic"></fa-icon>
|
||||
<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 class="ac-tab" routerLink="./dd-reprint-approvals" routerLinkActive="ac-tab--active">
|
||||
<fa-icon icon="print" class="ac-tab__ic"></fa-icon>
|
||||
<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>
|
||||
<!-- 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. -->
|
||||
<a class="ac-tab" routerLink="./dd-revoke-approvals" routerLinkActive="ac-tab--active">
|
||||
<fa-icon icon="ban" class="ac-tab__ic"></fa-icon>
|
||||
<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>
|
||||
<!-- 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. -->
|
||||
<a class="ac-tab" routerLink="./risk-change-approvals" routerLinkActive="ac-tab--active">
|
||||
<fa-icon icon="shield-alt" class="ac-tab__ic"></fa-icon>
|
||||
<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>
|
||||
</nav>
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ $green: #10b981;
|
||||
$amber: #f59e0b;
|
||||
|
||||
.ac-container {
|
||||
padding: 20px 24px;
|
||||
padding: 12px 20px;
|
||||
max-width: 1440px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
@@ -25,40 +25,40 @@ $amber: #f59e0b;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
margin-bottom: 10px;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
&__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 12px;
|
||||
border-radius: 9px;
|
||||
background: linear-gradient(135deg, $blue 0%, $purple 100%);
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
font-size: 14px;
|
||||
box-shadow: 0 2px 8px rgb(29 78 216 / 25%);
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
color: $navy;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
&__sub {
|
||||
margin-top: 2px;
|
||||
font-size: 12.5px;
|
||||
margin-top: 1px;
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
@@ -68,26 +68,26 @@ $amber: #f59e0b;
|
||||
hides most of them behind prev/next pagination arrows. Wrapping keeps every tab visible and one
|
||||
click away instead of a scroll-and-hunt. */
|
||||
.ac-tabs {
|
||||
margin-bottom: 20px;
|
||||
margin-bottom: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 14px;
|
||||
padding: 6px;
|
||||
border-radius: 10px;
|
||||
padding: 4px;
|
||||
box-shadow: 0 1px 3px rgb(15 23 42 / 4%);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.ac-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 38px;
|
||||
padding: 0 18px;
|
||||
border-radius: 10px;
|
||||
gap: 6px;
|
||||
height: 28px;
|
||||
padding: 0 11px;
|
||||
border-radius: 8px;
|
||||
font-family: Inter, 'DM Sans', Roboto, sans-serif;
|
||||
font-size: 13px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
color: #64748b;
|
||||
@@ -99,15 +99,34 @@ $amber: #f59e0b;
|
||||
cursor: pointer;
|
||||
|
||||
&__ic {
|
||||
font-size: 13px;
|
||||
font-size: 11px;
|
||||
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) {
|
||||
background: #f1f5f9;
|
||||
color: $navy;
|
||||
}
|
||||
|
||||
&.ac-tab--active .ac-tab__count {
|
||||
background: #fff;
|
||||
color: $blue-d;
|
||||
}
|
||||
|
||||
&.ac-tab--active {
|
||||
background: linear-gradient(135deg, $blue 0%, $blue-d 100%);
|
||||
color: #fff;
|
||||
@@ -118,7 +137,7 @@ $amber: #f59e0b;
|
||||
|
||||
@media (width <= 768px) {
|
||||
.ac-tab {
|
||||
padding: 0 14px;
|
||||
font-size: 12px;
|
||||
padding: 0 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,10 +7,12 @@
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component } from '@angular/core';
|
||||
import { Component, OnDestroy, OnInit, inject } from '@angular/core';
|
||||
import { RouterLinkActive, RouterOutlet } from '@angular/router';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { ApprovalCountsService } from '../approval-counts.service';
|
||||
|
||||
/**
|
||||
* Approval Center — checker inbox + client/loan approval workflows.
|
||||
@@ -31,6 +33,24 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
FaIconComponent
|
||||
]
|
||||
})
|
||||
export class CheckerInboxAndTasksComponent {
|
||||
constructor() {}
|
||||
export class CheckerInboxAndTasksComponent implements OnInit, OnDestroy {
|
||||
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,
|
||||
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',
|
||||
component: DividendRequestsComponent,
|
||||
|
||||
@@ -45,15 +45,17 @@
|
||||
"hasMask": false,
|
||||
"ao": 0,
|
||||
"ks": { "a": { "a": 0, "k": [
|
||||
-17.052704811096184,
|
||||
-15.0467209815979
|
||||
0,
|
||||
0,
|
||||
0
|
||||
], "ix": 1 }, "s": { "a": 0, "k": [
|
||||
362.228,
|
||||
362.228,
|
||||
394,
|
||||
394,
|
||||
100
|
||||
], "ix": 6 }, "sk": { "a": 0, "k": 0 }, "p": { "a": 0, "k": [
|
||||
188.2303284168625,
|
||||
195.49656352277756
|
||||
250,
|
||||
250,
|
||||
0
|
||||
], "ix": 2 }, "r": { "a": 0, "k": 0, "ix": 10 }, "sa": { "a": 0, "k": 0 }, "o": { "a": 0, "k": 100, "ix": 11 } },
|
||||
"shapes": [
|
||||
{
|
||||
@@ -341,16 +343,16 @@
|
||||
"g": { "p": 3, "k": { "a": 0, "k": [
|
||||
0,
|
||||
1,
|
||||
0.4470588235294118,
|
||||
0,
|
||||
0.6,
|
||||
0.2,
|
||||
0.485,
|
||||
0.9725490196078431,
|
||||
0.2235294117647059,
|
||||
0.5019607843137255,
|
||||
1,
|
||||
0.9411764705882353,
|
||||
0,
|
||||
1
|
||||
0.6,
|
||||
0.2,
|
||||
1,
|
||||
1,
|
||||
0.6,
|
||||
0.2
|
||||
], "ix": 8 } },
|
||||
"t": 1,
|
||||
"a": { "a": 0, "k": 0 },
|
||||
@@ -363,7 +365,7 @@
|
||||
"lj": 1,
|
||||
"ml": 4,
|
||||
"o": { "a": 0, "k": 100, "ix": 9 },
|
||||
"w": { "a": 0, "k": 2, "ix": 10 }
|
||||
"w": { "a": 0, "k": 2.5, "ix": 10 }
|
||||
},
|
||||
{
|
||||
"ty": "fl",
|
||||
@@ -402,8 +404,8 @@
|
||||
"o": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{ "o": { "x": 0.167, "y": 0.167 }, "i": { "x": 0.833, "y": 0.833 }, "s": [0], "t": 0 },
|
||||
{ "s": [360], "t": 90.0000036657751 }
|
||||
{ "o": { "x": 0.167, "y": 0.167 }, "i": { "x": 0.833, "y": 0.833 }, "s": [28], "t": 0 },
|
||||
{ "s": [388], "t": 90.0000036657751 }
|
||||
],
|
||||
"ix": 3
|
||||
},
|
||||
@@ -437,7 +439,7 @@
|
||||
250,
|
||||
250,
|
||||
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": [
|
||||
{
|
||||
"ty": "gr",
|
||||
@@ -723,16 +725,16 @@
|
||||
], "ix": 5 },
|
||||
"g": { "p": 3, "k": { "a": 0, "k": [
|
||||
0,
|
||||
0,
|
||||
0.7764705882352941,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
0.485,
|
||||
0.24705882352941178,
|
||||
0.38823529411764707,
|
||||
1,
|
||||
1,
|
||||
0.49411764705882355,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1
|
||||
], "ix": 8 } },
|
||||
"t": 1,
|
||||
@@ -746,7 +748,7 @@
|
||||
"lj": 1,
|
||||
"ml": 4,
|
||||
"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",
|
||||
@@ -813,14 +815,14 @@
|
||||
0,
|
||||
0
|
||||
], "ix": 1 }, "s": { "a": 0, "k": [
|
||||
362.228,
|
||||
362.228,
|
||||
330,
|
||||
330,
|
||||
100
|
||||
], "ix": 6 }, "sk": { "a": 0, "k": 0 }, "p": { "a": 0, "k": [
|
||||
250,
|
||||
250,
|
||||
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": [
|
||||
{
|
||||
"ty": "gr",
|
||||
@@ -1106,17 +1108,17 @@
|
||||
], "ix": 5 },
|
||||
"g": { "p": 3, "k": { "a": 0, "k": [
|
||||
0,
|
||||
1,
|
||||
0.8235294117647058,
|
||||
0,
|
||||
0.0784,
|
||||
0.7059,
|
||||
0.2118,
|
||||
0.485,
|
||||
0.5019607843137255,
|
||||
0.8117647058823529,
|
||||
0.5019607843137255,
|
||||
0.0784,
|
||||
0.7059,
|
||||
0.2118,
|
||||
1,
|
||||
0,
|
||||
0.8,
|
||||
1
|
||||
0.0784,
|
||||
0.7059,
|
||||
0.2118
|
||||
], "ix": 8 } },
|
||||
"t": 1,
|
||||
"a": { "a": 0, "k": 0 },
|
||||
@@ -1129,7 +1131,7 @@
|
||||
"lj": 1,
|
||||
"ml": 4,
|
||||
"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",
|
||||
@@ -1168,8 +1170,8 @@
|
||||
"o": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{ "o": { "x": 0.167, "y": 0.167 }, "i": { "x": 0.833, "y": 0.833 }, "s": [0], "t": 0 },
|
||||
{ "s": [360], "t": 90.0000036657751 }
|
||||
{ "o": { "x": 0.167, "y": 0.167 }, "i": { "x": 0.833, "y": 0.833 }, "s": [-28], "t": 0 },
|
||||
{ "s": [332], "t": 90.0000036657751 }
|
||||
],
|
||||
"ix": 3
|
||||
},
|
||||
|
||||
+6
-6
@@ -325,10 +325,10 @@
|
||||
};
|
||||
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 () {
|
||||
if (progress < 90) {
|
||||
progress += 5;
|
||||
progress += 2;
|
||||
} else if (angularReady && progress < 100) {
|
||||
progress += 5;
|
||||
}
|
||||
@@ -351,7 +351,7 @@
|
||||
}
|
||||
}, 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 () {
|
||||
minTimePassed = true;
|
||||
if (angularReady) {
|
||||
@@ -359,7 +359,7 @@
|
||||
if (bar) bar.style.width = '100%';
|
||||
dismissSplash();
|
||||
}
|
||||
}, 2000);
|
||||
}, 4000);
|
||||
|
||||
function dismissSplash() {
|
||||
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 () {
|
||||
angularReady = true;
|
||||
minTimePassed = true;
|
||||
dismissSplash();
|
||||
}, 7000);
|
||||
}, 9000);
|
||||
})();
|
||||
</script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user