Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fca550f4c4 | ||
|
|
3e5cc26e49 | ||
|
|
c3713f8a90 | ||
|
|
f804d356d7 | ||
|
|
1daa8e60a0 | ||
|
|
55f5c1cf32 | ||
|
|
6507e68203 | ||
|
|
4d205672df |
@@ -184,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>
|
||||
@@ -267,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
|
||||
@@ -1644,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>
|
||||
@@ -1715,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
|
||||
|
||||
@@ -2344,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
|
||||
@@ -4046,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());
|
||||
@@ -4077,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}`;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4141,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') {
|
||||
@@ -4221,6 +4253,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
return {
|
||||
list: '/savingsaccounts',
|
||||
detail: '/fixeddepositaccounts',
|
||||
depositTypeId: 200,
|
||||
nominee: 'FD',
|
||||
search: 'fixeddeposit',
|
||||
siAccountType: 2,
|
||||
@@ -4238,6 +4271,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
return {
|
||||
list: '/savingsaccounts',
|
||||
detail: '/recurringdepositaccounts',
|
||||
depositTypeId: 300,
|
||||
nominee: 'RD',
|
||||
search: 'recurringdeposit',
|
||||
siAccountType: 2,
|
||||
@@ -4255,6 +4289,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
return {
|
||||
list: '/savingsaccounts',
|
||||
detail: '/savingsaccounts',
|
||||
depositTypeId: 100,
|
||||
nominee: 'SB',
|
||||
search: 'saving',
|
||||
siAccountType: 2,
|
||||
@@ -5272,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.
|
||||
@@ -5349,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',
|
||||
@@ -5357,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;
|
||||
|
||||
@@ -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>
|
||||
+9
-7
@@ -6,14 +6,16 @@
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
@use '../../../shared/styles/list-layout' as *;
|
||||
@use '../i4c' as *;
|
||||
@use '../i4c-cases' as *;
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
@include i4c-skin;
|
||||
@include i4c-case-page;
|
||||
|
||||
.highlight td {
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.pf-subtitle {
|
||||
margin: 4px 0 0;
|
||||
color: rgb(0 0 0 / 60%);
|
||||
font-size: 0.875rem;
|
||||
.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(() => {
|
||||
|
||||
@@ -465,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
|
||||
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -50,8 +50,7 @@ export async function createRealLoanAccount(
|
||||
|
||||
const locale = settings.language.code;
|
||||
const dateFormat = settings.dateFormat;
|
||||
// Fineract rejects a submitted-on date later than its business date, so use that (the date shown in the header), not the PC clock.
|
||||
const today = dateUtils.formatDate(settings.businessDate, dateFormat);
|
||||
const today = dateUtils.formatDate(new Date(), dateFormat);
|
||||
|
||||
const payload = {
|
||||
clientId: app.clientId,
|
||||
|
||||
@@ -29,7 +29,6 @@ export { BRANCHES, BranchMaster };
|
||||
export type OriginationStage =
|
||||
| 'APPLICATION'
|
||||
| 'KYC_DOCUMENTS'
|
||||
| 'DEPOSIT_SELECTION'
|
||||
| 'CIC_CHECK'
|
||||
| 'FIELD_INVESTIGATION'
|
||||
| 'PERSONAL_DISCUSSION'
|
||||
@@ -44,7 +43,6 @@ export type OriginationStage =
|
||||
export const STAGE_ORDER: OriginationStage[] = [
|
||||
'APPLICATION',
|
||||
'KYC_DOCUMENTS',
|
||||
'DEPOSIT_SELECTION',
|
||||
'CIC_CHECK',
|
||||
'FIELD_INVESTIGATION',
|
||||
'PERSONAL_DISCUSSION',
|
||||
@@ -60,7 +58,6 @@ export const STAGE_ORDER: OriginationStage[] = [
|
||||
export const STAGE_LABELS: Record<OriginationStage, string> = {
|
||||
APPLICATION: 'Application Received',
|
||||
KYC_DOCUMENTS: 'KYC & Document Collection',
|
||||
DEPOSIT_SELECTION: 'Deposit Selection & Eligibility',
|
||||
CIC_CHECK: 'CIC / Credit Bureau Check',
|
||||
FIELD_INVESTIGATION: 'Field Investigation (FI)',
|
||||
PERSONAL_DISCUSSION: 'Personal Discussion with Manager',
|
||||
@@ -73,18 +70,6 @@ export const STAGE_LABELS: Record<OriginationStage, string> = {
|
||||
DISBURSED: 'Disbursed'
|
||||
};
|
||||
|
||||
/** The stages a given product actually goes through (a loan against a deposit has no CIC and no field investigation): Deposit Selection only for a loan against a deposit, and no
|
||||
* credit bureau (CIC) check for it. Everything else keeps the full 12-stage list. */
|
||||
export function stagesFor(productCode: string | undefined | null): OriginationStage[] {
|
||||
if (isDepositLoanCode(productCode)) {
|
||||
// No credit bureau check, field investigation or security cheque / ACH (recovery is by set-off against the lien); every other stage stays.
|
||||
return STAGE_ORDER.filter(
|
||||
(stage) => stage !== 'CIC_CHECK' && stage !== 'FIELD_INVESTIGATION' && stage !== 'CHEQUE_ACH'
|
||||
);
|
||||
}
|
||||
return STAGE_ORDER.filter((stage) => stage !== 'DEPOSIT_SELECTION');
|
||||
}
|
||||
|
||||
export function stageIndex(stage: OriginationStage): number {
|
||||
return STAGE_ORDER.indexOf(stage);
|
||||
}
|
||||
@@ -195,54 +180,7 @@ export interface StageHistoryEntry {
|
||||
remarks: string;
|
||||
}
|
||||
|
||||
/** The deposit(s) pledged for a loan against FD / RD / DDS / LIC and what they allow (policy 9.5). */
|
||||
export interface DepositSelection {
|
||||
accountIds: number[];
|
||||
accountNos: string[];
|
||||
securityValue: number;
|
||||
limitPct: number;
|
||||
eligibleAmount: number;
|
||||
depositRate: number;
|
||||
spread: number;
|
||||
maxTenure: number;
|
||||
over85: boolean;
|
||||
over85Reason: string;
|
||||
thirdParty: boolean;
|
||||
/** The product requires a lien on the deposit (a product can switch this off). */
|
||||
lienRequired: boolean;
|
||||
saved: boolean;
|
||||
}
|
||||
|
||||
/** Lien noted on the pledged deposit(s) for this application. */
|
||||
export interface LienRecord {
|
||||
marked: boolean;
|
||||
certificateNo: string;
|
||||
markedOn: string;
|
||||
amount: number;
|
||||
accountNos: string[];
|
||||
}
|
||||
|
||||
/** An approval sent for a decision (maker-checker): the user who sent it cannot be the one who decides. */
|
||||
export interface ApprovalRequest {
|
||||
status: 'PENDING' | 'APPROVED' | 'EXCEPTION_APPROVED' | 'REJECTED' | 'WITHDRAWN';
|
||||
submittedBy: string;
|
||||
submittedOn: string;
|
||||
requestedDecision: 'APPROVED' | 'EXCEPTION_APPROVED';
|
||||
remarks: string;
|
||||
resolutionNo?: string;
|
||||
resolutionDate?: string;
|
||||
/** What the sender had verified at the moment of sending -- what the approver reviews. */
|
||||
checklist?: { label: string; ok: boolean }[];
|
||||
/** What the approver ticked as checked herself/himself before approving. */
|
||||
checkerChecklist?: { label: string; ok: boolean }[];
|
||||
decidedBy?: string;
|
||||
decidedOn?: string;
|
||||
}
|
||||
|
||||
export interface LoanApplication {
|
||||
approvalRequests?: Partial<Record<string, ApprovalRequest>>;
|
||||
depositSelection?: DepositSelection;
|
||||
lien?: LienRecord;
|
||||
/** Database row id, assigned on save. Uploaded files hang off this (documents are stored against
|
||||
* the application's entity id), so it's absent only on an application not yet persisted. */
|
||||
id?: number;
|
||||
@@ -513,47 +451,7 @@ interface OriginationProduct {
|
||||
/** Minimum Share Capital the borrower must hold, as a % of the loan amount, per bank bye-laws
|
||||
* (a UCB norm -- membership/share-linkage against borrowing, distinct from margin/collateral). */
|
||||
shareLinkagePercent: number;
|
||||
/** Loan against a deposit: the share of the deposit value that can be lent (policy 9.5.5: 85%). */
|
||||
ltvPct?: number;
|
||||
/** Loan against a deposit: percentage points added to the deposit's own rate. */
|
||||
spread?: number;
|
||||
/** Loan against a deposit: when the lien is marked, as set on the product ("disbursement" or "sanction"). */
|
||||
lienStage?: string;
|
||||
lienRequired?: boolean;
|
||||
/** What the eligible amount is calculated on: "balance_accrued" (balance + accrued interest) or "balance". */
|
||||
eligibleBasis?: string;
|
||||
/** "deposit_spread" = the deposit's own rate plus the spread; anything else uses the product's own rate. */
|
||||
interestBasis?: string;
|
||||
/** The product's own interest rate (used when the rate is not "deposit rate + spread"). */
|
||||
baseRate?: number;
|
||||
allowMultipleDeposits?: boolean;
|
||||
allowThirdParty?: boolean;
|
||||
/** The loan tenure may not run past the deposit's maturity. */
|
||||
tenureWithinMaturity?: boolean;
|
||||
certificateRequired?: boolean;
|
||||
certificatePrefix?: string;
|
||||
/** Terms shown on the loan plan of a deposit loan. */
|
||||
penalRate?: number;
|
||||
penalApplicable?: boolean;
|
||||
repaymentType?: string;
|
||||
interestPostingFrequency?: string;
|
||||
facilityType?: string;
|
||||
preclosure?: string;
|
||||
renewalAllowed?: boolean;
|
||||
}
|
||||
/** Short names of the loans against a deposit or policy (FD, RD, DDS, LIC): the deposit is the security, so income
|
||||
* proof, bank statement, address proof and a co-borrower are optional and there is no credit bureau check. */
|
||||
export const DEPOSIT_PRODUCT_CODES = [
|
||||
'FD',
|
||||
'RD',
|
||||
'DS',
|
||||
'LI'
|
||||
];
|
||||
|
||||
export function isDepositLoanCode(productCode: string | undefined | null): boolean {
|
||||
return !!productCode && DEPOSIT_PRODUCT_CODES.includes(productCode);
|
||||
}
|
||||
|
||||
export const ORIGINATION_PRODUCTS: OriginationProduct[] = [
|
||||
{
|
||||
code: 'PL',
|
||||
@@ -688,53 +586,6 @@ interface ProductDocumentTemplate {
|
||||
* encumbering that asset has to be collected before the loan can be sanctioned, on top of the
|
||||
* borrower's own identity/income proof. */
|
||||
export const PRODUCT_DOCUMENT_REQUIREMENTS: Record<string, ProductDocumentTemplate[]> = {
|
||||
FD: [
|
||||
{
|
||||
key: 'fdReceipt',
|
||||
label: 'FD receipt — original, discharged on the back',
|
||||
hint: 'Signed by the depositor'
|
||||
},
|
||||
{ key: 'dpNote', label: 'Demand promissory note (DP Note)', hint: 'Promise to repay on demand' },
|
||||
{
|
||||
key: 'letterOfContinuity',
|
||||
label: 'Letter of continuity',
|
||||
hint: 'Liability and security continue until the loan is repaid'
|
||||
}
|
||||
],
|
||||
RD: [
|
||||
{ key: 'rdPassbook', label: 'RD passbook', hint: 'Kept by the bank' },
|
||||
{
|
||||
key: 'adjustDeclaration',
|
||||
label: 'Declaration: bank may adjust the loan on RD maturity',
|
||||
hint: 'Bank may adjust the loan from the deposit on maturity'
|
||||
},
|
||||
{ key: 'dpNote', label: 'Demand promissory note (DP Note)', hint: 'Promise to repay on demand' },
|
||||
{
|
||||
key: 'letterOfContinuity',
|
||||
label: 'Letter of continuity',
|
||||
hint: 'Liability and security continue until the loan is repaid'
|
||||
}
|
||||
],
|
||||
DS: [
|
||||
{ key: 'ddsPassbook', label: 'DDS passbook', hint: 'Kept by the bank' },
|
||||
{
|
||||
key: 'adjustDeclaration',
|
||||
label: 'Declaration: bank may adjust the loan on DDS maturity',
|
||||
hint: 'Bank may adjust the loan from the deposit on maturity'
|
||||
},
|
||||
{ key: 'dpNote', label: 'Demand promissory note (DP Note)', hint: 'Promise to repay on demand' },
|
||||
{
|
||||
key: 'letterOfContinuity',
|
||||
label: 'Letter of continuity',
|
||||
hint: 'Liability and security continue until the loan is repaid'
|
||||
}
|
||||
],
|
||||
LI: [
|
||||
{ key: 'licPolicyBond', label: 'Original LIC policy bond', hint: 'Kept by the bank' },
|
||||
{ key: 'licAssignment', label: 'Assignment deed in favour of the bank', hint: 'Registered with the LIC branch' },
|
||||
{ key: 'licSurrenderLetter', label: 'LIC surrender value letter', hint: 'Not older than 1 month' },
|
||||
{ key: 'dpNote', label: 'Demand promissory note (DP Note)', hint: 'Promise to repay on demand' }
|
||||
],
|
||||
HL: [
|
||||
{ key: 'propertyTaxReceipt', label: 'Property Tax Receipt' },
|
||||
{ key: 'propertyRegistration', label: 'Property Registration Document (Sale Deed)' },
|
||||
@@ -785,75 +636,15 @@ export function buildProductDocuments(productCode: string): ProductDocumentEntry
|
||||
return (PRODUCT_DOCUMENT_REQUIREMENTS[productCode] ?? []).map((doc) => ({ ...doc, custom: false, submitted: false }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a product saved in Product Creation usable by the application screens: the detail page, the eligibility
|
||||
* check and the Fineract bridge all look a product up by its code in ORIGINATION_PRODUCTS, so a saved product is
|
||||
* registered there under its Short Name (replacing an earlier registration of the same code).
|
||||
*/
|
||||
export function registerOriginationProduct(p: any): OriginationProduct {
|
||||
const ext = p?.advanceExtensions ?? {};
|
||||
const inMonths = !p?.repaymentFrequencyType?.value || p.repaymentFrequencyType.value === 'Months';
|
||||
const rate = Number(p?.interestRatePerPeriod ?? 0);
|
||||
const entry: OriginationProduct = {
|
||||
code: String(p.shortName),
|
||||
name: String(p.name).replace(/\s+/g, ' ').trim(),
|
||||
secured: !isDepositLoanCode(String(p.shortName)) && (ext.productTypeGroup === 'advances' || !!ext.securityType),
|
||||
minAmt: Number(p?.minPrincipal ?? p?.principal ?? 0),
|
||||
maxAmt: Number(p?.maxPrincipal ?? p?.principal ?? 0),
|
||||
minRate: Number(p?.minInterestRatePerPeriod ?? rate),
|
||||
maxRate: Number(p?.maxInterestRatePerPeriod ?? rate),
|
||||
minTenure: Number(inMonths ? (p?.minNumberOfRepayments ?? p?.numberOfRepayments ?? 1) : 1),
|
||||
maxTenure: Number(inMonths ? (p?.maxNumberOfRepayments ?? p?.numberOfRepayments ?? 360) : 360),
|
||||
incomeMultiplier: 20,
|
||||
shareLinkagePercent: 5,
|
||||
ltvPct: Number(ext.ltv ?? 85),
|
||||
spread: Number(ext.spread ?? 0),
|
||||
lienStage: String(ext.lienMarkingStage ?? 'disbursement'),
|
||||
lienRequired: ext.lienRequired !== false,
|
||||
eligibleBasis: String(ext.eligibleAmountBasis ?? 'balance_accrued'),
|
||||
interestBasis: String(ext.interestBasis ?? 'deposit_spread'),
|
||||
baseRate: rate,
|
||||
allowMultipleDeposits: ext.lienAllowMultipleDeposits === true,
|
||||
allowThirdParty: ext.lienAllowThirdParty === true,
|
||||
tenureWithinMaturity: ext.lienTenureNotExceedMaturity !== false,
|
||||
certificateRequired: ext.lienCertificateRequired !== false && ext.lienCertificateRequired !== 'NO',
|
||||
certificatePrefix: ext.lienCertificatePrefix ? String(ext.lienCertificatePrefix) : undefined,
|
||||
penalRate: Number(ext.penalRate ?? 0),
|
||||
penalApplicable: ext.penalApplicable === true,
|
||||
repaymentType: String(ext.repaymentType ?? 'bullet'),
|
||||
interestPostingFrequency: String(ext.interestPostingFrequency ?? 'Monthly'),
|
||||
facilityType: String(ext.facilityType ?? 'demand'),
|
||||
preclosure: String(ext.preclosureAllowed ?? 'anytime'),
|
||||
renewalAllowed: ext.renewalAllowed === true
|
||||
};
|
||||
const at = ORIGINATION_PRODUCTS.findIndex((x) => x.code === entry.code);
|
||||
if (at >= 0) {
|
||||
ORIGINATION_PRODUCTS[at] = entry;
|
||||
} else {
|
||||
ORIGINATION_PRODUCTS.push(entry);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** What Legal Approval actually collects proof of -- the executed agreement, the stamp duty paid
|
||||
* on it, and (for a secured product) the mortgage/title paperwork -- same "Add More Doc" pattern
|
||||
* as the product-specific documents above, since a specific matter can always need one more
|
||||
* paper (a second mortgagor's consent letter, a court NOC, etc.) than this starter list expects. */
|
||||
export function buildLegalDocuments(secured: boolean, depositLoan = false): ProductDocumentEntry[] {
|
||||
if (depositLoan) {
|
||||
// The DP Note, letter of continuity, deposit receipt and letter of lien were collected earlier (KYC & Documents and
|
||||
// Lien Marking); only what is new at the legal stage is asked here. No mortgage, title or legal opinion: the security is a deposit.
|
||||
return [
|
||||
{
|
||||
key: 'stampDutyReceipt',
|
||||
label: 'Stamp duty / revenue stamp receipt (on the DP Note and agreement)'
|
||||
}
|
||||
].map((doc) => ({ ...doc, custom: false, submitted: false }));
|
||||
}
|
||||
export function buildLegalDocuments(secured: boolean): ProductDocumentEntry[] {
|
||||
const templates: ProductDocumentTemplate[] = [
|
||||
{ key: 'loanAgreement', label: 'Loan Agreement (Registered/Notarized)' },
|
||||
{ key: 'stampDutyReceipt', label: 'Legal Stamp Paper / Stamp Duty Receipt' },
|
||||
{ key: 'feeReceipt', label: 'Processing Fee Receipt' }
|
||||
{ key: 'feeReceipt', label: 'Fee & Account Opening Receipt' }
|
||||
];
|
||||
if (secured) {
|
||||
templates.push(
|
||||
@@ -2048,7 +1839,7 @@ export function createApplication(input: NewApplicationInput, seq: number): Loan
|
||||
const actionBy =
|
||||
input.channel === 'BC' ? input.bcAgentName : input.channel === 'SELF_APPLY' ? 'Customer (Digital)' : 'Branch Staff';
|
||||
return {
|
||||
applicationNo: `LN${input.branchCode.replace(/^(OFF|BR)/, '').padStart(2, '0')}${input.productCode}${String(seq).padStart(6, '0')}`,
|
||||
applicationNo: `LN${input.branchCode.slice(2)}${input.productCode}${String(seq).padStart(6, '0')}`,
|
||||
applicationDate: todayDDMMYYYY(),
|
||||
applicantName: input.applicantName,
|
||||
branchCode: input.branchCode,
|
||||
@@ -2141,7 +1932,7 @@ export function createApplication(input: NewApplicationInput, seq: number): Loan
|
||||
legalStampDone: false,
|
||||
fileFilingDone: false,
|
||||
agreementSigningSlot: '—',
|
||||
legalDocuments: buildLegalDocuments(input.secured, isDepositLoanCode(input.productCode)),
|
||||
legalDocuments: buildLegalDocuments(input.secured),
|
||||
customerSigningDate: '—',
|
||||
chequeReceived: false,
|
||||
achSigned: false,
|
||||
|
||||
@@ -865,16 +865,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.loi-legal-doc-thumb-remove.loi-bank-remove {
|
||||
height: 34px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 999px;
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
// CIC Credit Bureau Hero Card in Stage detail
|
||||
.loi-cic-hero-card {
|
||||
display: flex;
|
||||
@@ -988,211 +978,3 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.loi-optional-tag {
|
||||
font-weight: 600;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
color: #0369a1;
|
||||
}
|
||||
|
||||
.loi-deposit-note {
|
||||
margin-bottom: 14px;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid #bae6fd;
|
||||
border-radius: 8px;
|
||||
background: #f0f9ff;
|
||||
color: #075985;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.loi-dep-wrap {
|
||||
overflow-x: auto;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.loi-dep-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
||||
th {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: #64748b;
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 10px 12px;
|
||||
font-size: 13.5px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.r {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
tr.loi-dep-selected td {
|
||||
background: #eef2ff;
|
||||
}
|
||||
|
||||
tr.loi-dep-disabled td {
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
.loi-dep-calc {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 14px 16px;
|
||||
margin: 12px 0;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.loi-dep-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
font-size: 13.5px;
|
||||
color: #334155;
|
||||
|
||||
b {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #0f172a;
|
||||
}
|
||||
}
|
||||
|
||||
.loi-dep-total {
|
||||
padding-top: 10px;
|
||||
border-top: 1px dashed #cbd5e1;
|
||||
font-size: 15px;
|
||||
|
||||
b {
|
||||
color: #15803d;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.loi-dep-fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
margin-bottom: 10px;
|
||||
|
||||
@media (width <= 700px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.loi-deposit-ok {
|
||||
margin: 6px 0;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: 8px;
|
||||
background: #f0fdf4;
|
||||
color: #15803d;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.loi-lien-cert {
|
||||
margin: 12px 0;
|
||||
padding: 14px 18px;
|
||||
border: 2px dashed #94a3b8;
|
||||
border-radius: 10px;
|
||||
background: #fffef7;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.pg-btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.loi-approval-wait {
|
||||
margin: 10px 0;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #fde68a;
|
||||
border-radius: 8px;
|
||||
background: #fffbeb;
|
||||
color: #92400e;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.loi-verified {
|
||||
margin: 8px 0 12px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
font-size: 13px;
|
||||
color: #334155;
|
||||
|
||||
ul {
|
||||
margin: 6px 0 0;
|
||||
padding-left: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
li {
|
||||
padding: 2px 0;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.loi-verified--no {
|
||||
color: #b91c1c;
|
||||
}
|
||||
}
|
||||
|
||||
.loi-locked {
|
||||
pointer-events: none;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.loi-checker {
|
||||
margin: 8px 0 12px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #c7d2fe;
|
||||
border-radius: 8px;
|
||||
background: #eef2ff;
|
||||
font-size: 13px;
|
||||
color: #1e1b4b;
|
||||
}
|
||||
|
||||
/* A stage sent for approval: files can still be opened (View), nothing can be added, replaced or removed. */
|
||||
.loi-docs-locked {
|
||||
label.pg-btn,
|
||||
.loi-legal-doc-thumb-remove,
|
||||
.loi-doc-remove,
|
||||
.loi-custom-doc-input,
|
||||
.loan-action-row {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.loi-view-link {
|
||||
margin-left: 8px;
|
||||
padding: 2px 10px;
|
||||
border: 1px solid #c7d2fe;
|
||||
border-radius: 999px;
|
||||
background: #eef2ff;
|
||||
color: #3730a3;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -9,13 +9,7 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { BranchMaster, LoanApplication } from './loan-origination-data';
|
||||
|
||||
/** The code a branch carries on an application: the Fineract office it belongs to (office 1 is "OFF1"). */
|
||||
export function officeBranchCode(officeId: number | string): string {
|
||||
return `OFF${officeId}`;
|
||||
}
|
||||
import { LoanApplication } from './loan-origination-data';
|
||||
|
||||
/**
|
||||
* Persistence for the Loan Origination Pipeline, in two deliberately different shapes.
|
||||
@@ -35,23 +29,6 @@ export class LoanOriginationService {
|
||||
|
||||
private readonly baseUrl = '/loan-origination/applications';
|
||||
|
||||
/** One fixed or recurring deposit account with its balance, rate and maturity. */
|
||||
getDepositAccount(kind: 'FD' | 'RD', accountId: number): Observable<any> {
|
||||
return this.http.get(`/${kind === 'FD' ? 'fixeddepositaccounts' : 'recurringdepositaccounts'}/${accountId}`);
|
||||
}
|
||||
|
||||
/** The bank's real branches: every office of the organisation, in hierarchy order. */
|
||||
listBranches(): Observable<BranchMaster[]> {
|
||||
return this.http.get<{ id: number; name: string; hierarchy?: string }[]>('/offices').pipe(
|
||||
map((offices) =>
|
||||
(offices ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => (a.hierarchy ?? '').localeCompare(b.hierarchy ?? '') || a.id - b.id)
|
||||
.map((office) => ({ code: officeBranchCode(office.id), name: office.name }))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
list(): Observable<LoanApplication[]> {
|
||||
return this.http.get<LoanApplication[]>(this.baseUrl);
|
||||
}
|
||||
@@ -90,10 +67,6 @@ export class LoanOriginationService {
|
||||
return this.http.post<LoanApplication>(`${this.baseUrl}/${encodeURIComponent(applicationNo)}/${path}`, body);
|
||||
}
|
||||
|
||||
completeDepositSelection(applicationNo: string, actionBy: string, remarks?: string): Observable<LoanApplication> {
|
||||
return this.stage(applicationNo, 'deposit-selection-complete', { actionBy, remarks });
|
||||
}
|
||||
|
||||
completeKyc(applicationNo: string, actionBy: string, remarks?: string): Observable<LoanApplication> {
|
||||
return this.stage(applicationNo, 'kyc-complete', { actionBy, remarks });
|
||||
}
|
||||
@@ -151,31 +124,11 @@ export class LoanOriginationService {
|
||||
remarks?: string;
|
||||
resolutionNo?: string;
|
||||
resolutionDate?: string;
|
||||
checkerChecklist?: { label: string; ok: boolean }[];
|
||||
}
|
||||
): Observable<LoanApplication> {
|
||||
return this.stage(applicationNo, `approvals/${field}`, body);
|
||||
}
|
||||
|
||||
submitApproval(
|
||||
applicationNo: string,
|
||||
field: string,
|
||||
body: {
|
||||
decision: 'APPROVED' | 'EXCEPTION_APPROVED';
|
||||
approverRole: string;
|
||||
remarks?: string;
|
||||
resolutionNo?: string;
|
||||
resolutionDate?: string;
|
||||
checklist?: { label: string; ok: boolean }[];
|
||||
}
|
||||
): Observable<LoanApplication> {
|
||||
return this.stage(applicationNo, `approvals/${field}/submit`, body);
|
||||
}
|
||||
|
||||
withdrawApproval(applicationNo: string, field: string): Observable<LoanApplication> {
|
||||
return this.stage(applicationNo, `approvals/${field}/withdraw`, {});
|
||||
}
|
||||
|
||||
markSigned(applicationNo: string, actionBy: string): Observable<LoanApplication> {
|
||||
return this.stage(applicationNo, 'signing', { actionBy });
|
||||
}
|
||||
|
||||
@@ -171,51 +171,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (awaitingApproval.length > 0) {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Awaiting Approval ({{ awaitingApproval.length }})</div>
|
||||
<div class="pg-table-scroll">
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Application No</th>
|
||||
<th>Applicant</th>
|
||||
<th>Stage</th>
|
||||
<th>Sent by</th>
|
||||
<th>Sent on</th>
|
||||
<th>For you</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of awaitingApproval; track row.app.applicationNo + row.stage) {
|
||||
<tr class="cctr-branch-row" (click)="openApplication(row.app)">
|
||||
<td>
|
||||
<span class="pg-chip">{{ row.app.applicationNo }}</span>
|
||||
</td>
|
||||
<td>{{ row.app.applicantName }}</td>
|
||||
<td>
|
||||
{{ row.stage }}
|
||||
@if (row.exception) {
|
||||
(exception)
|
||||
}
|
||||
</td>
|
||||
<td>{{ row.sentBy }}</td>
|
||||
<td>{{ row.sentOn }}</td>
|
||||
<td>
|
||||
@if (row.mine) {
|
||||
<span class="loan-badge loan-badge--hold">Sent by you — waiting for another user</span>
|
||||
} @else {
|
||||
<span class="loan-badge loan-badge--missing">Needs your decision</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Applications ({{ filteredApplications.length }})</div>
|
||||
<div class="pg-table-scroll">
|
||||
@@ -348,38 +303,19 @@
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>* Product Type</span>
|
||||
<select [(ngModel)]="newApp.productGroup" (ngModelChange)="onProductGroupChange()">
|
||||
<option value="">Select product type</option>
|
||||
@for (g of productGroups; track g.id) {
|
||||
<option [value]="g.id">{{ g.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>* Sub Type</span>
|
||||
<select
|
||||
[(ngModel)]="newApp.productSubType"
|
||||
(ngModelChange)="onSubTypeChange()"
|
||||
[disabled]="!newApp.productGroup"
|
||||
>
|
||||
<option value="">Select sub type</option>
|
||||
@for (t of subTypeOptions; track t.id) {
|
||||
<option [value]="t.id">{{ t.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>* Product</span>
|
||||
<select
|
||||
[(ngModel)]="newApp.productCode"
|
||||
(ngModelChange)="onProductChange()"
|
||||
[disabled]="!newApp.productSubType"
|
||||
>
|
||||
<option value="">{{ loadingProducts ? 'Loading products…' : 'Select product' }}</option>
|
||||
@for (p of productOptions; track p.id) {
|
||||
<option [value]="p.shortName">{{ p.name }}</option>
|
||||
<select [(ngModel)]="newApp.productCode" (ngModelChange)="onProductChange()">
|
||||
@for (p of products; track p.code) {
|
||||
<option [value]="p.code">{{ p.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>* Product Type</span>
|
||||
<select [(ngModel)]="newApp.productType">
|
||||
@for (t of newAppProductTypeOptions; track t) {
|
||||
<option [value]="t">{{ productTypeLabels[t] }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
@@ -389,12 +325,7 @@
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>Tenure (Months)</span>
|
||||
<select [(ngModel)]="newApp.tenureMonths" [disabled]="!selectedProduct">
|
||||
<option [ngValue]="null">{{ selectedProduct ? 'Select tenure' : 'Select a product first' }}</option>
|
||||
@for (m of tenureOptions; track m) {
|
||||
<option [ngValue]="m">{{ m }} months</option>
|
||||
}
|
||||
</select>
|
||||
<input type="number" [(ngModel)]="newApp.tenureMonths" placeholder="e.g. 36" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -13,10 +13,9 @@ import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { AuthenticationService } from '../../core/authentication/authentication.service';
|
||||
import { ClientsService } from '../../clients/clients.service';
|
||||
import { ExportSheetSpec, exportWorkbook } from '../../cashier/cashier-export';
|
||||
import { LoanOriginationService, officeBranchCode } from '../loan-origination.service';
|
||||
import { LoanProductCatalogService } from '../../loans/services/loan-product-catalog.service';
|
||||
import { PRODUCT_SUB_TYPES, PRODUCT_TYPE_GROUPS } from '../../products/loan-products/loan-product-types';
|
||||
import { LoanOriginationService } from '../loan-origination.service';
|
||||
import {
|
||||
BRANCHES,
|
||||
BranchMaster,
|
||||
branchTargetAmount,
|
||||
Channel,
|
||||
@@ -26,10 +25,10 @@ import {
|
||||
generateSeedApplications,
|
||||
LoanApplication,
|
||||
ORIGINATION_PRODUCTS,
|
||||
registerOriginationProduct,
|
||||
OriginationStage,
|
||||
OverallStatus,
|
||||
PRODUCT_TARGET_PCT,
|
||||
PRODUCT_TYPE_LABELS,
|
||||
ProductType,
|
||||
SHARE_LINKAGE_SLABS,
|
||||
STAGE_LABELS,
|
||||
@@ -37,7 +36,9 @@ import {
|
||||
STATUS_LABELS,
|
||||
TARGET_STATUS_LABELS,
|
||||
TargetStatus,
|
||||
allowedProductTypes,
|
||||
createApplication,
|
||||
defaultProductType,
|
||||
progressPct,
|
||||
stageBadgeClass,
|
||||
targetStatus,
|
||||
@@ -123,12 +124,7 @@ export class LoanPipelineComponent implements OnInit {
|
||||
readonly stageLabels = STAGE_LABELS;
|
||||
readonly statusLabels = STATUS_LABELS;
|
||||
readonly channelLabels = CHANNEL_LABELS;
|
||||
/** The bank's offices (real). Until they load, the logged-in user's own office is the only choice. */
|
||||
branches: BranchMaster[] = [];
|
||||
/** Active products saved in Product Creation -- the only products an application can be taken for. */
|
||||
dbProducts: any[] = [];
|
||||
loadingProducts = true;
|
||||
readonly productGroups = PRODUCT_TYPE_GROUPS;
|
||||
readonly branches = BRANCHES;
|
||||
readonly products = ORIGINATION_PRODUCTS;
|
||||
readonly shareLinkageSlabs = SHARE_LINKAGE_SLABS;
|
||||
asOnDate = todayDDMMYYYY();
|
||||
@@ -156,63 +152,26 @@ export class LoanPipelineComponent implements OnInit {
|
||||
customerType: 'NEW' as CustomerType,
|
||||
applicantName: '',
|
||||
clientId: null as number | null,
|
||||
branchCode: '',
|
||||
productGroup: '',
|
||||
productSubType: '',
|
||||
productCode: '',
|
||||
productType: 'TERM_LOAN' as ProductType,
|
||||
branchCode: this.branches[0].code,
|
||||
productCode: this.products[0].code,
|
||||
productType: defaultProductType(this.products[0].code),
|
||||
requestedAmount: null as number | null,
|
||||
tenureMonths: null as number | null
|
||||
};
|
||||
|
||||
/** Sub types of the chosen Product Type that actually have an active product saved. */
|
||||
get subTypeOptions() {
|
||||
const group = this.newApp.productGroup;
|
||||
return (PRODUCT_SUB_TYPES[group] ?? []).filter((t) =>
|
||||
this.dbProducts.some(
|
||||
(p) => p.advanceExtensions?.productTypeGroup === group && p.advanceExtensions?.productTypeSubGroup === t.id
|
||||
)
|
||||
);
|
||||
readonly productTypeLabels = PRODUCT_TYPE_LABELS;
|
||||
|
||||
/** Which Product Types the currently-selected product can be sanctioned as -- Cash Credit/KCC
|
||||
* are Overdraft-only, Gold Loan offers both, everything else is Term Loan-only. */
|
||||
get newAppProductTypeOptions(): ProductType[] {
|
||||
return allowedProductTypes(this.newApp.productCode);
|
||||
}
|
||||
|
||||
/** The products saved in the database for the chosen Product Type and Sub Type. */
|
||||
get productOptions(): any[] {
|
||||
return this.dbProducts.filter(
|
||||
(p) =>
|
||||
p.advanceExtensions?.productTypeGroup === this.newApp.productGroup &&
|
||||
p.advanceExtensions?.productTypeSubGroup === this.newApp.productSubType
|
||||
);
|
||||
}
|
||||
|
||||
/** Only the tenures the chosen product allows (its min to max, in months). */
|
||||
get tenureOptions(): number[] {
|
||||
const p = this.selectedProduct;
|
||||
if (!p) {
|
||||
return [];
|
||||
}
|
||||
return Array.from({ length: Math.max(0, p.maxTenure - p.minTenure + 1) }, (_, i) => p.minTenure + i);
|
||||
}
|
||||
|
||||
get selectedProduct() {
|
||||
return this.products.find((p) => p.code === this.newApp.productCode) || null;
|
||||
}
|
||||
|
||||
onProductGroupChange(): void {
|
||||
this.newApp.productSubType = '';
|
||||
this.newApp.productCode = '';
|
||||
}
|
||||
|
||||
onSubTypeChange(): void {
|
||||
const options = this.productOptions;
|
||||
this.newApp.productCode = options.length === 1 ? options[0].shortName : '';
|
||||
this.onProductChange();
|
||||
}
|
||||
|
||||
/** Advances have no EMI (revolving limit); Loans are EMI term loans. */
|
||||
/** Switching Product can change which Product Types are even valid -- reset to that product's
|
||||
* default rather than leaving a stale selection (e.g. "Overdraft" left over from Gold Loan)
|
||||
* on a product that doesn't offer it. */
|
||||
onProductChange(): void {
|
||||
this.newApp.productType = this.newApp.productGroup === 'advances' ? 'OVERDRAFT' : 'TERM_LOAN';
|
||||
const product = this.selectedProduct;
|
||||
this.newApp.tenureMonths = product ? product.minTenure : null;
|
||||
this.newApp.productType = defaultProductType(this.newApp.productCode);
|
||||
}
|
||||
|
||||
/** Existing-customer search (real Fineract clients, unlike the rest of this mock pipeline). */
|
||||
@@ -230,7 +189,6 @@ export class LoanPipelineComponent implements OnInit {
|
||||
private readonly auth = inject(AuthenticationService);
|
||||
private readonly clientsService = inject(ClientsService);
|
||||
private readonly originationService = inject(LoanOriginationService);
|
||||
private readonly catalog = inject(LoanProductCatalogService);
|
||||
|
||||
/** The live book, loaded from the database. Every count, report and filter on this screen derives
|
||||
* from this one array, so the list can never disagree with what an application actually says. */
|
||||
@@ -263,27 +221,17 @@ export class LoanPipelineComponent implements OnInit {
|
||||
const creds = this.auth.getCredentials();
|
||||
this.isHeadOffice = this.auth.isHeadOfficeUser();
|
||||
this.currentOfficeName = creds?.officeName || 'Branch Office';
|
||||
this.ownBranchCode = creds?.officeId ? officeBranchCode(creds.officeId) : null;
|
||||
// The logged-in user's own office is the default branch. A branch-level login can only originate for its own branch:
|
||||
// the select is disabled and `saveNewApplication` forces the code again, so it is locked here and not just hidden.
|
||||
this.branches = this.ownBranchCode ? [{ code: this.ownBranchCode, name: this.currentOfficeName }] : [];
|
||||
this.branchFilter = this.isHeadOffice ? '' : this.ownBranchCode || '';
|
||||
this.newApp.branchCode = this.ownBranchCode || '';
|
||||
this.catalog.activeProducts().subscribe((products) => {
|
||||
this.dbProducts = products.filter((p) => p.shortName && p.advanceExtensions?.productTypeGroup);
|
||||
this.dbProducts.forEach((p) => registerOriginationProduct(p));
|
||||
this.loadingProducts = false;
|
||||
});
|
||||
this.originationService.listBranches().subscribe({
|
||||
next: (offices) => {
|
||||
if (offices.length > 0) {
|
||||
this.branches = offices;
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
/* keep the user's own office as the only choice */
|
||||
}
|
||||
});
|
||||
if (this.isHeadOffice) {
|
||||
this.branchFilter = '';
|
||||
} else {
|
||||
this.ownBranchCode =
|
||||
this.branches.find((b: BranchMaster) => this.currentOfficeName.toLowerCase().includes(b.name.toLowerCase()))
|
||||
?.code || null;
|
||||
this.branchFilter = this.ownBranchCode || this.branches[0].code;
|
||||
// A branch-level login can only originate applications for its own branch — locked here,
|
||||
// not just hidden, since the New Application form posts whatever branchCode is bound.
|
||||
this.newApp.branchCode = this.branchFilter;
|
||||
}
|
||||
|
||||
this.loadApplications();
|
||||
}
|
||||
@@ -362,42 +310,6 @@ export class LoanPipelineComponent implements OnInit {
|
||||
return this.branchFilter ? this.applications.filter((a) => a.branchCode === this.branchFilter) : this.applications;
|
||||
}
|
||||
|
||||
private get currentUserName(): string {
|
||||
return (this.auth.getCredentials()?.username ?? '').toLowerCase();
|
||||
}
|
||||
|
||||
/** Applications with an approval sent and waiting for a decision (maker-checker), newest first. */
|
||||
get awaitingApproval() {
|
||||
const rows: {
|
||||
app: LoanApplication;
|
||||
stage: string;
|
||||
sentBy: string;
|
||||
sentOn: string;
|
||||
mine: boolean;
|
||||
exception: boolean;
|
||||
}[] = [];
|
||||
this.applications.forEach((app) => {
|
||||
Object.entries(app.approvalRequests ?? {}).forEach(
|
||||
([
|
||||
,
|
||||
request
|
||||
]) => {
|
||||
if (request && request.status === 'PENDING') {
|
||||
rows.push({
|
||||
app,
|
||||
stage: STAGE_LABELS[app.currentStage],
|
||||
sentBy: request.submittedBy,
|
||||
sentOn: request.submittedOn,
|
||||
mine: (request.submittedBy ?? '').toLowerCase() === this.currentUserName,
|
||||
exception: request.requestedDecision === 'EXCEPTION_APPROVED'
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
get filteredApplications(): LoanApplication[] {
|
||||
return this.scopedApplications.filter((a) => {
|
||||
if (this.channelFilter && a.channel !== this.channelFilter) {
|
||||
@@ -631,7 +543,7 @@ export class LoanPipelineComponent implements OnInit {
|
||||
|
||||
// -------------------- New Application intake --------------------
|
||||
productOf(code: string) {
|
||||
return this.products.find((p) => p.code === code);
|
||||
return this.products.find((p) => p.code === code) || this.products[0];
|
||||
}
|
||||
|
||||
/** Switching Customer Type away from "Existing" drops any client picked under the old mode --
|
||||
@@ -696,27 +608,8 @@ export class LoanPipelineComponent implements OnInit {
|
||||
if (!this.isHeadOffice && this.ownBranchCode) {
|
||||
this.newApp.branchCode = this.ownBranchCode;
|
||||
}
|
||||
const branch = this.branches.find((b) => b.code === this.newApp.branchCode) ||
|
||||
this.branches[0] || { code: this.newApp.branchCode, name: this.currentOfficeName };
|
||||
const branch = this.branches.find((b) => b.code === this.newApp.branchCode) || this.branches[0];
|
||||
const product = this.productOf(this.newApp.productCode);
|
||||
if (!product) {
|
||||
this.toast('Select the Product Type, Sub Type and Product.');
|
||||
return;
|
||||
}
|
||||
if (
|
||||
this.newApp.requestedAmount &&
|
||||
(this.newApp.requestedAmount < product.minAmt || this.newApp.requestedAmount > product.maxAmt)
|
||||
) {
|
||||
this.toast(
|
||||
`Amount must be between ₹${product.minAmt.toLocaleString('en-IN')} and ₹${product.maxAmt.toLocaleString('en-IN')} for ${product.name}.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
const tenure = this.newApp.tenureMonths || product.minTenure;
|
||||
if (tenure < product.minTenure || tenure > product.maxTenure) {
|
||||
this.toast(`Tenure must be between ${product.minTenure} and ${product.maxTenure} months for ${product.name}.`);
|
||||
return;
|
||||
}
|
||||
const seq = this.applications.length + 20000;
|
||||
const app = createApplication(
|
||||
{
|
||||
@@ -751,11 +644,9 @@ export class LoanPipelineComponent implements OnInit {
|
||||
customerType: 'NEW',
|
||||
applicantName: '',
|
||||
clientId: null,
|
||||
branchCode: this.ownBranchCode || this.branches[0]?.code || '',
|
||||
productGroup: '',
|
||||
productSubType: '',
|
||||
productCode: '',
|
||||
productType: 'TERM_LOAN',
|
||||
branchCode: this.isHeadOffice ? this.branches[0].code : this.ownBranchCode || this.branches[0].code,
|
||||
productCode: this.products[0].code,
|
||||
productType: defaultProductType(this.products[0].code),
|
||||
requestedAmount: null,
|
||||
tenureMonths: null
|
||||
};
|
||||
|
||||
-23
@@ -7,27 +7,6 @@
|
||||
-->
|
||||
|
||||
<form [formGroup]="loansAccountDetailsForm">
|
||||
<div class="layout-row-wrap layout-lt-md-column gap-2percent">
|
||||
<mat-form-field class="flex-48">
|
||||
<mat-label>Category</mat-label>
|
||||
<mat-select [value]="categoryFilter" (selectionChange)="categoryFilter = $event.value; onCategoryChange()">
|
||||
<mat-option value="">All categories</mat-option>
|
||||
@for (category of categories; track category.id) {
|
||||
<mat-option [value]="category.id">{{ category.label }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field class="flex-48">
|
||||
<mat-label>Product type</mat-label>
|
||||
<mat-select [value]="typeFilter" (selectionChange)="typeFilter = $event.value; applyProductFilters()">
|
||||
<mat-option value="">All types</mat-option>
|
||||
@for (type of typeOptions; track type.id) {
|
||||
<mat-option [value]="type.id">{{ type.label }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="layout-row-wrap gap-2px responsive-column">
|
||||
<mat-form-field class="flex-fill">
|
||||
<mat-label>{{ 'labels.inputs.Product Name' | translate }}</mat-label>
|
||||
@@ -56,8 +35,6 @@
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<mifosx-loan-product-template-card [product]="selectedProduct"></mifosx-loan-product-template-card>
|
||||
|
||||
@if (loanProductSelected) {
|
||||
<div class="layout-row-wrap layout-lt-md-column gap-2percent">
|
||||
<mat-form-field class="flex-48">
|
||||
|
||||
+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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
-9
@@ -96,15 +96,6 @@
|
||||
>
|
||||
Edit · Submit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="lirr__icon-btn lirr__icon-btn--danger"
|
||||
title="Delete draft"
|
||||
aria-label="Delete draft"
|
||||
(click)="deleteDraft(r, $event)"
|
||||
>
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</ng-container>
|
||||
<ng-container *ngSwitchCase="'Active'">
|
||||
<button
|
||||
|
||||
-28
@@ -522,31 +522,3 @@
|
||||
color: #3730a3;
|
||||
}
|
||||
}
|
||||
|
||||
/* Icon-only action on a row (delete draft). */
|
||||
.lirr__icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
margin-left: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
&--danger {
|
||||
color: #dc2626;
|
||||
border-color: #fecaca;
|
||||
|
||||
&:hover {
|
||||
background: #fef2f2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,6 @@ import { Component, OnInit, inject } from '@angular/core';
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { Router } from '@angular/router';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
|
||||
import { MatExpansionModule } from '@angular/material/expansion';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { RateRegisterService } from './rate-register.service';
|
||||
@@ -35,7 +33,6 @@ export class LoanInterestRateRegisterComponent implements OnInit {
|
||||
private readonly service = inject(RateRegisterService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly snack = inject(MatSnackBar);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
|
||||
registers: RateRegister[] = [];
|
||||
active?: RateRegister;
|
||||
@@ -74,22 +71,6 @@ export class LoanInterestRateRegisterComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
/** Deletes a draft (a version that was never submitted) after a confirmation. */
|
||||
deleteDraft(register: RateRegister, event: Event): void {
|
||||
event.stopPropagation();
|
||||
const label = register.name || `draft from ${this.formatShort(register.effectiveFrom)}`;
|
||||
this.dialog
|
||||
.open(DeleteDialogComponent, { data: { deleteContext: label } })
|
||||
.afterClosed()
|
||||
.subscribe((response: any) => {
|
||||
if (response?.delete) {
|
||||
this.service.deleteDraft(register.id);
|
||||
this.snack.open('Draft deleted', 'OK', { duration: 3000 });
|
||||
this.reload();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Checker approves the pending register. The previously-Active one is deactivated. */
|
||||
approve(register: RateRegister, event: Event): void {
|
||||
event.stopPropagation();
|
||||
|
||||
@@ -52,13 +52,6 @@ export class RateRegisterService {
|
||||
}
|
||||
}
|
||||
|
||||
/** Throws a draft away. Only a local, never-submitted draft can go; server versions are never touched. */
|
||||
deleteDraft(id: number): void {
|
||||
this.localDrafts = this.localDrafts.filter((d) => !(d.id === id && d.status === 'Draft'));
|
||||
this.cache = this.cache.filter((d) => !(d.id === id && d.status === 'Draft'));
|
||||
this.saveDrafts();
|
||||
}
|
||||
|
||||
/** Persist the current drafts (call after any edit the user should not lose). */
|
||||
saveDrafts(): void {
|
||||
try {
|
||||
|
||||
+50
-80
@@ -9,9 +9,7 @@
|
||||
<div class="lp-container">
|
||||
<div class="lp-header">
|
||||
<i class="fas fa-hand-holding-usd lp-header__icon"></i>
|
||||
<h2 class="lp-header__title">
|
||||
{{ isEdit ? 'Edit Loan Product' : ('labels.heading.Create Loan Product' | translate) }}
|
||||
</h2>
|
||||
<h2 class="lp-header__title">{{ 'labels.heading.Create Loan Product' | translate }}</h2>
|
||||
</div>
|
||||
|
||||
<mat-tab-group
|
||||
@@ -32,7 +30,7 @@
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<mat-tab [disabled]="!hasProductType">
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<i class="fas fa-coins lp-tab-icon"></i>
|
||||
{{ 'labels.inputs.CURRENCY' | translate }}
|
||||
@@ -45,16 +43,10 @@
|
||||
</mat-tab>
|
||||
|
||||
@if (isAdvanceProduct) {
|
||||
<mat-tab [disabled]="!hasProductType">
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<i class="fas fa-shield-alt lp-tab-icon"></i>
|
||||
{{
|
||||
advanceSubType === 'gold'
|
||||
? 'SECURITY & GOLD'
|
||||
: advanceSubType === 'warehouse'
|
||||
? 'SECURITY & WAREHOUSE'
|
||||
: 'SECURITY & LIEN'
|
||||
}}
|
||||
SECURITY & LIEN
|
||||
</ng-template>
|
||||
<div class="lp-tab-content">
|
||||
<mifosx-loan-product-security-lien-step
|
||||
@@ -65,7 +57,7 @@
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<mat-tab [disabled]="!hasProductType">
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<i class="fas fa-sliders-h lp-tab-icon"></i>
|
||||
LIMIT & TENURE
|
||||
@@ -77,7 +69,7 @@
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<mat-tab [disabled]="!hasProductType">
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<i class="fas fa-percent lp-tab-icon"></i>
|
||||
INTEREST
|
||||
@@ -90,7 +82,19 @@
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<mat-tab [disabled]="!hasProductType">
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<i class="fas fa-triangle-exclamation lp-tab-icon"></i>
|
||||
PENAL & NPA
|
||||
</ng-template>
|
||||
<div class="lp-tab-content">
|
||||
<mifosx-loan-product-penal-npa-step
|
||||
[loanProductsTemplate]="loanProductsTemplate"
|
||||
></mifosx-loan-product-penal-npa-step>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<i class="fas fa-arrows-rotate lp-tab-icon"></i>
|
||||
RENEWAL & CLOSURE
|
||||
@@ -101,7 +105,7 @@
|
||||
</mat-tab>
|
||||
|
||||
@if (isOverdraftFacility) {
|
||||
<mat-tab [disabled]="!hasProductType">
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<i class="fas fa-money-check lp-tab-icon"></i>
|
||||
CHEQUE BOOK
|
||||
@@ -114,31 +118,27 @@
|
||||
}
|
||||
|
||||
@if (isLoanAccount) {
|
||||
<mat-tab [disabled]="!hasProductType">
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<i class="fas fa-shield-alt lp-tab-icon"></i>
|
||||
SECURITY & COLLATERAL
|
||||
</ng-template>
|
||||
<div class="lp-tab-content">
|
||||
<mifosx-loan-product-loan-security-step
|
||||
[productTypeSubGroup]="loanProductDetailsForm.get('productTypeSubGroup')"
|
||||
></mifosx-loan-product-loan-security-step>
|
||||
<mifosx-loan-product-loan-security-step></mifosx-loan-product-loan-security-step>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<mat-tab [disabled]="!hasProductType">
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<i class="fas fa-sliders-h lp-tab-icon"></i>
|
||||
AMOUNT & TENURE
|
||||
</ng-template>
|
||||
<div class="lp-tab-content">
|
||||
<mifosx-loan-product-loan-amount-tenure-step
|
||||
[productSubType]="advanceSubType"
|
||||
></mifosx-loan-product-loan-amount-tenure-step>
|
||||
<mifosx-loan-product-loan-amount-tenure-step></mifosx-loan-product-loan-amount-tenure-step>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<mat-tab [disabled]="!hasProductType">
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<i class="fas fa-calendar-alt lp-tab-icon"></i>
|
||||
REPAYMENT & EMI
|
||||
@@ -148,7 +148,7 @@
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<mat-tab [disabled]="!hasProductType">
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<i class="fas fa-percent lp-tab-icon"></i>
|
||||
INTEREST
|
||||
@@ -161,7 +161,19 @@
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<mat-tab [disabled]="!hasProductType">
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<i class="fas fa-triangle-exclamation lp-tab-icon"></i>
|
||||
PENAL & NPA
|
||||
</ng-template>
|
||||
<div class="lp-tab-content">
|
||||
<mifosx-loan-product-penal-npa-step
|
||||
[loanProductsTemplate]="loanProductsTemplate"
|
||||
></mifosx-loan-product-penal-npa-step>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<i class="fas fa-flag-checkered lp-tab-icon"></i>
|
||||
PREPAYMENT & CLOSURE
|
||||
@@ -172,27 +184,12 @@
|
||||
</mat-tab>
|
||||
}
|
||||
|
||||
<!-- Common to both Advances A/C and Loans A/C (and visible even before a product type is chosen). -->
|
||||
<mat-tab [disabled]="!hasProductType">
|
||||
<ng-template mat-tab-label>
|
||||
<i class="fas fa-triangle-exclamation lp-tab-icon"></i>
|
||||
PENAL & NPA
|
||||
</ng-template>
|
||||
<div class="lp-tab-content">
|
||||
<mifosx-loan-product-penal-npa-step
|
||||
[loanProductsTemplate]="loanProductsTemplate"
|
||||
[productTypeGroup]="loanProductDetailsForm.get('productTypeGroup')"
|
||||
[productTypeSubGroup]="loanProductDetailsForm.get('productTypeSubGroup')"
|
||||
></mifosx-loan-product-penal-npa-step>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<!--
|
||||
OLD LOANS A/C PATH — commented out for now (replaced by the Loans A/C steps above).
|
||||
Settings, Interest Refund, Payment Allocation and Terms tabs are kept here so they can be restored.
|
||||
|
||||
@if (!isAdvanceProduct) {
|
||||
<mat-tab [disabled]="!hasProductType">
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<i class="fas fa-cog lp-tab-icon"></i>
|
||||
{{ 'labels.inputs.SETTINGS' | translate }}
|
||||
@@ -209,7 +206,7 @@
|
||||
}
|
||||
|
||||
@if (isAdvancedPaymentStrategy) {
|
||||
<mat-tab [disabled]="!hasProductType">
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<i class="fas fa-undo lp-tab-icon"></i>
|
||||
{{ 'labels.inputs.INTEREST REFUND' | translate }}
|
||||
@@ -222,7 +219,7 @@
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<mat-tab [disabled]="!hasProductType">
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<i class="fas fa-exchange-alt lp-tab-icon"></i>
|
||||
{{ 'labels.inputs.PAYMENT ALLOCATION' | translate }}
|
||||
@@ -243,7 +240,7 @@
|
||||
}
|
||||
|
||||
@if (!isAdvanceProduct) {
|
||||
<mat-tab [disabled]="!hasProductType">
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<i class="fas fa-sliders-h lp-tab-icon"></i>
|
||||
{{ 'labels.inputs.TERMS' | translate }}
|
||||
@@ -257,7 +254,7 @@
|
||||
}
|
||||
-->
|
||||
|
||||
<mat-tab [disabled]="!hasProductType">
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<i class="fas fa-receipt lp-tab-icon"></i>
|
||||
{{ 'labels.inputs.CHARGES' | translate }}
|
||||
@@ -273,7 +270,7 @@
|
||||
|
||||
<!-- OLD LOANS A/C PATH (Deferred Income Recognition) — commented out for now.
|
||||
@if (isAdvancedPaymentStrategy) {
|
||||
<mat-tab [disabled]="!hasProductType">
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<i class="fas fa-clock lp-tab-icon"></i>
|
||||
{{ 'labels.inputs.DEFERRED INCOME RECOGNITION' | translate }}
|
||||
@@ -294,7 +291,7 @@
|
||||
}
|
||||
-->
|
||||
|
||||
<mat-tab [disabled]="!hasProductType">
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<i class="fas fa-book lp-tab-icon"></i>
|
||||
{{ 'labels.inputs.ACCOUNTING' | translate }}
|
||||
@@ -305,15 +302,12 @@
|
||||
[accountingRuleData]="accountingRuleData"
|
||||
[loanProductFormValid]="loanProductFormValid"
|
||||
[deferredIncomeRecognition]="deferredIncomeRecognition"
|
||||
[productTypeGroup]="loanProductDetailsForm.get('productTypeGroup')?.value"
|
||||
[productTypeSubGroup]="loanProductDetailsForm.get('productTypeSubGroup')?.value"
|
||||
[productGlAccountId]="productGlAccount"
|
||||
></mifosx-loan-product-accounting-step>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
@if (loanProductFormValid) {
|
||||
<mat-tab [disabled]="!hasProductType">
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<i class="fas fa-eye lp-tab-icon"></i>
|
||||
{{ 'labels.inputs.PREVIEW' | translate }}
|
||||
@@ -322,7 +316,7 @@
|
||||
<mifosx-loan-product-preview-step
|
||||
[loanProductsTemplate]="loanProductsTemplate"
|
||||
[accountingRuleData]="accountingRuleData"
|
||||
[loanProduct]="previewLoanProduct"
|
||||
[loanProduct]="loanProduct"
|
||||
(submitEvent)="submit()"
|
||||
></mifosx-loan-product-preview-step>
|
||||
</div>
|
||||
@@ -330,25 +324,6 @@
|
||||
}
|
||||
</mat-tab-group>
|
||||
|
||||
@if (hasProductType && !loanProductFormValid) {
|
||||
<div class="lp-incomplete">
|
||||
<i class="fas fa-circle-info"></i>
|
||||
<div>
|
||||
<strong>The Review tab appears once every tab is complete.</strong>
|
||||
Still to finish:
|
||||
@for (item of incompleteTabs; track item.tab; let last = $last) {
|
||||
<b>{{ item.tab }}</b>
|
||||
@if (item.fields.length > 0) {
|
||||
({{ item.fields.join(', ') }})
|
||||
}
|
||||
@if (!last) {
|
||||
,
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="lp-action-bar">
|
||||
<button mat-raised-button [routerLink]="['../']" class="lp-action-bar__cancel">
|
||||
{{ 'labels.buttons.Cancel' | translate }}
|
||||
@@ -361,12 +336,7 @@
|
||||
</button>
|
||||
}
|
||||
@if (selectedTabIndex < totalTabs - 1) {
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
[disabled]="!hasProductType"
|
||||
(click)="selectedTabIndex = selectedTabIndex + 1"
|
||||
>
|
||||
<button mat-raised-button color="primary" (click)="selectedTabIndex = selectedTabIndex + 1">
|
||||
{{ 'labels.buttons.Next' | translate }}
|
||||
<i class="fas fa-arrow-right" style="margin-left: 8px"></i>
|
||||
</button>
|
||||
@@ -374,7 +344,7 @@
|
||||
@if (loanProductFormValid && selectedTabIndex === totalTabs - 1) {
|
||||
<button mat-raised-button color="primary" (click)="submit()">
|
||||
<i class="fas fa-check" style="margin-right: 8px"></i>
|
||||
{{ isEdit ? 'Save changes' : ('labels.buttons.Submit' | translate) }}
|
||||
{{ 'labels.buttons.Submit' | translate }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -90,27 +90,3 @@ $primary: #1353a0;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tells the user which tabs still block the Review tab. */
|
||||
.lp-incomplete {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
margin: 16px 0 0;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid #fcd34d;
|
||||
border-radius: 8px;
|
||||
background: #fffbeb;
|
||||
color: #78350f;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
|
||||
i {
|
||||
margin-top: 3px;
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
b {
|
||||
color: #92400e;
|
||||
}
|
||||
}
|
||||
|
||||
+37
-619
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { AfterViewInit, Component, OnInit, ViewChild, inject } from '@angular/core';
|
||||
import { Component, OnInit, ViewChild, inject } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
|
||||
/** Custom Components */
|
||||
@@ -84,7 +84,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
LoanProductPreviewStepComponent
|
||||
]
|
||||
})
|
||||
export class CreateLoanProductComponent implements OnInit, AfterViewInit {
|
||||
export class CreateLoanProductComponent implements OnInit {
|
||||
private route = inject(ActivatedRoute);
|
||||
private productsService = inject(ProductsService);
|
||||
private alertService = inject(AlertService);
|
||||
@@ -135,8 +135,6 @@ export class CreateLoanProductComponent implements OnInit, AfterViewInit {
|
||||
loanProductAccountingStep: LoanProductAccountingStepComponent;
|
||||
|
||||
loanProductsTemplate: any;
|
||||
/** True when this wizard edits a saved product (the edit route) instead of creating one. */
|
||||
isEdit = false;
|
||||
accountingRuleData: string[] = [];
|
||||
itemsByDefault: any[] = [];
|
||||
selectedTabIndex = 0;
|
||||
@@ -160,382 +158,22 @@ export class CreateLoanProductComponent implements OnInit, AfterViewInit {
|
||||
constructor() {
|
||||
const loanProducts = this.loanProducts;
|
||||
|
||||
this.route.data.subscribe(
|
||||
(data: { loanProductsTemplate: any; loanProductAndTemplate: any; configurations: any }) => {
|
||||
// The edit route resolves the saved product together with the template options.
|
||||
this.isEdit = !!data.loanProductAndTemplate;
|
||||
this.loanProductsTemplate = data.loanProductAndTemplate ?? data.loanProductsTemplate;
|
||||
const assetAccountData = this.loanProductsTemplate.accountingMappingOptions.assetAccountOptions || [];
|
||||
const liabilityAccountData = this.loanProductsTemplate.accountingMappingOptions.liabilityAccountOptions || [];
|
||||
this.loanProductsTemplate.accountingMappingOptions.assetAndLiabilityAccountOptions =
|
||||
assetAccountData.concat(liabilityAccountData);
|
||||
this.route.data.subscribe((data: { loanProductsTemplate: any; configurations: any }) => {
|
||||
this.loanProductsTemplate = data.loanProductsTemplate;
|
||||
const assetAccountData = this.loanProductsTemplate.accountingMappingOptions.assetAccountOptions || [];
|
||||
const liabilityAccountData = this.loanProductsTemplate.accountingMappingOptions.liabilityAccountOptions || [];
|
||||
this.loanProductsTemplate.accountingMappingOptions.assetAndLiabilityAccountOptions =
|
||||
assetAccountData.concat(liabilityAccountData);
|
||||
|
||||
this.itemsByDefault = loanProducts.setItemsByDefault(data.configurations);
|
||||
this.loanProductsTemplate['itemsByDefault'] = this.itemsByDefault;
|
||||
if (!this.isEdit) {
|
||||
this.loanProductsTemplate = loanProducts.updateLoanProductDefaults(this.loanProductsTemplate, false);
|
||||
}
|
||||
}
|
||||
);
|
||||
this.itemsByDefault = loanProducts.setItemsByDefault(data.configurations);
|
||||
this.loanProductsTemplate['itemsByDefault'] = this.itemsByDefault;
|
||||
this.loanProductsTemplate = loanProducts.updateLoanProductDefaults(this.loanProductsTemplate, false);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.accountingRuleData = this.accounting.getAccountingRulesForLoans();
|
||||
this.buildAdvancedPaymentAllocation();
|
||||
// Advances and Loans A/C (EMI) products are filled into the curated tabs. A product made before the type was saved
|
||||
// uses the old editor instead of an empty form.
|
||||
const savedGroup = this.loanProductsTemplate?.advanceExtensions?.productTypeGroup;
|
||||
if (this.isEdit && savedGroup !== 'advances' && savedGroup !== 'loans') {
|
||||
this.router.navigate(['../edit-legacy'], { relativeTo: this.route });
|
||||
}
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
if (this.isEdit) {
|
||||
this.populateWhenReady(0);
|
||||
}
|
||||
}
|
||||
|
||||
/** The tabs of the product's type appear after the Details step sets the type; fill them once they exist. */
|
||||
private populateWhenReady(attempt: number): void {
|
||||
const group = this.loanProductsTemplate?.advanceExtensions?.productTypeGroup;
|
||||
const ready =
|
||||
group === 'advances'
|
||||
? !!(
|
||||
this.loanProductSecurityLienStep &&
|
||||
this.loanProductAdvanceLimitTenureStep &&
|
||||
this.loanProductAdvanceInterestStep &&
|
||||
this.loanProductRenewalClosureStep &&
|
||||
this.loanProductPenalNpaStep &&
|
||||
(!this.isOverdraftFacility || this.loanProductChequeBookStep)
|
||||
)
|
||||
: group === 'loans'
|
||||
? !!(
|
||||
this.loanProductLoanSecurityStep &&
|
||||
this.loanProductLoanAmountTenureStep &&
|
||||
this.loanProductLoanRepaymentStep &&
|
||||
this.loanProductLoanInterestStep &&
|
||||
this.loanProductLoanPrepaymentStep &&
|
||||
this.loanProductPenalNpaStep
|
||||
)
|
||||
: false;
|
||||
if (!ready) {
|
||||
if (attempt < 60) {
|
||||
setTimeout(() => this.populateWhenReady(attempt + 1), 150);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (group === 'loans') {
|
||||
this.populateLoans();
|
||||
} else {
|
||||
this.populateAdvance();
|
||||
}
|
||||
}
|
||||
|
||||
/** Reverse of assembleLoanProduct: the saved core fields and the extension record go back into the Loans tabs. */
|
||||
private populateLoans(): void {
|
||||
const p = this.loanProductsTemplate;
|
||||
const e = p.advanceExtensions ?? {};
|
||||
const repayment = this.loanProductLoanRepaymentStep;
|
||||
const interest = this.loanProductLoanInterestStep;
|
||||
const frequency = repayment.frequencyOptions.find((o) => o.fineractId === p.repaymentFrequencyType?.id)?.id;
|
||||
const daysInYear = interest.daysInYearOptions.find((o) => o.fineractId === p.daysInYearType?.id)?.id;
|
||||
const daysInMonth = interest.daysInMonthOptions.find((o) => o.fineractId === p.daysInMonthType?.id)?.id;
|
||||
|
||||
this.fill(this.loanProductCurrencyStep?.loanProductCurrencyForm, {
|
||||
currencyCode: p.currency?.code,
|
||||
digitsAfterDecimal: p.currency?.decimalPlaces
|
||||
});
|
||||
|
||||
this.fill(this.loanProductLoanSecurityStep.loanProductLoanSecurityForm, {
|
||||
securityType: e.securityType,
|
||||
securityMandatory: e.securityMandatory,
|
||||
margin: e.margin,
|
||||
guarantorsRequired: e.guarantorsRequired,
|
||||
coApplicant: e.coApplicant,
|
||||
thirdPartySecurity: e.thirdPartySecurity,
|
||||
legalOpinionRequired: e.legalOpinionRequired,
|
||||
propertyInsuranceRequired: e.propertyInsuranceRequired,
|
||||
registerCharge: e.registerCharge,
|
||||
autoReleaseOnClosure: e.releaseSecurityOnClosure,
|
||||
allowMultipleSecurities: e.allowMultipleSecurities,
|
||||
allowThirdPartyDeposit: e.allowThirdPartyDeposit
|
||||
});
|
||||
|
||||
this.fill(this.loanProductLoanAmountTenureStep.loanProductLoanAmountTenureForm, {
|
||||
minAmount: p.minPrincipal,
|
||||
defaultAmount: p.principal,
|
||||
maxAmount: p.maxPrincipal,
|
||||
minTenure: p.minNumberOfRepayments,
|
||||
defaultTenure: p.numberOfRepayments,
|
||||
maxTenure: p.maxNumberOfRepayments,
|
||||
minAge: e.minAge,
|
||||
maxAgeAtMaturity: e.maxAgeAtMaturity,
|
||||
minCibil: e.minCibil,
|
||||
minMonthlyIncome: e.minMonthlyIncome,
|
||||
maxFoir: e.maxFoir,
|
||||
maxLoansPerCustomer: e.maxLoansPerCustomer,
|
||||
memberRequired: e.memberRequired,
|
||||
guarantorMustBeMember: e.guarantorMustBeMember,
|
||||
incomeMultiple: e.incomeMultiple,
|
||||
disbursementType: e.disbursementType,
|
||||
// The tranche count only applies to a multiple disbursement; a single one leaves the field at its own default.
|
||||
maxTranches: e.disbursementType === 'multiple' ? p.maxTrancheCount : undefined,
|
||||
disbursementMode: e.disbursementMode,
|
||||
undisbursedInterest: e.undisbursedInterest,
|
||||
preEmiInterest: e.preEmiInterest
|
||||
});
|
||||
|
||||
this.fill(repayment.loanProductLoanRepaymentForm, {
|
||||
repaymentType: e.repaymentType,
|
||||
repaidEvery: p.repaymentEvery,
|
||||
frequency,
|
||||
minDaysToFirstRepayment: p.minimumDaysBetweenDisbursalAndFirstRepayment,
|
||||
emiDueDay: e.emiDueDay,
|
||||
holidayRule: e.holidayRule,
|
||||
moratoriumMonths: e.moratoriumMonths,
|
||||
moratoriumInterest: e.moratoriumInterest,
|
||||
variableInstallments: e.variableInstallments,
|
||||
repaymentMode: e.repaymentMode,
|
||||
linkedAccountType: e.linkedAccountType,
|
||||
pdcCollection: e.pdcCollection,
|
||||
mandateRequired: e.mandateRequired,
|
||||
appropriationOrder: e.appropriationOrder,
|
||||
advanceEmi: e.advanceEmi
|
||||
});
|
||||
|
||||
this.fill(interest.loanProductLoanInterestForm, {
|
||||
interestType: e.rateType,
|
||||
registerParticular: e.registerParticular,
|
||||
applyCibilAdjustment: e.applyCibilAdjustment,
|
||||
spread: e.spread,
|
||||
rateResolvedAt: e.rateResolvedAt,
|
||||
minRate: p.minInterestRatePerPeriod,
|
||||
defaultRate: p.interestRatePerPeriod,
|
||||
maxRate: p.maxInterestRatePerPeriod,
|
||||
calcMethod: e.interestCalcMethod,
|
||||
daysInYear,
|
||||
daysInMonth,
|
||||
calcPeriod: e.calcPeriod,
|
||||
interestStartsFrom: e.interestStartsFrom,
|
||||
compounding: e.compounding,
|
||||
benchmark: e.benchmark,
|
||||
resetFrequency: e.resetFrequency,
|
||||
resetChange: e.resetChange
|
||||
});
|
||||
|
||||
this.fill(this.loanProductLoanPrepaymentStep.loanProductLoanPrepaymentForm, {
|
||||
partPrepaymentAllowed: e.partPrepaymentAllowed,
|
||||
minPartPayment: e.minPartPayment,
|
||||
allowedOn: e.allowedOn,
|
||||
partPaymentsPerYear: e.partPaymentsPerYear,
|
||||
partPrepaymentMode: e.partPrepaymentMode,
|
||||
foreclosureFloatingIndividual: e.foreclosureFloatingIndividual,
|
||||
foreclosureFixedIndividual: e.foreclosureFixedIndividual,
|
||||
foreclosureNonIndividual: e.foreclosureNonIndividual,
|
||||
foreclosureTakeover: e.foreclosureTakeover,
|
||||
lockInMonths: e.lockInMonths,
|
||||
interestOnPreclosure: e.interestOnPreclosure,
|
||||
foreclosureQuoteValidDays: e.foreclosureQuoteValidDays,
|
||||
autoCloseOnZeroBalance: e.autoCloseOnZeroBalance,
|
||||
nocLetter: e.nocLetter,
|
||||
restructuring: e.restructuring,
|
||||
topUpAllowed: e.topUpAllowed,
|
||||
balanceTransferOutAllowed: e.balanceTransferOutAllowed
|
||||
});
|
||||
|
||||
this.fill(this.loanProductPenalNpaStep.loanProductPenalNpaForm, {
|
||||
penalApplicable: e.penalApplicable,
|
||||
penalRate: e.penalRate,
|
||||
penalAppliedOn: e.penalAppliedOn,
|
||||
penalGraceDays: e.penalGraceDays,
|
||||
delinquencyBucketId: p.delinquencyBucket?.id,
|
||||
npaDays: p.overdueDaysForNPA,
|
||||
npaTrigger: e.npaTrigger,
|
||||
interestAfterNpa: e.interestAfterNpa,
|
||||
smaStartAfterMonths: e.smaStartAfterMonths,
|
||||
inArrearsTolerance: p.inArrearsTolerance,
|
||||
upgradeOnlyAllArrears: e.npaUpgradeOnlyAllArrears
|
||||
});
|
||||
|
||||
this.fill(this.loanProductAccountingStep?.loanProductAccountingForm, {
|
||||
undebitedInterestAccountId: e.undebitedInterestGlId
|
||||
});
|
||||
|
||||
// Filling the currency empties the Charges list; put the saved charges back, or saving would detach them.
|
||||
if (this.loanProductChargesStep) {
|
||||
this.loanProductChargesStep.chargesDataSource = [...(p.charges ?? [])];
|
||||
}
|
||||
}
|
||||
|
||||
/** Sets the controls and marks them touched-by-user so a sub type default does not overwrite the saved value. */
|
||||
private fill(form: UntypedFormGroup | undefined, values: Record<string, any>): void {
|
||||
if (!form) {
|
||||
return;
|
||||
}
|
||||
Object.keys(values).forEach((name) => {
|
||||
const control = form.get(name);
|
||||
const value = values[name];
|
||||
if (control && value !== undefined && value !== null) {
|
||||
// A YES / NO select is saved as true / false; give it the word the select understands.
|
||||
const isYesNoSelect = control.value === 'YES' || control.value === 'NO';
|
||||
control.setValue(isYesNoSelect && typeof value === 'boolean' ? (value ? 'YES' : 'NO') : value);
|
||||
control.markAsDirty();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Reverse of assembleAdvanceProduct: the saved core fields and the extension record go back into the tabs. */
|
||||
private populateAdvance(): void {
|
||||
const p = this.loanProductsTemplate;
|
||||
const e = p.advanceExtensions ?? {};
|
||||
const split = (value: any) =>
|
||||
value
|
||||
? String(value)
|
||||
.split('|')
|
||||
.filter((v) => v !== '')
|
||||
: undefined;
|
||||
|
||||
this.fill(this.loanProductCurrencyStep?.loanProductCurrencyForm, {
|
||||
currencyCode: p.currency?.code,
|
||||
digitsAfterDecimal: p.currency?.decimalPlaces
|
||||
});
|
||||
|
||||
this.fill(this.loanProductSecurityLienStep.loanProductSecurityLienForm, {
|
||||
securityType: e.securityType,
|
||||
eligibleAmountBasis: e.eligibleAmountBasis,
|
||||
margin: e.margin,
|
||||
lienMarkingStage: e.lienMarkingStage,
|
||||
eligibleDepositProducts: split(e.eligibleDepositProducts),
|
||||
tenureNotExceedMaturity: e.lienTenureNotExceedMaturity,
|
||||
autoReleaseOnClosure: e.lienAutoReleaseOnClosure,
|
||||
allowMultipleDeposits: e.lienAllowMultipleDeposits,
|
||||
adjustFromMaturity: e.lienAdjustFromMaturity,
|
||||
allowThirdPartyDeposit: e.lienAllowThirdParty,
|
||||
allowRenewalUnderLien: e.lienAllowRenewalUnderLien,
|
||||
lienCertificateRequired: e.lienCertificateRequired,
|
||||
lienCertificatePrefix: e.lienCertificatePrefix,
|
||||
printOnDisbursement: e.printCertificateOnDisbursement,
|
||||
goldMaxLtv: e.goldMaxLtv,
|
||||
goldMinPurityCarat: e.goldMinPurityCarat,
|
||||
goldRateSource: e.goldRateSource,
|
||||
goldMaxPerGram: e.goldMaxPerGram,
|
||||
goldMinNetWeight: e.goldMinNetWeight,
|
||||
goldMaxNetWeightPerCustomer: e.goldMaxNetWeightPerCustomer,
|
||||
goldAllowedItems: e.goldAllowedItems,
|
||||
goldValuationCertificateRequired: e.goldValuationCertificateRequired,
|
||||
goldMaxValuationPerAppraiser: e.goldMaxValuationPerAppraiser,
|
||||
goldPacketPrefix: e.goldPacketPrefix,
|
||||
goldRevalueOnNpa: e.goldRevalueOnNpa,
|
||||
goldRevaluationChargeApplicable: e.goldRevaluationChargeApplicable,
|
||||
insuranceRequired: e.insuranceRequired,
|
||||
goldAuctionNoticeDays: e.goldAuctionNoticeDays,
|
||||
goldPartialReleaseAllowed: e.goldPartialReleaseAllowed,
|
||||
goldThirdPartyAllowed: e.goldThirdPartyAllowed,
|
||||
goldDisbursementMode: e.goldDisbursementMode,
|
||||
goldCashDisbursementLimit: e.goldCashDisbursementLimit,
|
||||
goldLtvBreachAlert: e.goldLtvBreachAlert,
|
||||
goldAuctionReservePct: e.goldAuctionReservePct,
|
||||
wrMaxLtv: e.wrMaxLtv,
|
||||
wrReceiptType: e.wrReceiptType,
|
||||
wrTenureWithinReceiptValidity: e.wrTenureWithinReceiptValidity,
|
||||
wrPledgeMarkedBeforeDisbursement: e.wrPledgeMarkedBeforeDisbursement,
|
||||
wrCommodityGroups: split(e.wrCommodityGroups),
|
||||
wrValuationPriceSource: e.wrValuationPriceSource,
|
||||
wrMaxLoanPerQuintal: e.wrMaxLoanPerQuintal,
|
||||
wrStockInspectionMonths: e.wrStockInspectionMonths,
|
||||
wrLtvBreachAlert: e.wrLtvBreachAlert,
|
||||
wrPartialReleaseAllowed: e.wrPartialReleaseAllowed,
|
||||
wrDisposalNoticeDays: e.wrDisposalNoticeDays,
|
||||
wrDisposalReservePct: e.wrDisposalReservePct
|
||||
});
|
||||
|
||||
this.fill(this.loanProductAdvanceLimitTenureStep.loanProductAdvanceLimitTenureForm, {
|
||||
facilityType: e.facilityType,
|
||||
minLimit: p.minPrincipal,
|
||||
defaultLimit: p.principal,
|
||||
maxLimit: p.maxPrincipal,
|
||||
minTenure: p.minNumberOfRepayments,
|
||||
defaultTenure: p.numberOfRepayments,
|
||||
maxTenure: p.maxNumberOfRepayments,
|
||||
repaymentType: e.repaymentType,
|
||||
appropriationOrder: e.appropriationOrder,
|
||||
debitBalanceAllow: e.debitBalanceAllow,
|
||||
allowDebitTransAfterDueDate: e.allowDebitAfterDueDate,
|
||||
branchLevelAccountType: e.branchLevelAccountType
|
||||
});
|
||||
|
||||
this.fill(this.loanProductAdvanceInterestStep.loanProductAdvanceInterestForm, {
|
||||
interestBasis: e.interestBasis,
|
||||
registerParticular: e.registerParticular,
|
||||
spread: e.spread,
|
||||
calcMethod: e.interestCalcMethod,
|
||||
interestChargedOn: e.interestChargedOn,
|
||||
interestOnCreditBalance: e.interestOnCreditBalance,
|
||||
postingFrequency: e.interestPostingFrequency,
|
||||
postingDay: e.interestPostingDay
|
||||
});
|
||||
|
||||
this.fill(this.loanProductRenewalClosureStep.loanProductRenewalClosureForm, {
|
||||
renewalAllowed: e.renewalAllowed,
|
||||
renewalPeriodMonths: e.renewalPeriodMonths,
|
||||
renewalGraceDays: e.renewalGraceDays,
|
||||
preclosureAllowed: e.preclosureAllowed,
|
||||
lockInMonths: e.lockInMonths,
|
||||
interestOnPreclosure: e.interestOnPreclosure
|
||||
});
|
||||
|
||||
this.fill(this.loanProductPenalNpaStep.loanProductPenalNpaForm, {
|
||||
penalApplicable: e.penalApplicable,
|
||||
penalRate: e.penalRate,
|
||||
penalAppliedOn: e.penalAppliedOn,
|
||||
penalGraceDays: e.penalGraceDays,
|
||||
delinquencyBucketId: p.delinquencyBucket?.id,
|
||||
npaDays: p.overdueDaysForNPA,
|
||||
npaTrigger: e.npaTrigger,
|
||||
interestAfterNpa: e.interestAfterNpa,
|
||||
smaStartAfterMonths: e.smaStartAfterMonths,
|
||||
stockStatementValidityDays: e.stockStatementValidityDays,
|
||||
inArrearsTolerance: p.inArrearsTolerance,
|
||||
upgradeOnlyAllArrears: e.npaUpgradeOnlyAllArrears,
|
||||
keepStandardIfDepositCovers: e.npaKeepStandardIfDepositCovers,
|
||||
npaOutOfOrderBasis: e.npaOutOfOrderBasis,
|
||||
cropDuration: e.cropDuration
|
||||
});
|
||||
|
||||
if (this.isOverdraftFacility && this.loanProductChequeBookStep) {
|
||||
this.fill(this.loanProductChequeBookStep.loanProductChequeBookForm, {
|
||||
chequeType: e.chequeType,
|
||||
bookSizes: split(e.chequeBookSizes),
|
||||
defaultBookSize: e.defaultChequeBookSize !== undefined ? String(e.defaultChequeBookSize) : undefined,
|
||||
maxBooksOutstanding: e.chequeMaxBooksOutstanding,
|
||||
firstBookFree: e.chequeFirstBookFree,
|
||||
freeBooksPerYear: e.chequeFreeBooksPerYear,
|
||||
namePrinted: e.chequeNamePrinted,
|
||||
ppsThreshold: e.ppsThreshold,
|
||||
ppsMandatoryAbove: e.ppsMandatoryAbove,
|
||||
stopPaymentAllowed: e.chequeStopPaymentAllowed,
|
||||
maintainStockRegister: e.chequeMaintainStockRegister,
|
||||
allowWithdrawalSlip: e.chequeAllowWithdrawalSlip,
|
||||
blockNpa: e.chequeBlockNpa,
|
||||
blockLimitExpired: e.chequeBlockLimitExpired,
|
||||
blockOverDp: e.chequeBlockOverDp,
|
||||
blockFrozen: e.chequeBlockFrozen,
|
||||
blockLienReleased: e.chequeBlockLienReleased
|
||||
});
|
||||
}
|
||||
|
||||
// The nine GLs: the core ones are filled by the Accounting step from the saved mappings; the suspense GL is
|
||||
// kept on the extension record.
|
||||
this.fill(this.loanProductAccountingStep?.loanProductAccountingForm, {
|
||||
undebitedInterestAccountId: e.undebitedInterestGlId
|
||||
});
|
||||
|
||||
// Filling the currency above empties the Charges list (a new currency invalidates it); put the saved charges back,
|
||||
// or saving the edit would detach every charge from the product.
|
||||
if (this.loanProductChargesStep) {
|
||||
this.loanProductChargesStep.chargesDataSource = [...(p.charges ?? [])];
|
||||
}
|
||||
}
|
||||
|
||||
get loanProductDetailsForm() {
|
||||
@@ -667,12 +305,6 @@ export class CreateLoanProductComponent implements OnInit, AfterViewInit {
|
||||
return this.loanProductAccountingStep.loanProductAccountingForm;
|
||||
}
|
||||
|
||||
/** True once the Details step has both a product type and a sub type; every other tab stays locked until then. */
|
||||
get hasProductType(): boolean {
|
||||
const details = this.loanProductDetailsForm;
|
||||
return !!details?.get('productTypeGroup')?.value && !!details?.get('productTypeSubGroup')?.value;
|
||||
}
|
||||
|
||||
/** The Details step's chosen product type; drives the Advances-only tabs. */
|
||||
get isAdvanceProduct(): boolean {
|
||||
return this.loanProductDetailsForm?.get('productTypeGroup')?.value === 'advances';
|
||||
@@ -684,14 +316,14 @@ export class CreateLoanProductComponent implements OnInit, AfterViewInit {
|
||||
}
|
||||
|
||||
get totalTabs(): number {
|
||||
let tabs = 5; // details, currency, penal & npa, charges, accounting (always)
|
||||
let tabs = 4; // details, currency, charges, accounting (always)
|
||||
if (this.isAdvanceProduct) {
|
||||
// security & lien, limit & tenure, interest, renewal & closure
|
||||
tabs += 4;
|
||||
// security & lien, limit & tenure, interest, penal & npa, renewal & closure
|
||||
tabs += 5;
|
||||
if (this.isOverdraftFacility) tabs += 1; // cheque book
|
||||
} else if (this.isLoanAccount) {
|
||||
// security & collateral, amount & tenure, repayment & emi, interest, prepayment & closure
|
||||
tabs += 5;
|
||||
// security & collateral, amount & tenure, repayment & emi, interest, penal & npa, prepayment & closure
|
||||
tabs += 6;
|
||||
}
|
||||
// Old Loans A/C path (Settings, Terms, Interest Refund, Payment Allocation, Deferred Income) — commented out:
|
||||
// else {
|
||||
@@ -702,114 +334,9 @@ export class CreateLoanProductComponent implements OnInit, AfterViewInit {
|
||||
return tabs;
|
||||
}
|
||||
|
||||
/**
|
||||
* The tabs that still block the Review tab, each with the fields to fix. The Review tab only appears once every
|
||||
* tab is valid, and a tab that was never opened or a missing field is otherwise invisible to the user.
|
||||
*/
|
||||
get incompleteTabs(): { tab: string; fields: string[] }[] {
|
||||
const humanize = (name: string) =>
|
||||
name
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||
.replace(/Id$/, '')
|
||||
.replace(/^./, (ch) => ch.toUpperCase());
|
||||
const tabs: [string, any][] = [
|
||||
[
|
||||
'Details',
|
||||
this.loanProductDetailsForm
|
||||
],
|
||||
[
|
||||
'Currency',
|
||||
this.loanProductCurrencyForm
|
||||
]
|
||||
];
|
||||
if (this.isAdvanceProduct) {
|
||||
tabs.push(
|
||||
[
|
||||
'Security & Lien',
|
||||
this.loanProductSecurityLienStep?.loanProductSecurityLienForm
|
||||
],
|
||||
[
|
||||
'Limit & Tenure',
|
||||
this.loanProductAdvanceLimitTenureStep?.loanProductAdvanceLimitTenureForm
|
||||
],
|
||||
[
|
||||
'Interest',
|
||||
this.loanProductAdvanceInterestStep?.loanProductAdvanceInterestForm
|
||||
],
|
||||
[
|
||||
'Renewal & Closure',
|
||||
this.loanProductRenewalClosureStep?.loanProductRenewalClosureForm
|
||||
]
|
||||
);
|
||||
if (this.isOverdraftFacility) {
|
||||
tabs.push([
|
||||
'Cheque Book',
|
||||
this.loanProductChequeBookStep?.loanProductChequeBookForm
|
||||
]);
|
||||
}
|
||||
} else if (this.isLoanAccount) {
|
||||
tabs.push(
|
||||
[
|
||||
'Security & Collateral',
|
||||
this.loanProductLoanSecurityStep?.loanProductLoanSecurityForm
|
||||
],
|
||||
[
|
||||
'Amount & Tenure',
|
||||
this.loanProductLoanAmountTenureStep?.loanProductLoanAmountTenureForm
|
||||
],
|
||||
[
|
||||
'Repayment & EMI',
|
||||
this.loanProductLoanRepaymentStep?.loanProductLoanRepaymentForm
|
||||
],
|
||||
[
|
||||
'Interest',
|
||||
this.loanProductLoanInterestStep?.loanProductLoanInterestForm
|
||||
],
|
||||
[
|
||||
'Prepayment & Closure',
|
||||
this.loanProductLoanPrepaymentStep?.loanProductLoanPrepaymentForm
|
||||
]
|
||||
);
|
||||
}
|
||||
tabs.push(
|
||||
[
|
||||
'Penal & NPA',
|
||||
this.loanProductPenalNpaStep?.loanProductPenalNpaForm
|
||||
],
|
||||
[
|
||||
'Accounting',
|
||||
this.loanProductAccountingStep?.loanProductAccountingForm
|
||||
]
|
||||
);
|
||||
return tabs
|
||||
.filter(
|
||||
([
|
||||
,
|
||||
form
|
||||
]) => !form || form.invalid
|
||||
)
|
||||
.map(
|
||||
([
|
||||
tab,
|
||||
form
|
||||
]) => ({
|
||||
tab,
|
||||
fields: form
|
||||
? Object.keys(form.controls)
|
||||
.filter((key) => form.controls[key].invalid)
|
||||
.slice(0, 4)
|
||||
.map(humanize)
|
||||
: []
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
get loanProductFormValid() {
|
||||
const base =
|
||||
this.loanProductDetailsForm.valid &&
|
||||
this.loanProductCurrencyForm.valid &&
|
||||
this.loanProductAccountingForm.valid &&
|
||||
!!this.loanProductPenalNpaStep?.loanProductPenalNpaForm?.valid;
|
||||
this.loanProductDetailsForm.valid && this.loanProductCurrencyForm.valid && this.loanProductAccountingForm.valid;
|
||||
|
||||
// Advances A/C: the curated Figma steps gate the form; Settings/Terms tabs are hidden.
|
||||
if (this.isAdvanceProduct) {
|
||||
@@ -818,6 +345,7 @@ export class CreateLoanProductComponent implements OnInit, AfterViewInit {
|
||||
!!this.loanProductSecurityLienStep?.loanProductSecurityLienForm?.valid &&
|
||||
!!this.loanProductAdvanceLimitTenureStep?.loanProductAdvanceLimitTenureForm?.valid &&
|
||||
!!this.loanProductAdvanceInterestStep?.loanProductAdvanceInterestForm?.valid &&
|
||||
!!this.loanProductPenalNpaStep?.loanProductPenalNpaForm?.valid &&
|
||||
!!this.loanProductRenewalClosureStep?.loanProductRenewalClosureForm?.valid &&
|
||||
(!this.isOverdraftFacility || !!this.loanProductChequeBookStep?.loanProductChequeBookForm?.valid)
|
||||
);
|
||||
@@ -831,6 +359,7 @@ export class CreateLoanProductComponent implements OnInit, AfterViewInit {
|
||||
!!this.loanProductLoanAmountTenureStep?.loanProductLoanAmountTenureForm?.valid &&
|
||||
!!this.loanProductLoanRepaymentStep?.loanProductLoanRepaymentForm?.valid &&
|
||||
!!this.loanProductLoanInterestStep?.loanProductLoanInterestForm?.valid &&
|
||||
!!this.loanProductPenalNpaStep?.loanProductPenalNpaForm?.valid &&
|
||||
!!this.loanProductLoanPrepaymentStep?.loanProductLoanPrepaymentForm?.valid
|
||||
);
|
||||
}
|
||||
@@ -846,33 +375,6 @@ export class CreateLoanProductComponent implements OnInit, AfterViewInit {
|
||||
// return base && loansValid;
|
||||
}
|
||||
|
||||
private previewKey = '';
|
||||
private previewProduct: any = {};
|
||||
|
||||
/**
|
||||
* The product the Review tab shows. It is rebuilt only when a form value changes, so the summary gets a stable object
|
||||
* (it fills in currency, schedule type and so on on the object it receives). Submit still uses the fresh `loanProduct`.
|
||||
*/
|
||||
get previewLoanProduct(): any {
|
||||
const key = JSON.stringify(
|
||||
Object.keys(this)
|
||||
.filter((name) => /Step$/.test(name))
|
||||
.map((name) => {
|
||||
const step = (this as any)[name];
|
||||
return step
|
||||
? Object.keys(step)
|
||||
.filter((prop) => /Form$/.test(prop))
|
||||
.map((prop) => step[prop]?.getRawValue?.())
|
||||
: null;
|
||||
})
|
||||
);
|
||||
if (key !== this.previewKey) {
|
||||
this.previewKey = key;
|
||||
this.previewProduct = this.loanProduct;
|
||||
}
|
||||
return this.previewProduct;
|
||||
}
|
||||
|
||||
get loanProduct() {
|
||||
// Advances A/C builds its own payload: the curated steps map onto Fineract's mandatory core fields
|
||||
// (principal, tenure, rate, amortization defaults) plus the whitelisted satellite params.
|
||||
@@ -1033,14 +535,9 @@ export class CreateLoanProductComponent implements OnInit, AfterViewInit {
|
||||
penalAppliedOn: pn.penalAppliedOn,
|
||||
penalGraceDays: num(pn.penalGraceDays),
|
||||
npaTrigger: pn.npaTrigger,
|
||||
smaStartAfterMonths: num(pn.smaStartAfterMonths),
|
||||
interestAfterNpa: pn.interestAfterNpa,
|
||||
npaUpgradeOnlyAllArrears: pn.upgradeOnlyAllArrears,
|
||||
|
||||
// The SICR & LGD (ECL) card is hidden for now, so its values are not sent. "Out of order" (CC / OD only) and the
|
||||
// deposit-cover rule do not apply to a term loan, so neither is sent.
|
||||
cropDuration: pn.cropDuration || undefined,
|
||||
|
||||
npaKeepStandardIfDepositCovers: pn.keepStandardIfDepositCovers,
|
||||
securityMandatory: sec.securityMandatory,
|
||||
guarantorsRequired: num(sec.guarantorsRequired),
|
||||
coApplicant: sec.coApplicant,
|
||||
@@ -1057,9 +554,6 @@ export class CreateLoanProductComponent implements OnInit, AfterViewInit {
|
||||
minMonthlyIncome: num(at.minMonthlyIncome),
|
||||
maxFoir: num(at.maxFoir),
|
||||
maxLoansPerCustomer: num(at.maxLoansPerCustomer),
|
||||
memberRequired: at.memberRequired,
|
||||
guarantorMustBeMember: at.guarantorMustBeMember,
|
||||
incomeMultiple: num(at.incomeMultiple),
|
||||
disbursementType: at.disbursementType,
|
||||
disbursementMode: at.disbursementMode,
|
||||
undisbursedInterest: at.undisbursedInterest,
|
||||
@@ -1097,6 +591,7 @@ export class CreateLoanProductComponent implements OnInit, AfterViewInit {
|
||||
autoCloseOnZeroBalance: pp.autoCloseOnZeroBalance,
|
||||
nocLetter: pp.nocLetter,
|
||||
restructuring: pp.restructuring,
|
||||
releaseCollateralOnClosure: pp.releaseCollateralOnClosure,
|
||||
topUpAllowed: pp.topUpAllowed,
|
||||
balanceTransferOutAllowed: pp.balanceTransferOutAllowed
|
||||
};
|
||||
@@ -1118,9 +613,6 @@ export class CreateLoanProductComponent implements OnInit, AfterViewInit {
|
||||
const d = this.loanProductDetailsStep.loanProductDetails;
|
||||
const cur = this.loanProductCurrencyStep.loanProductCurrency;
|
||||
const sec = this.loanProductSecurityLienStep.loanProductSecurityLien;
|
||||
const lien = this.loanProductSecurityLienStep.isLienSubType;
|
||||
const gold = this.loanProductSecurityLienStep.isGold ? sec.gold : null;
|
||||
const wr = this.loanProductSecurityLienStep.isWarehouse ? sec.warehouse : null;
|
||||
const lt = this.loanProductAdvanceLimitTenureStep.loanProductAdvanceLimitTenure;
|
||||
const intr = this.loanProductAdvanceInterestStep.loanProductAdvanceInterest;
|
||||
const pn = this.loanProductPenalNpaStep.loanProductPenalNpa;
|
||||
@@ -1196,62 +688,10 @@ export class CreateLoanProductComponent implements OnInit, AfterViewInit {
|
||||
ltv: sec.ltv,
|
||||
dpMethod: 'security_value_x_ltv',
|
||||
lienRequired: this.loanProductSecurityLienStep.isLienSubType,
|
||||
// Lien settings exist only for a deposit / policy sub type; a pledged one (Gold ...) sends none of them.
|
||||
...(lien
|
||||
? {
|
||||
lienMarkingStage: sec.lienMarkingStage,
|
||||
lienCertificateRequired: sec.lienCertificateRequired,
|
||||
lienCertificatePrefix: sec.lienCertificatePrefix,
|
||||
printCertificateOnDisbursement: sec.printOnDisbursement
|
||||
}
|
||||
: {}),
|
||||
...(gold
|
||||
? {
|
||||
goldMaxLtv: num(gold.maxLtv),
|
||||
goldMinPurityCarat: num(gold.minPurityCarat),
|
||||
goldRateSource: gold.rateSource,
|
||||
goldMaxPerGram: num(gold.maxPerGram),
|
||||
goldMinNetWeight: num(gold.minNetWeight),
|
||||
goldMaxNetWeightPerCustomer: num(gold.maxNetWeightPerCustomer),
|
||||
goldAllowedItems: gold.allowedItems,
|
||||
goldAppraiserRequired: true,
|
||||
goldValuationCertificateRequired: gold.valuationCertificateRequired === 'YES',
|
||||
goldMaxValuationPerAppraiser: num(gold.maxValuationPerAppraiser),
|
||||
goldPacketPrefix: gold.packetPrefix || undefined,
|
||||
goldRevalueOnNpa: gold.revalueOnNpa,
|
||||
goldRevaluationChargeApplicable: gold.revaluationChargeApplicable,
|
||||
insuranceRequired: gold.insuranceRequired === 'YES',
|
||||
goldAuctionNoticeDays: num(gold.auctionNoticeDays),
|
||||
goldPartialReleaseAllowed: gold.partialReleaseAllowed,
|
||||
goldThirdPartyAllowed: gold.thirdPartyAllowed,
|
||||
goldDisbursementMode: gold.disbursementMode,
|
||||
goldCashDisbursementLimit:
|
||||
gold.disbursementMode === 'account_credit_cash_limit' ? num(gold.cashDisbursementLimit) : undefined,
|
||||
goldLtvBreachAlert: num(gold.ltvBreachAlert),
|
||||
goldAuctionReservePct: num(gold.auctionReservePct),
|
||||
goldRedeemBeforeAuction: true
|
||||
}
|
||||
: {}),
|
||||
...(wr
|
||||
? {
|
||||
wrMaxLtv: num(wr.maxLtv),
|
||||
wrReceiptType: wr.receiptType,
|
||||
wrWdraRegistered: true,
|
||||
wrAssayCertificateRequired: true,
|
||||
wrTenureWithinReceiptValidity: wr.tenureWithinReceiptValidity,
|
||||
wrPledgeMarkedBeforeDisbursement: wr.pledgeMarkedBeforeDisbursement,
|
||||
wrCommodityGroups: (wr.commodityGroups ?? []).join('|'),
|
||||
wrValuationPriceSource: wr.valuationPriceSource,
|
||||
wrMaxLoanPerQuintal: num(wr.maxLoanPerQuintal),
|
||||
wrStockInspectionMonths: num(wr.stockInspectionMonths),
|
||||
insuranceRequired: wr.insuranceRequired === 'YES',
|
||||
wrLtvBreachAlert: num(wr.ltvBreachAlert),
|
||||
wrPartialReleaseAllowed: wr.partialReleaseAllowed,
|
||||
wrDisposalNoticeDays: num(wr.disposalNoticeDays),
|
||||
wrDisposalReservePct: num(wr.disposalReservePct),
|
||||
wrRedeemBeforeDisposal: true
|
||||
}
|
||||
: {}),
|
||||
lienMarkingStage: sec.lienMarkingStage,
|
||||
lienCertificateRequired: sec.lienCertificateRequired,
|
||||
lienCertificatePrefix: sec.lienCertificatePrefix,
|
||||
printCertificateOnDisbursement: sec.printOnDisbursement,
|
||||
interestBasis: intr.interestBasis,
|
||||
registerParticular: intr.registerParticular || undefined,
|
||||
spread: num(intr.spread),
|
||||
@@ -1262,14 +702,7 @@ export class CreateLoanProductComponent implements OnInit, AfterViewInit {
|
||||
penalAppliedOn: pn.penalAppliedOn,
|
||||
penalGraceDays: num(pn.penalGraceDays),
|
||||
npaTrigger: pn.npaTrigger,
|
||||
smaStartAfterMonths: num(pn.smaStartAfterMonths),
|
||||
stockStatementValidityDays: num(pn.stockStatementValidityDays),
|
||||
interestAfterNpa: pn.interestAfterNpa,
|
||||
|
||||
// The SICR & LGD (ECL) card is hidden for now, so its values are not sent.
|
||||
npaOutOfOrderBasis: pn.npaOutOfOrderBasis || undefined,
|
||||
cropDuration: pn.cropDuration || undefined,
|
||||
|
||||
renewalAllowed: rc.renewalAllowed,
|
||||
renewalPeriodMonths: num(rc.renewalPeriodMonths),
|
||||
renewalGraceDays: num(rc.renewalGraceDays),
|
||||
@@ -1283,18 +716,14 @@ export class CreateLoanProductComponent implements OnInit, AfterViewInit {
|
||||
ppsMandatoryAbove: num(cb?.ppsMandatoryAbove),
|
||||
|
||||
// ---- remaining wizard fields ----
|
||||
// security & lien extras (deposit list joined; rule checkboxes), only for a deposit / policy sub type
|
||||
...(lien
|
||||
? {
|
||||
eligibleDepositProducts: (sec.eligibleDepositProducts ?? []).join('|'),
|
||||
lienTenureNotExceedMaturity: sec.lienRules?.tenureNotExceedMaturity,
|
||||
lienAutoReleaseOnClosure: sec.lienRules?.autoReleaseOnClosure,
|
||||
lienAllowMultipleDeposits: sec.lienRules?.allowMultipleDeposits,
|
||||
lienAdjustFromMaturity: sec.lienRules?.adjustFromMaturity,
|
||||
lienAllowThirdParty: sec.lienRules?.allowThirdPartyDeposit,
|
||||
lienAllowRenewalUnderLien: sec.lienRules?.allowRenewalUnderLien
|
||||
}
|
||||
: {}),
|
||||
// security & lien extras (deposit list joined; rule checkboxes)
|
||||
eligibleDepositProducts: (sec.eligibleDepositProducts ?? []).join('|'),
|
||||
lienTenureNotExceedMaturity: sec.lienRules?.tenureNotExceedMaturity,
|
||||
lienAutoReleaseOnClosure: sec.lienRules?.autoReleaseOnClosure,
|
||||
lienAllowMultipleDeposits: sec.lienRules?.allowMultipleDeposits,
|
||||
lienAdjustFromMaturity: sec.lienRules?.adjustFromMaturity,
|
||||
lienAllowThirdParty: sec.lienRules?.allowThirdPartyDeposit,
|
||||
lienAllowRenewalUnderLien: sec.lienRules?.allowRenewalUnderLien,
|
||||
// limit & tenure extras
|
||||
repaymentType: lt.repaymentType,
|
||||
appropriationOrder: lt.appropriationOrder,
|
||||
@@ -1307,13 +736,15 @@ export class CreateLoanProductComponent implements OnInit, AfterViewInit {
|
||||
interestPostingDay: intr.postingDay,
|
||||
// NPA upgrade rules
|
||||
npaUpgradeOnlyAllArrears: pn.upgradeOnlyAllArrears,
|
||||
...(lien ? { npaKeepStandardIfDepositCovers: pn.keepStandardIfDepositCovers } : {}),
|
||||
npaKeepStandardIfDepositCovers: pn.keepStandardIfDepositCovers,
|
||||
// cheque book full config
|
||||
chequeBookSizes: (cb?.bookSizes ?? []).join('|'),
|
||||
chequeMaxBooksOutstanding: num(cb?.maxBooksOutstanding),
|
||||
chequeReissueWhenLeaves: num(cb?.reissueWhenLeaves),
|
||||
chequeFirstBookFree: cb?.firstBookFree,
|
||||
chequeFreeBooksPerYear: num(cb?.freeBooksPerYear),
|
||||
chequeNamePrinted: cb?.namePrinted,
|
||||
chequePrintDescription: cb?.chequePrintDescription,
|
||||
chequeStopPaymentAllowed: cb?.stopPaymentAllowed,
|
||||
chequeMaintainStockRegister: cb?.maintainStockRegister,
|
||||
chequeAllowWithdrawalSlip: cb?.allowWithdrawalSlip,
|
||||
@@ -1340,19 +771,6 @@ export class CreateLoanProductComponent implements OnInit, AfterViewInit {
|
||||
delete loanProduct['daysInYearCustomStrategy'];
|
||||
}
|
||||
|
||||
if (this.isEdit) {
|
||||
this.productsService.updateLoanProduct(this.loanProductsTemplate.id, loanProduct).subscribe((response: any) => {
|
||||
if (response?.rollbackTransaction) {
|
||||
this.alertService.alert({
|
||||
type: 'Success',
|
||||
message: 'Loan product update request submitted successfully for approval.'
|
||||
});
|
||||
}
|
||||
this.router.navigate(['../'], { relativeTo: this.route });
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.productsService.createLoanProduct(loanProduct).subscribe({
|
||||
next: (response: any) => {
|
||||
if (response?.rollbackTransaction) {
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A saved Advances / Loans product keeps most of its settings in an extension record (`advanceExtensions`), under the
|
||||
* same names the edit form submits. Putting those next to the core fields gives every submitted field a current value
|
||||
* to compare against, so a pending edit shows only what really changed. Core fields win on a name clash.
|
||||
*
|
||||
* The product read leaves out a core number that is zero, while the edit form always sends it, so that one is filled in
|
||||
* as 0 rather than reading as "newly set" on every edit.
|
||||
*/
|
||||
export function withExtensions(product: any): any {
|
||||
if (!product) {
|
||||
return product;
|
||||
}
|
||||
const merged = product.advanceExtensions ? { ...product.advanceExtensions, ...product } : { ...product };
|
||||
if (merged.inArrearsTolerance === undefined || merged.inArrearsTolerance === null) {
|
||||
merged.inArrearsTolerance = 0;
|
||||
}
|
||||
// Same for the arrears grace: a Loans A/C (EMI) product counts arrears from day one, and the read leaves a zero out.
|
||||
if (merged.graceOnArrearsAgeing === undefined || merged.graceOnArrearsAgeing === null) {
|
||||
merged.graceOnArrearsAgeing = 0;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/** Core accounting mapping name on the product read -> the payload name the Review reader uses. */
|
||||
const GL_MAPPING_KEYS: Record<string, string> = {
|
||||
loanPortfolioAccount: 'loanPortfolioAccountId',
|
||||
fundSourceAccount: 'fundSourceAccountId',
|
||||
interestOnLoanAccount: 'interestOnLoanAccountId',
|
||||
incomeFromFeeAccount: 'incomeFromFeeAccountId',
|
||||
incomeFromPenaltyAccount: 'incomeFromPenaltyAccountId',
|
||||
writeOffAccount: 'writeOffAccountId',
|
||||
receivableInterestAccount: 'receivableInterestAccountId',
|
||||
overpaymentLiabilityAccount: 'overpaymentLiabilityAccountId'
|
||||
};
|
||||
|
||||
/** A date the API sends as [2026, 10, 15] shown as 15-10-2026. */
|
||||
function dateText(value: any): string | undefined {
|
||||
if (!Array.isArray(value) || value.length < 3) {
|
||||
return value ?? undefined;
|
||||
}
|
||||
const [
|
||||
year,
|
||||
month,
|
||||
day
|
||||
] = value;
|
||||
return `${String(day).padStart(2, '0')}-${String(month).padStart(2, '0')}-${year}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A saved Advances product as the read-only Review page needs it: the flat field names the create wizard submits
|
||||
* (core fields plus the extension record), the GL mappings as ids with a matching option list so each shows by name,
|
||||
* and the attached charges as one readable line. Pure; the product object is not changed.
|
||||
*/
|
||||
export function reviewProductOf(product: any): { product: any; template: any } {
|
||||
const flat: any = { ...withExtensions(product) };
|
||||
const accounts: any[] = [];
|
||||
Object.keys(GL_MAPPING_KEYS).forEach((name) => {
|
||||
const account = product?.accountingMappings?.[name];
|
||||
if (account?.id !== undefined) {
|
||||
flat[GL_MAPPING_KEYS[name]] = account.id;
|
||||
accounts.push({ id: account.id, glCode: account.glCode, name: account.name });
|
||||
}
|
||||
});
|
||||
flat.currencyCode = product?.currency?.code;
|
||||
flat.digitsAfterDecimal = product?.currency?.decimalPlaces;
|
||||
flat.startDate = dateText(product?.startDate);
|
||||
flat.closeDate = dateText(product?.closeDate);
|
||||
flat.delinquencyBucketId = undefined;
|
||||
flat.chargesSummary = (product?.charges ?? []).map((charge: any) => `${charge.name} (${charge.amount})`).join(', ');
|
||||
return { product: flat, template: { accountingMappingOptions: { assetAccountOptions: accounts } } };
|
||||
}
|
||||
@@ -214,242 +214,3 @@ $gray-900: #202124;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Penal / SMA / NPA / ECL cards: each gets its own accent so they read as separate blocks. */
|
||||
.pn-card {
|
||||
border-left-width: 4px;
|
||||
|
||||
&--penal {
|
||||
border-left-color: #0ea5e9;
|
||||
}
|
||||
|
||||
&--sma {
|
||||
border-left-color: #f59e0b;
|
||||
}
|
||||
|
||||
&--npa {
|
||||
border-left-color: #dc2626;
|
||||
}
|
||||
|
||||
&--ecl {
|
||||
border-left-color: #6366f1;
|
||||
}
|
||||
}
|
||||
|
||||
.pn-stages {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 0 16px 16px;
|
||||
}
|
||||
|
||||
.pn-stage {
|
||||
padding: 4px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
|
||||
&--0 {
|
||||
background: #fef9c3;
|
||||
color: #854d0e;
|
||||
}
|
||||
|
||||
&--1 {
|
||||
background: #ffedd5;
|
||||
color: #9a3412;
|
||||
}
|
||||
|
||||
&--2 {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
}
|
||||
|
||||
/* ECL card: collapsed by default, grouped, plain wording. */
|
||||
.pn-ecl-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
border: 0;
|
||||
background: #f8fafc;
|
||||
color: #1e293b;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
&__title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
white-space: nowrap;
|
||||
|
||||
i {
|
||||
color: #6366f1;
|
||||
}
|
||||
}
|
||||
|
||||
&__tag {
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: #e0e7ff;
|
||||
color: #3730a3;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__summary {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.pn-ecl-body {
|
||||
padding: 4px 16px 16px;
|
||||
}
|
||||
|
||||
.pn-ecl-intro {
|
||||
margin: 8px 0 16px;
|
||||
color: #475569;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.pn-ecl-group {
|
||||
margin-bottom: 20px;
|
||||
|
||||
h4 {
|
||||
margin: 0 0 12px;
|
||||
color: #1e293b;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.pn-ecl-row {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.pn-ecl-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
/* Eligibility & Documents tab. */
|
||||
.ed-body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.ed-count {
|
||||
margin-left: auto;
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
background: #e0f2fe;
|
||||
color: #075985;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ed-rules {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
|
||||
@media (width <= 900px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.ed-rule {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
|
||||
&__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
|
||||
strong {
|
||||
color: #1e293b;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
small {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ed-group {
|
||||
margin-bottom: 18px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
&__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
|
||||
i {
|
||||
color: #6366f1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ed-docs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px 12px;
|
||||
|
||||
@media (width <= 900px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.ed-doc {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 12px 4px 4px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
color: #334155;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
&--on {
|
||||
border-color: #a5b4fc;
|
||||
background: #eef2ff;
|
||||
color: #1e1b4b;
|
||||
}
|
||||
}
|
||||
|
||||
+336
-2
@@ -38,12 +38,18 @@
|
||||
<div class="lpa-group">
|
||||
<div class="lpa-group__header">
|
||||
<h4>{{ section.title }}</h4>
|
||||
<button
|
||||
type="button"
|
||||
class="lpa-add-other-btn"
|
||||
(click)="addOtherGlMapping(section.otherMappingsControlName)"
|
||||
>
|
||||
+ Add Other
|
||||
</button>
|
||||
</div>
|
||||
<div class="lpa-list">
|
||||
@for (row of section.rows; track row.key) {
|
||||
@if (
|
||||
basicControls.has(row.controlName) &&
|
||||
(!row.accrualOnly || isAccrualAccounting() || basicControls.has(row.controlName)) &&
|
||||
(!row.accrualOnly || isAccrualAccounting()) &&
|
||||
(!row.deferredOnly || isDeferredOnly()) &&
|
||||
(!row.deferredCapitalizationOnly || isDeferredCapitalizationOnly()) &&
|
||||
(!row.deferredBuyDownOnly || isDeferredBuyDownOnly()) &&
|
||||
@@ -72,6 +78,334 @@
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Other GL Mappings -->
|
||||
<div class="lpa-list" [formArrayName]="section.otherMappingsControlName">
|
||||
@for (
|
||||
otherRow of getOtherGlMappingsArray(section.otherMappingsControlName).controls;
|
||||
track $index;
|
||||
let i = $index
|
||||
) {
|
||||
<div class="lpa-other-row-wrap">
|
||||
<div class="lpa-row lpa-row--other" [formGroupName]="i">
|
||||
<div class="lpa-row__meta lpa-row__meta--other">
|
||||
<span
|
||||
class="lpa-pill"
|
||||
[class.lpa-pill--green]="section.pillClass === 'green'"
|
||||
[class.lpa-pill--amber]="section.pillClass === 'amber'"
|
||||
[class.lpa-pill--blue]="section.pillClass === 'blue'"
|
||||
>
|
||||
{{ section.pillLabel }}
|
||||
</span>
|
||||
<input
|
||||
class="lpa-row__name lpa-row__name--other-account"
|
||||
formControlName="accountName"
|
||||
placeholder="Enter account name..."
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="lpa-row__actions lpa-row__actions--other">
|
||||
<mat-form-field class="lpa-row__other-field" appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>GL Code</mat-label>
|
||||
<mat-select
|
||||
[formControl]="getOtherGlMappingControl(section.otherMappingsControlName, i, 'glAccountId')"
|
||||
>
|
||||
@for (glAccount of section.otherAccountOptions; track glAccount.id) {
|
||||
<mat-option [value]="glAccount.id">
|
||||
{{ '(' + glAccount.glCode + ') ' + glAccount.name }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="lpa-remove-btn lpa-remove-btn--side"
|
||||
(click)="removeOtherGlMapping(section.otherMappingsControlName, i)"
|
||||
>
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Advanced Accounting Rules Section -->
|
||||
@if (loanProductAccountingForm.value.accountingRule >= 2 && loanProductAccountingForm.value.accountingRule <= 4) {
|
||||
<div class="lpa-group">
|
||||
<div class="lpa-group__header">
|
||||
<h4>Advanced Accounting Rules</h4>
|
||||
</div>
|
||||
<div class="lpa-checkbox-row">
|
||||
<mat-checkbox formControlName="advancedAccountingRules">{{
|
||||
'labels.heading.Advanced Accounting Rules' | translate
|
||||
}}</mat-checkbox>
|
||||
</div>
|
||||
|
||||
@if (loanProductAccountingForm.value.advancedAccountingRules) {
|
||||
<!-- Payment Fund Sources -->
|
||||
<div class="lpa-subsection">
|
||||
<div class="lpa-subsection-header">
|
||||
<h4>{{ 'labels.heading.Configure Fund Sources for Payment Channels' | translate }}</h4>
|
||||
<button
|
||||
type="button"
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
class="lpa-add-btn"
|
||||
(click)="add('PaymentFundSource', paymentChannelToFundSourceMappings)"
|
||||
>
|
||||
<fa-icon icon="plus" class="m-r-10"></fa-icon>
|
||||
{{ 'labels.buttons.Add' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
@if (paymentChannelToFundSourceMappings.value.length !== 0) {
|
||||
<div class="lpa-table-wrapper">
|
||||
<table mat-table [dataSource]="paymentChannelToFundSourceMappings.value">
|
||||
<ng-container matColumnDef="paymentTypeId">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Payment Type' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let paymentFundSource">
|
||||
{{ paymentFundSource.paymentTypeId | find: paymentTypeData : 'id' : 'name' }}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="fundSourceAccountId">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Fund Source' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let paymentFundSource">
|
||||
{{ paymentFundSource.fundSourceAccountId | find: assetAccountData : 'id' : 'name' }}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Actions' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let paymentFundSource; let i = index">
|
||||
<button
|
||||
mat-icon-button
|
||||
color="primary"
|
||||
(click)="edit('PaymentFundSource', paymentChannelToFundSourceMappings, i)"
|
||||
>
|
||||
<fa-icon icon="edit"></fa-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
color="warn"
|
||||
(click)="delete('PaymentFundSource', paymentChannelToFundSourceMappings, i)"
|
||||
>
|
||||
<fa-icon icon="trash"></fa-icon>
|
||||
</button>
|
||||
</td>
|
||||
</ng-container>
|
||||
<tr mat-header-row *matHeaderRowDef="paymentFundSourceDisplayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: paymentFundSourceDisplayedColumns"></tr>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Fees to Income -->
|
||||
<div class="lpa-subsection">
|
||||
<div class="lpa-subsection-header">
|
||||
<h4>{{ 'labels.heading.Map Fees to Specific Income Accounts' | translate }}</h4>
|
||||
<button
|
||||
type="button"
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
class="lpa-add-btn"
|
||||
(click)="add('FeesIncome', feeToIncomeAccountMappings)"
|
||||
>
|
||||
<fa-icon icon="plus" class="m-r-10"></fa-icon>
|
||||
{{ 'labels.buttons.Add' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
@if (feeToIncomeAccountMappings.value.length !== 0) {
|
||||
<div class="lpa-table-wrapper">
|
||||
<table mat-table [dataSource]="feeToIncomeAccountMappings.value">
|
||||
<ng-container matColumnDef="chargeId">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Fees' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let feesIncome">
|
||||
{{ feesIncome.chargeId | find: chargeData : 'id' : 'name' }}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="incomeAccountId">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Income Account' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let feesIncome">
|
||||
{{ feesIncome.incomeAccountId | find: incomeAndLiabilityAccountData : 'id' : 'name' }}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Actions' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let feesIncome; let i = index">
|
||||
<button
|
||||
mat-icon-button
|
||||
color="primary"
|
||||
(click)="edit('FeesIncome', feeToIncomeAccountMappings, i)"
|
||||
>
|
||||
<fa-icon icon="edit"></fa-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
color="warn"
|
||||
(click)="delete('FeesIncome', feeToIncomeAccountMappings, i)"
|
||||
>
|
||||
<fa-icon icon="trash"></fa-icon>
|
||||
</button>
|
||||
</td>
|
||||
</ng-container>
|
||||
<tr mat-header-row *matHeaderRowDef="feesPenaltyIncomeDisplayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: feesPenaltyIncomeDisplayedColumns"></tr>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Penalties to Income -->
|
||||
<div class="lpa-subsection">
|
||||
<div class="lpa-subsection-header">
|
||||
<h4>{{ 'labels.heading.Map Penalties to Specific Income Accounts' | translate }}</h4>
|
||||
<button
|
||||
type="button"
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
class="lpa-add-btn"
|
||||
(click)="add('PenaltyIncome', penaltyToIncomeAccountMappings)"
|
||||
>
|
||||
<fa-icon icon="plus" class="m-r-10"></fa-icon>
|
||||
{{ 'labels.buttons.Add' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
@if (penaltyToIncomeAccountMappings.value.length !== 0) {
|
||||
<div class="lpa-table-wrapper">
|
||||
<table mat-table [dataSource]="penaltyToIncomeAccountMappings.value">
|
||||
<ng-container matColumnDef="chargeId">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Penalty' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let penaltyIncome">
|
||||
{{ penaltyIncome.chargeId | find: penaltyData : 'id' : 'name' }}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="incomeAccountId">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Income Account' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let penaltyIncome">
|
||||
{{ penaltyIncome.incomeAccountId | find: incomeAccountData : 'id' : 'name' }}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Actions' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let penaltyIncome; let i = index">
|
||||
<button
|
||||
mat-icon-button
|
||||
color="primary"
|
||||
(click)="edit('PenaltyIncome', penaltyToIncomeAccountMappings, i)"
|
||||
>
|
||||
<fa-icon icon="edit"></fa-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
color="warn"
|
||||
(click)="delete('PenaltyIncome', penaltyToIncomeAccountMappings, i)"
|
||||
>
|
||||
<fa-icon icon="trash"></fa-icon>
|
||||
</button>
|
||||
</td>
|
||||
</ng-container>
|
||||
<tr mat-header-row *matHeaderRowDef="feesPenaltyIncomeDisplayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: feesPenaltyIncomeDisplayedColumns"></tr>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Charge-off Reasons to Expense -->
|
||||
<div class="lpa-subsection">
|
||||
<div class="lpa-subsection-header">
|
||||
<h4>{{ 'labels.heading.Map Charge-off reasons to Expense accounts' | translate }}</h4>
|
||||
<button
|
||||
type="button"
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
class="lpa-add-btn"
|
||||
[disabled]="!allowAddChargeOffReasonExpense"
|
||||
(click)="add('ChargeOffReasonExpense', chargeOffReasonToExpenseAccountMappings)"
|
||||
>
|
||||
<fa-icon icon="plus" class="m-r-10"></fa-icon>
|
||||
{{ 'labels.buttons.Add' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
@if (chargeOffReasonToExpenseAccountMappings.value.length !== 0) {
|
||||
<div class="lpa-table-wrapper">
|
||||
<table mat-table [dataSource]="chargeOffReasonToExpenseAccountMappings.value">
|
||||
<ng-container matColumnDef="chargeOffReasonCodeValueId">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Charge-off reason' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let chargeOffReasonExpense">
|
||||
{{
|
||||
chargeOffReasonExpense.chargeOffReasonCodeValueId
|
||||
| find: chargeOffReasonOptions : 'id' : 'name'
|
||||
}}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="expenseAccountId">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Expense Account' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let chargeOffReasonExpense">
|
||||
{{ chargeOffReasonExpense.expenseAccountId | find: expenseAccountData : 'id' : 'name' }}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Actions' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let chargeOffReasonExpense; let i = index">
|
||||
<button
|
||||
mat-icon-button
|
||||
color="primary"
|
||||
(click)="edit('ChargeOffReasonExpense', chargeOffReasonToExpenseAccountMappings, i)"
|
||||
>
|
||||
<fa-icon icon="edit"></fa-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
color="warn"
|
||||
(click)="delete('ChargeOffReasonExpense', chargeOffReasonToExpenseAccountMappings, i)"
|
||||
>
|
||||
<fa-icon icon="trash"></fa-icon>
|
||||
</button>
|
||||
</td>
|
||||
</ng-container>
|
||||
<tr mat-header-row *matHeaderRowDef="chargeOffReasonExpenseDisplayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: chargeOffReasonExpenseDisplayedColumns"></tr>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Advanced Mapping Rule Components -->
|
||||
<mifosx-advanced-accounting-mapping-rule
|
||||
class="lpa-full-width"
|
||||
[textField]="'Classification'"
|
||||
[formType]="'BuydownFeeClassificationToIncome'"
|
||||
[formArray]="buydownfeeClassificationToIncomeAccountMappings"
|
||||
[textHeading]="'Buydown Fee classifications to Income accounts'"
|
||||
[incomeAccountData]="incomeAccountData"
|
||||
[accountingMappingOptions]="buydownFeeClassificationOptions"
|
||||
(formChangeEvent)="formChangeEvent($event)"
|
||||
></mifosx-advanced-accounting-mapping-rule>
|
||||
<mifosx-advanced-accounting-mapping-rule
|
||||
class="lpa-full-width"
|
||||
[textField]="'Classification'"
|
||||
[formType]="'CapitalizedIncomeClassificationToIncome'"
|
||||
[formArray]="capitalizedIncomeClassificationToIncomeAccountMappings"
|
||||
[textHeading]="'Capitalized Income classifications to Income accounts'"
|
||||
[incomeAccountData]="incomeAccountData"
|
||||
[accountingMappingOptions]="capitalizedIncomeClassificationOptions"
|
||||
(formChangeEvent)="formChangeEvent($event)"
|
||||
></mifosx-advanced-accounting-mapping-rule>
|
||||
<mifosx-advanced-accounting-mapping-rule
|
||||
class="lpa-full-width"
|
||||
[textField]="'WriteOff Reason'"
|
||||
[formType]="'WriteOffReasonToExpense'"
|
||||
[formArray]="writeOffReasonsToExpenseMappings"
|
||||
[textHeading]="'WriteOff reasons to Expense accounts'"
|
||||
[expenseAccountData]="expenseAccountData"
|
||||
[accountingMappingOptions]="writeOffReasonOptions"
|
||||
(formChangeEvent)="formChangeEvent($event)"
|
||||
></mifosx-advanced-accounting-mapping-rule>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
+3
-130
@@ -104,23 +104,6 @@ export class LoanProductAccountingStepComponent implements OnInit, OnChanges {
|
||||
@Input() accountingRuleData: any;
|
||||
@Input() loanProductFormValid: boolean;
|
||||
@Input() deferredIncomeRecognition: DeferredIncomeRecognition;
|
||||
/** The product's category / type and its portfolio GL, from the Details step; they drive the default GL mapping. */
|
||||
@Input() productTypeGroup: string | null = null;
|
||||
@Input() productTypeSubGroup: string | null = null;
|
||||
@Input() productGlAccountId: number | string | null = null;
|
||||
|
||||
/** The mappings every product needs; the rest default from these and sit behind "Show all mappings". */
|
||||
readonly basicControls = new Set<string>([
|
||||
'loanPortfolioAccountId',
|
||||
'fundSourceAccountId',
|
||||
'interestOnLoanAccountId',
|
||||
'incomeFromFeeAccountId',
|
||||
'incomeFromPenaltyAccountId',
|
||||
'writeOffAccountId',
|
||||
'receivableInterestAccountId',
|
||||
'overpaymentLiabilityAccountId',
|
||||
'undebitedInterestAccountId'
|
||||
]);
|
||||
|
||||
public loanProductAccountingForm!: UntypedFormGroup;
|
||||
|
||||
@@ -168,73 +151,6 @@ export class LoanProductAccountingStepComponent implements OnInit, OnChanges {
|
||||
if (this.loanProductAccountingForm) {
|
||||
this.setDeferredIncomeRecognitionControls();
|
||||
}
|
||||
if (changes['productTypeGroup'] || changes['productTypeSubGroup'] || changes['productGlAccountId']) {
|
||||
this.applyDefaultMapping();
|
||||
}
|
||||
}
|
||||
|
||||
/** The id of the GL account with this GL code among all the accounting options, or null. */
|
||||
private glIdByCode(code: string): number | null {
|
||||
const all = [
|
||||
...(this.assetAccountData ?? []),
|
||||
...(this.incomeAccountData ?? []),
|
||||
...(this.expenseAccountData ?? []),
|
||||
...(this.liabilityAccountData ?? [])
|
||||
];
|
||||
return all.find((account: any) => String(account.glCode) === code)?.id ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-fills the GL mappings from the product type: the portfolio GL is the product's own GL, interest income follows
|
||||
* the loan term (Advances short, Loans medium, Housing long), the rest are the bank's shared GLs. A mapping the
|
||||
* officer has already changed is left alone.
|
||||
*/
|
||||
applyDefaultMapping(): void {
|
||||
// A saved product keeps its own mappings; one saved without any still gets the defaults.
|
||||
const hasSavedMappings = !!this.loanProductsTemplate?.id && !!this.loanProductsTemplate?.accountingMappings;
|
||||
if (!this.loanProductAccountingForm || !this.assetAccountData || hasSavedMappings) {
|
||||
return;
|
||||
}
|
||||
const isAdvance = this.productTypeGroup === 'advances';
|
||||
const interestCode = isAdvance ? '400301' : this.productTypeSubGroup === 'housing' ? '400303' : '400302';
|
||||
const cash = this.glIdByCode('100101');
|
||||
const interest = this.glIdByCode(interestCode);
|
||||
const fee = this.glIdByCode('400106') ?? this.glIdByCode('400101');
|
||||
const penal = this.glIdByCode('400304') ?? this.glIdByCode('400101');
|
||||
const writeOff = this.glIdByCode('500503');
|
||||
const receivable = this.glIdByCode('100601');
|
||||
const overpayment = this.glIdByCode('200907');
|
||||
const suspense = this.glIdByCode('200908');
|
||||
const defaults: Record<string, number | string | null> = {
|
||||
loanPortfolioAccountId: this.productGlAccountId || null,
|
||||
fundSourceAccountId: cash,
|
||||
transfersInSuspenseAccountId: cash,
|
||||
interestOnLoanAccountId: interest,
|
||||
incomeFromFeeAccountId: fee,
|
||||
incomeFromPenaltyAccountId: penal,
|
||||
incomeFromRecoveryAccountId: interest,
|
||||
writeOffAccountId: writeOff,
|
||||
goodwillCreditAccountId: writeOff,
|
||||
chargeOffFraudExpenseAccountId: writeOff,
|
||||
chargeOffExpenseAccountId: writeOff,
|
||||
incomeFromChargeOffPenaltyAccountId: penal,
|
||||
incomeFromChargeOffFeesAccountId: fee,
|
||||
incomeFromChargeOffInterestAccountId: interest,
|
||||
incomeFromGoodwillCreditInterestAccountId: interest,
|
||||
incomeFromGoodwillCreditFeesAccountId: fee,
|
||||
incomeFromGoodwillCreditPenaltyAccountId: penal,
|
||||
receivableInterestAccountId: receivable,
|
||||
receivableFeeAccountId: receivable,
|
||||
receivablePenaltyAccountId: receivable,
|
||||
overpaymentLiabilityAccountId: overpayment,
|
||||
undebitedInterestAccountId: suspense
|
||||
};
|
||||
Object.keys(defaults).forEach((name) => {
|
||||
const control = this.loanProductAccountingForm.get(name);
|
||||
if (control && !control.dirty && defaults[name]) {
|
||||
control.setValue(defaults[name]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
@@ -261,7 +177,6 @@ export class LoanProductAccountingStepComponent implements OnInit, OnChanges {
|
||||
this.loanProductAccountingForm.get('accountingRule').disable();
|
||||
|
||||
this.buildAccountingSectionConfigs();
|
||||
this.applyDefaultMapping();
|
||||
|
||||
const accountingMappings = this.loanProductsTemplate.accountingMappings;
|
||||
this.setDeferredIncomeRecognitionControls();
|
||||
@@ -362,33 +277,6 @@ export class LoanProductAccountingStepComponent implements OnInit, OnChanges {
|
||||
'incomeFromGoodwillCreditFeesAccountId',
|
||||
'incomeFromGoodwillCreditPenaltyAccountId',
|
||||
'receivableInterestAccountId',
|
||||
'receivableInterestAccountId',
|
||||
'receivableFeeAccountId',
|
||||
'receivablePenaltyAccountId',
|
||||
'intReceivablesGlAccountId',
|
||||
'intPayableGlAccountId',
|
||||
'deafIntPayableGlAccountId',
|
||||
'intOverdueReserveAccountId',
|
||||
'undebitedInterestAccountId',
|
||||
'penalIntCrGlAccountId',
|
||||
'rdMaturedGlAccountId'
|
||||
];
|
||||
// CBS-specific mappings the backend does not accept yet: optional here so a product can be created without
|
||||
// them (empty values are dropped from the payload in LoanProducts.buildPayload).
|
||||
const optionalCbsFields = [
|
||||
// derived by the backend from the nine mapped GLs (LoanGlMappingDefaults)
|
||||
'transfersInSuspenseAccountId',
|
||||
'incomeFromRecoveryAccountId',
|
||||
'goodwillCreditAccountId',
|
||||
'chargeOffFraudExpenseAccountId',
|
||||
'chargeOffExpenseAccountId',
|
||||
'incomeFromChargeOffPenaltyAccountId',
|
||||
'incomeFromChargeOffFeesAccountId',
|
||||
'incomeFromChargeOffInterestAccountId',
|
||||
'incomeFromGoodwillCreditInterestAccountId',
|
||||
'incomeFromGoodwillCreditFeesAccountId',
|
||||
'incomeFromGoodwillCreditPenaltyAccountId',
|
||||
'receivableInterestAccountId',
|
||||
'receivableFeeAccountId',
|
||||
'receivablePenaltyAccountId',
|
||||
'intReceivablesGlAccountId',
|
||||
@@ -401,10 +289,7 @@ export class LoanProductAccountingStepComponent implements OnInit, OnChanges {
|
||||
];
|
||||
commonFields.forEach((field) => {
|
||||
if (!this.loanProductAccountingForm.contains(field)) {
|
||||
this.loanProductAccountingForm.addControl(
|
||||
field,
|
||||
new UntypedFormControl('', optionalCbsFields.includes(field) ? null : Validators.required)
|
||||
);
|
||||
this.loanProductAccountingForm.addControl(field, new UntypedFormControl('', Validators.required));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -682,20 +567,8 @@ export class LoanProductAccountingStepComponent implements OnInit, OnChanges {
|
||||
} = this.loanProductAccountingForm.getRawValue();
|
||||
const otherGlMappings = this.getOtherGlMappings();
|
||||
|
||||
// Only the nine mapped GLs are sent. The other Fineract placeholders (recovery, goodwill credit, charge-off ...)
|
||||
// are derived by the backend. Interest Suspense has no core mapping, so it is kept on the satellite record.
|
||||
const payload: any = {};
|
||||
this.basicControls.forEach((name) => {
|
||||
if (name !== 'undebitedInterestAccountId' && loanProductAccounting[name]) {
|
||||
payload[name] = loanProductAccounting[name];
|
||||
}
|
||||
});
|
||||
if (loanProductAccounting.undebitedInterestAccountId) {
|
||||
payload.undebitedInterestGlId = loanProductAccounting.undebitedInterestAccountId;
|
||||
}
|
||||
return {
|
||||
accountingRule: loanProductAccounting.accountingRule,
|
||||
...payload,
|
||||
...loanProductAccounting,
|
||||
...(otherGlMappings ? { otherGlMappings } : {})
|
||||
};
|
||||
}
|
||||
@@ -811,7 +684,7 @@ export class LoanProductAccountingStepComponent implements OnInit, OnChanges {
|
||||
inputLabel: 'Interest Receivable',
|
||||
controlName: 'receivableInterestAccountId',
|
||||
options: this.assetAccountData,
|
||||
required: false,
|
||||
required: true,
|
||||
accrualOnly: true
|
||||
},
|
||||
{
|
||||
|
||||
+11
-20
@@ -61,12 +61,12 @@
|
||||
<mat-form-field appearance="outline" class="adv-field">
|
||||
<mat-label>Minimum Tenure (months)</mat-label>
|
||||
<input matInput type="number" formControlName="minTenure" [min]="tenureMin" [max]="tenureMax" required />
|
||||
@if (tenureBand; as band) {
|
||||
<mat-hint>{{ band.label }} policy: {{ band.min }}–{{ band.max }} months only</mat-hint>
|
||||
@if (isFdTenureLocked) {
|
||||
<mat-hint>FD policy: 12–15 months only</mat-hint>
|
||||
}
|
||||
<mat-error>
|
||||
@if (tenureBand; as band) {
|
||||
Must be between <strong>{{ band.min }} and {{ band.max }}</strong> months
|
||||
@if (isFdTenureLocked) {
|
||||
Must be between <strong>12 and 15</strong> months
|
||||
} @else {
|
||||
Minimum Tenure is <strong>required</strong>
|
||||
}
|
||||
@@ -75,31 +75,22 @@
|
||||
<mat-form-field appearance="outline" class="adv-field">
|
||||
<mat-label>Default Tenure (months)</mat-label>
|
||||
<input matInput type="number" formControlName="defaultTenure" [min]="tenureMin" [max]="tenureMax" />
|
||||
@if (tenureBand; as band) {
|
||||
<mat-hint>Between {{ band.min }} and {{ band.max }}</mat-hint>
|
||||
}
|
||||
@if (tenureBand; as band) {
|
||||
<mat-error
|
||||
>Must be between <strong>{{ band.min }} and {{ band.max }}</strong> months</mat-error
|
||||
>
|
||||
@if (isFdTenureLocked) {
|
||||
<mat-hint>Between 12 and 15</mat-hint>
|
||||
<mat-error>Must be between <strong>12 and 15</strong> months</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="adv-field">
|
||||
<mat-label>Maximum Tenure (months)</mat-label>
|
||||
<input matInput type="number" formControlName="maxTenure" [min]="tenureMin" [max]="tenureMax" required />
|
||||
@if (tenureBand; as band) {
|
||||
<mat-hint>
|
||||
{{ band.label }} policy: max {{ band.max }} months
|
||||
@if (band.depositBacked) {
|
||||
(also capped at deposit maturity)
|
||||
}
|
||||
</mat-hint>
|
||||
@if (isFdTenureLocked) {
|
||||
<mat-hint>FD policy: max 15 months (also capped at deposit maturity)</mat-hint>
|
||||
} @else {
|
||||
<mat-hint>Also capped at the deposit's maturity (Lien rule)</mat-hint>
|
||||
}
|
||||
<mat-error>
|
||||
@if (tenureBand; as band) {
|
||||
Must be between <strong>{{ band.min }} and {{ band.max }}</strong> months
|
||||
@if (isFdTenureLocked) {
|
||||
Must be between <strong>12 and 15</strong> months
|
||||
} @else {
|
||||
Maximum Tenure is <strong>required</strong>
|
||||
}
|
||||
|
||||
+12
-33
@@ -110,41 +110,21 @@ export class LoanProductAdvanceLimitTenureStepComponent {
|
||||
return this.loanProductAdvanceLimitTenureForm.getRawValue();
|
||||
}
|
||||
|
||||
/** Bank-policy tenure band per sub type: FD 12 to 15 months, a bullet Gold loan 1 to 12. Others stay open. */
|
||||
private readonly tenureBands: Record<
|
||||
string,
|
||||
{ label: string; min: number; max: number; defaultTenure: number; depositBacked?: boolean }
|
||||
> = {
|
||||
// A loan against a deposit or policy is a short-term advance: 12 to 15 months for all four, and never past maturity.
|
||||
fd: { label: 'FD', min: 12, max: 15, defaultTenure: 12, depositBacked: true },
|
||||
rd: { label: 'RD', min: 12, max: 15, defaultTenure: 12, depositBacked: true },
|
||||
dds: { label: 'DDS', min: 12, max: 15, defaultTenure: 12, depositBacked: true },
|
||||
lic: { label: 'LIC', min: 12, max: 15, defaultTenure: 12, depositBacked: true },
|
||||
gold: { label: 'Gold', min: 1, max: 12, defaultTenure: 12 },
|
||||
warehouse: { label: 'Warehouse', min: 1, max: 12, defaultTenure: 6 }
|
||||
};
|
||||
|
||||
/** The policy band in force for the chosen sub type, or null when the sub type is open. */
|
||||
get tenureBand(): {
|
||||
label: string;
|
||||
min: number;
|
||||
max: number;
|
||||
defaultTenure: number;
|
||||
depositBacked?: boolean;
|
||||
} | null {
|
||||
return this.tenureBands[this._productSubType] ?? null;
|
||||
/** True when the FD 12–15 month cap is in force (drives the hints/errors in the template). */
|
||||
get isFdTenureLocked(): boolean {
|
||||
return this._productSubType === 'fd';
|
||||
}
|
||||
|
||||
get tenureMin(): number {
|
||||
return this.tenureBand?.min ?? 1;
|
||||
return this.isFdTenureLocked ? 12 : 1;
|
||||
}
|
||||
|
||||
get tenureMax(): number | null {
|
||||
return this.tenureBand?.max ?? null;
|
||||
return this.isFdTenureLocked ? 15 : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* FD, RD, DDS and LIC (12–15 months), Gold and Warehouse (1–12 months) are bank-policy capped; every other sub type stays open (min 1).
|
||||
* FD advances are bank-policy capped to 12–15 months; every other sub type stays open (min 1).
|
||||
* Called whenever the sub type changes so the validators — and thus the wizard's validity gate —
|
||||
* match the selected product.
|
||||
*/
|
||||
@@ -156,11 +136,10 @@ export class LoanProductAdvanceLimitTenureStepComponent {
|
||||
const min = form.get('minTenure');
|
||||
const def = form.get('defaultTenure');
|
||||
const max = form.get('maxTenure');
|
||||
const band = this.tenureBand;
|
||||
if (band) {
|
||||
if (this._productSubType === 'fd') {
|
||||
const bounds = [
|
||||
Validators.min(band.min),
|
||||
Validators.max(band.max)
|
||||
Validators.min(12),
|
||||
Validators.max(15)
|
||||
];
|
||||
min.setValidators([
|
||||
Validators.required,
|
||||
@@ -171,9 +150,9 @@ export class LoanProductAdvanceLimitTenureStepComponent {
|
||||
Validators.required,
|
||||
...bounds
|
||||
]);
|
||||
min.setValue(band.min, { emitEvent: false });
|
||||
def.setValue(band.defaultTenure, { emitEvent: false });
|
||||
max.setValue(band.max, { emitEvent: false });
|
||||
min.setValue(12, { emitEvent: false });
|
||||
def.setValue(12, { emitEvent: false });
|
||||
max.setValue(15, { emitEvent: false });
|
||||
} else {
|
||||
min.setValidators([
|
||||
Validators.required,
|
||||
|
||||
+8
@@ -42,6 +42,10 @@
|
||||
<mat-label>Max Books Outstanding</mat-label>
|
||||
<input matInput type="number" formControlName="maxBooksOutstanding" min="0" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="adv-field">
|
||||
<mat-label>Reissue When Unused Leaves ≤</mat-label>
|
||||
<input matInput type="number" formControlName="reissueWhenLeaves" min="0" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="adv-field">
|
||||
<mat-label>First Cheque Book Free</mat-label>
|
||||
<mat-select formControlName="firstBookFree">
|
||||
@@ -62,6 +66,10 @@
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="adv-field adv-field--full" style="grid-column: span 2">
|
||||
<mat-label>Cheque Print Description</mat-label>
|
||||
<input matInput formControlName="chequePrintDescription" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
+2
@@ -62,9 +62,11 @@ export class LoanProductChequeBookStepComponent {
|
||||
],
|
||||
defaultBookSize: ['25'],
|
||||
maxBooksOutstanding: [1],
|
||||
reissueWhenLeaves: [5],
|
||||
firstBookFree: ['YES'],
|
||||
freeBooksPerYear: [1],
|
||||
namePrinted: ['Account holder name'],
|
||||
chequePrintDescription: [''],
|
||||
ppsThreshold: [
|
||||
50000,
|
||||
[
|
||||
|
||||
+1
-90
@@ -105,29 +105,6 @@ export class LoanProductDetailsStepComponent implements OnInit {
|
||||
startDate: this.loanProductsTemplate.startDate ? new Date(this.loanProductsTemplate.startDate) : new Date(),
|
||||
closeDate: this.loanProductsTemplate.closeDate && new Date(this.loanProductsTemplate.closeDate)
|
||||
});
|
||||
|
||||
// A saved Advances / Loans product comes back with its type in the extension record. Show it filled and
|
||||
// lock it: changing the type would change every other tab.
|
||||
const ext = this.loanProductsTemplate?.advanceExtensions;
|
||||
if (this.isExistingProduct && ext?.productTypeGroup) {
|
||||
this.loanProductDetailsForm.patchValue({
|
||||
productTypeGroup: ext.productTypeGroup,
|
||||
productTypeSubGroup: ext.productTypeSubGroup,
|
||||
glAccount: ext.glAccount ?? '',
|
||||
loanProductCategory: ext.loanProductCategory ?? this.loanProductDetailsForm.value.loanProductCategory,
|
||||
prioritySector: ext.prioritySector ?? this.loanProductDetailsForm.value.prioritySector,
|
||||
cibilAccountType: ext.cibilAccountType ?? this.loanProductDetailsForm.value.cibilAccountType,
|
||||
kfsEnabled: this.yesNo(ext.kfsEnabled, this.loanProductDetailsForm.value.kfsEnabled),
|
||||
aprEnabled: this.yesNo(ext.aprEnabled, this.loanProductDetailsForm.value.aprEnabled)
|
||||
});
|
||||
this.loanProductDetailsForm.get('productTypeGroup').disable({ emitEvent: false });
|
||||
this.loanProductDetailsForm.get('productTypeSubGroup').disable({ emitEvent: false });
|
||||
}
|
||||
}
|
||||
|
||||
/** A saved YES / NO choice comes back as true / false. */
|
||||
private yesNo(saved: any, fallback: any): any {
|
||||
return typeof saved === 'boolean' ? (saved ? 'YES' : 'NO') : (saved ?? fallback);
|
||||
}
|
||||
|
||||
/** True when editing a saved product; see ngOnInit. */
|
||||
@@ -248,77 +225,11 @@ export class LoanProductDetailsStepComponent implements OnInit {
|
||||
if (subGroup) {
|
||||
this.loanProductDetailsForm.get('shortName').setValue(subGroup.shortName);
|
||||
}
|
||||
this.applyCategoryForSubType(subGroupId);
|
||||
});
|
||||
|
||||
// The category sets the Priority Sector and CIBIL defaults. A change the officer makes here reaches this
|
||||
// subscription; the sub type's own default (above) calls applyCategoryDefaults directly.
|
||||
this.loanProductDetailsForm.get('loanProductCategory')?.valueChanges.subscribe((category: string) => {
|
||||
this.applyCategoryDefaults(category);
|
||||
});
|
||||
}
|
||||
|
||||
/** Sub type -> the category that normally fits it. Sub types not listed leave the category as it is. */
|
||||
private readonly categoryBySubType: Record<string, string> = {
|
||||
fd: 'advances_deposits',
|
||||
rd: 'advances_deposits',
|
||||
dds: 'advances_deposits',
|
||||
lic: 'advances_deposits',
|
||||
gold: 'gold_loan',
|
||||
warehouse: 'agriculture',
|
||||
// Loans A/C (EMI)
|
||||
term: 'other',
|
||||
personal: 'personal',
|
||||
housing: 'housing',
|
||||
vehicle: 'personal',
|
||||
education: 'personal',
|
||||
business: 'msme',
|
||||
gold_emi: 'gold_loan'
|
||||
};
|
||||
|
||||
/**
|
||||
* Category -> Priority Sector and CIBIL account type that normally go with it (RBI sector classification).
|
||||
* Defaults only: the officer can still pick another value for either.
|
||||
*/
|
||||
private readonly defaultsByCategory: Record<string, { prioritySector: string; cibilAccountType: string }> = {
|
||||
agriculture: { prioritySector: 'yes', cibilAccountType: 'other' },
|
||||
msme: { prioritySector: 'yes', cibilAccountType: 'business_loan' },
|
||||
personal: { prioritySector: 'no', cibilAccountType: 'other' },
|
||||
housing: { prioritySector: 'no', cibilAccountType: 'other' },
|
||||
advances_deposits: { prioritySector: 'no', cibilAccountType: 'loan_bank_deposits' },
|
||||
gold_loan: { prioritySector: 'no', cibilAccountType: 'gold_loan' },
|
||||
other: { prioritySector: 'no', cibilAccountType: 'other' }
|
||||
};
|
||||
|
||||
/** Picking a sub type sets its usual category (unless the officer chose one), and the category's defaults follow. */
|
||||
private applyCategoryForSubType(subGroupId: string): void {
|
||||
const category = this.categoryBySubType[subGroupId];
|
||||
if (!category) {
|
||||
return;
|
||||
}
|
||||
const control = this.loanProductDetailsForm.get('loanProductCategory');
|
||||
if (control && !control.dirty) {
|
||||
control.setValue(category, { emitEvent: false });
|
||||
this.applyCategoryDefaults(category);
|
||||
}
|
||||
}
|
||||
|
||||
/** Sets Priority Sector and CIBIL type for a category, leaving alone any the officer has already chosen. */
|
||||
private applyCategoryDefaults(category: string): void {
|
||||
const fit = this.defaultsByCategory[category];
|
||||
if (!fit) {
|
||||
return;
|
||||
}
|
||||
(Object.keys(fit) as (keyof typeof fit)[]).forEach((name) => {
|
||||
const control = this.loanProductDetailsForm.get(name);
|
||||
if (control && !control.dirty) {
|
||||
control.setValue(fit[name], { emitEvent: false });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get loanProductDetails() {
|
||||
const loanProductDetailsFormData = this.loanProductDetailsForm.getRawValue();
|
||||
const loanProductDetailsFormData = this.loanProductDetailsForm.value;
|
||||
const prevStartDate: Date = this.loanProductDetailsForm.value.startDate;
|
||||
const prevCloseDate: Date = this.loanProductDetailsForm.value.closeDate;
|
||||
const dateFormat = this.settingsService.dateFormat;
|
||||
|
||||
-60
@@ -1,60 +0,0 @@
|
||||
<!--
|
||||
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/.
|
||||
-->
|
||||
|
||||
<form [formGroup]="loanProductEligibilityDocsForm">
|
||||
<div class="adv-section">
|
||||
<div class="adv-section__header">
|
||||
<i class="fas fa-user-check"></i>
|
||||
<h3>Borrower Eligibility</h3>
|
||||
</div>
|
||||
<div class="ed-body">
|
||||
<div class="ed-rules">
|
||||
<label class="ed-rule">
|
||||
<mat-checkbox formControlName="memberRequired"></mat-checkbox>
|
||||
<span class="ed-rule__text">
|
||||
<strong>Borrower must be a member</strong>
|
||||
<small>Only members of the bank can take this product</small>
|
||||
</span>
|
||||
</label>
|
||||
<label class="ed-rule">
|
||||
<mat-checkbox formControlName="guarantorMustBeMember"></mat-checkbox>
|
||||
<span class="ed-rule__text">
|
||||
<strong>Guarantors must be members</strong>
|
||||
<small>Applies when the product asks for guarantors</small>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="adv-section">
|
||||
<div class="adv-section__header">
|
||||
<i class="fas fa-file-lines"></i>
|
||||
<h3>Required Documents</h3>
|
||||
<span class="ed-count">{{ selectedCount }} of {{ documentOptions.length }} selected</span>
|
||||
</div>
|
||||
<div class="ed-body">
|
||||
@for (group of documentGroups; track group.title) {
|
||||
<div class="ed-group">
|
||||
<div class="ed-group__title"><i class="fas {{ group.icon }}"></i>{{ group.title }}</div>
|
||||
<div class="ed-docs">
|
||||
@for (doc of documentsOf(group); track doc.id) {
|
||||
<label class="ed-doc" [class.ed-doc--on]="isSelected(doc.id)">
|
||||
<mat-checkbox
|
||||
[checked]="isSelected(doc.id)"
|
||||
(change)="toggleDocument(doc.id, $event.checked)"
|
||||
></mat-checkbox>
|
||||
<span>{{ doc.label }}</span>
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
-163
@@ -1,163 +0,0 @@
|
||||
/**
|
||||
* 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, OnInit, inject } from '@angular/core';
|
||||
import { UntypedFormGroup, UntypedFormBuilder, UntypedFormControl, Validators } from '@angular/forms';
|
||||
import { MatCheckbox } from '@angular/material/checkbox';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
/**
|
||||
* Borrower eligibility and document checklist, common to Advances A/C and Loans A/C. Age, CIBIL, income and
|
||||
* FOIR already live on the Loans amount/tenure step, so only the rules both categories share are held here.
|
||||
* Sent as satellite params (LoanProductAdvanceExtService.ADVANCE_PARAMETERS).
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-loan-product-eligibility-docs-step',
|
||||
templateUrl: './loan-product-eligibility-docs-step.component.html',
|
||||
styleUrls: ['../advance-steps.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
MatCheckbox
|
||||
]
|
||||
})
|
||||
export class LoanProductEligibilityDocsStepComponent implements OnInit {
|
||||
private formBuilder = inject(UntypedFormBuilder);
|
||||
|
||||
@Input() productTypeGroup: UntypedFormControl;
|
||||
|
||||
loanProductEligibilityDocsForm: UntypedFormGroup;
|
||||
|
||||
readonly documentOptions = [
|
||||
{ id: 'KYC', label: 'KYC (ID proof)' },
|
||||
{ id: 'ADDRESS_PROOF', label: 'Address proof' },
|
||||
{ id: 'INCOME_PROOF', label: 'Income proof / salary certificate' },
|
||||
{ id: 'ITR', label: 'Income-tax returns (3 years)' },
|
||||
{ id: 'DEMAND_PROMISSORY_NOTE', label: 'Demand promissory note' },
|
||||
{ id: 'LOAN_AGREEMENT', label: 'Loan agreement' },
|
||||
{ id: 'GUARANTEE_DEED', label: 'Guarantor agreement' },
|
||||
{ id: 'SALARY_DEDUCTION_AUTHORITY', label: 'Employer salary-deduction authority' },
|
||||
{ id: 'LETTER_OF_LIEN', label: 'Letter of lien and set-off' },
|
||||
{ id: 'LETTER_OF_CONTINUITY', label: 'Letter of continuity' },
|
||||
{ id: 'HYPOTHECATION_AGREEMENT', label: 'Hypothecation agreement' },
|
||||
{ id: 'TITLE_DEED', label: 'Title deed / registry' },
|
||||
{ id: 'VALUATION_REPORT', label: 'Valuation report' },
|
||||
{ id: 'STOCK_STATEMENT', label: 'Stock statement' },
|
||||
{ id: 'INSURANCE_POLICY', label: 'Insurance policy (joint name)' },
|
||||
{ id: 'QUOTATION', label: 'Dealer quotation / estimate' }
|
||||
];
|
||||
|
||||
/** The checklist grouped the way an officer thinks about the papers. Every document sits in exactly one group. */
|
||||
readonly documentGroups: { title: string; icon: string; ids: string[] }[] = [
|
||||
{ title: 'Identity and address', icon: 'fa-id-card', ids: [
|
||||
'KYC',
|
||||
'ADDRESS_PROOF'
|
||||
] },
|
||||
{ title: 'Income', icon: 'fa-money-bill-wave', ids: [
|
||||
'INCOME_PROOF',
|
||||
'ITR',
|
||||
'SALARY_DEDUCTION_AUTHORITY'
|
||||
] },
|
||||
{
|
||||
title: 'Agreements',
|
||||
icon: 'fa-file-signature',
|
||||
ids: [
|
||||
'DEMAND_PROMISSORY_NOTE',
|
||||
'LOAN_AGREEMENT',
|
||||
'GUARANTEE_DEED',
|
||||
'HYPOTHECATION_AGREEMENT'
|
||||
]
|
||||
},
|
||||
{ title: 'Lien and continuity', icon: 'fa-lock', ids: [
|
||||
'LETTER_OF_LIEN',
|
||||
'LETTER_OF_CONTINUITY'
|
||||
] },
|
||||
{
|
||||
title: 'Security papers',
|
||||
icon: 'fa-shield-halved',
|
||||
ids: [
|
||||
'TITLE_DEED',
|
||||
'VALUATION_REPORT',
|
||||
'STOCK_STATEMENT',
|
||||
'INSURANCE_POLICY',
|
||||
'QUOTATION'
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
documentsOf(group: { ids: string[] }): { id: string; label: string }[] {
|
||||
return this.documentOptions.filter((doc) => group.ids.includes(doc.id));
|
||||
}
|
||||
|
||||
get selectedCount(): number {
|
||||
return (this.loanProductEligibilityDocsForm.get('requiredDocuments')?.value ?? []).length;
|
||||
}
|
||||
|
||||
private readonly defaultDocuments: Record<string, string[]> = {
|
||||
advances: [
|
||||
'KYC',
|
||||
'DEMAND_PROMISSORY_NOTE',
|
||||
'LETTER_OF_LIEN',
|
||||
'LETTER_OF_CONTINUITY'
|
||||
],
|
||||
loans: [
|
||||
'KYC',
|
||||
'ADDRESS_PROOF',
|
||||
'INCOME_PROOF',
|
||||
'LOAN_AGREEMENT',
|
||||
'GUARANTEE_DEED'
|
||||
]
|
||||
};
|
||||
|
||||
constructor() {
|
||||
this.loanProductEligibilityDocsForm = this.formBuilder.group({
|
||||
memberRequired: [true],
|
||||
guarantorMustBeMember: [true],
|
||||
requiredDocuments: [[]]
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
if (this.productTypeGroup) {
|
||||
this.applyDefaultDocuments(this.productTypeGroup.value);
|
||||
this.productTypeGroup.valueChanges.subscribe((group: string) => this.applyDefaultDocuments(group));
|
||||
}
|
||||
}
|
||||
|
||||
/** Pre-ticks the usual checklist for the category until the user edits it. */
|
||||
private applyDefaultDocuments(group: string): void {
|
||||
const control = this.loanProductEligibilityDocsForm.get('requiredDocuments');
|
||||
if (control && !control.dirty && this.defaultDocuments[group]) {
|
||||
control.setValue([...this.defaultDocuments[group]]);
|
||||
}
|
||||
}
|
||||
|
||||
isSelected(id: string): boolean {
|
||||
return (this.loanProductEligibilityDocsForm.get('requiredDocuments')?.value ?? []).includes(id);
|
||||
}
|
||||
|
||||
toggleDocument(id: string, checked: boolean): void {
|
||||
const control = this.loanProductEligibilityDocsForm.get('requiredDocuments');
|
||||
const current: string[] = control?.value ?? [];
|
||||
control?.setValue(
|
||||
checked
|
||||
? [
|
||||
...new Set([
|
||||
...current,
|
||||
id
|
||||
])
|
||||
]
|
||||
: current.filter((d) => d !== id)
|
||||
);
|
||||
control?.markAsDirty();
|
||||
}
|
||||
|
||||
get loanProductEligibilityDocs() {
|
||||
const value = this.loanProductEligibilityDocsForm.getRawValue();
|
||||
return { ...value, requiredDocuments: (value.requiredDocuments ?? []).join(',') };
|
||||
}
|
||||
}
|
||||
+5
-36
@@ -44,38 +44,18 @@
|
||||
<div class="adv-grid adv-grid--3">
|
||||
<mat-form-field appearance="outline" class="adv-field">
|
||||
<mat-label>Minimum Tenure (months)</mat-label>
|
||||
<input matInput type="number" formControlName="minTenure" [min]="band?.minTenure ?? 1" required />
|
||||
@if (band; as b) {
|
||||
<mat-hint>{{ b.label }}: {{ b.minTenure }}–{{ b.maxTenure }} months</mat-hint>
|
||||
}
|
||||
<mat-error>
|
||||
@if (band; as b) {
|
||||
Must be between <strong>{{ b.minTenure }} and {{ b.maxTenure }}</strong> months
|
||||
} @else {
|
||||
Minimum Tenure is <strong>required</strong>
|
||||
}
|
||||
</mat-error>
|
||||
<input matInput type="number" formControlName="minTenure" min="1" required />
|
||||
<mat-error>Minimum Tenure is <strong>required</strong></mat-error>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="adv-field">
|
||||
<mat-label>Default Tenure (months)</mat-label>
|
||||
<input matInput type="number" formControlName="defaultTenure" [min]="band?.minTenure ?? 1" />
|
||||
@if (band; as b) {
|
||||
<mat-error
|
||||
>Must be between <strong>{{ b.minTenure }} and {{ b.maxTenure }}</strong> months</mat-error
|
||||
>
|
||||
}
|
||||
<input matInput type="number" formControlName="defaultTenure" min="1" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="adv-field">
|
||||
<mat-label>Maximum Tenure (months)</mat-label>
|
||||
<input matInput type="number" formControlName="maxTenure" [min]="band?.minTenure ?? 1" required />
|
||||
<input matInput type="number" formControlName="maxTenure" min="1" required />
|
||||
<mat-hint>Age at maturity cap comes from Borrower Eligibility below</mat-hint>
|
||||
<mat-error>
|
||||
@if (band; as b) {
|
||||
Must be between <strong>{{ b.minTenure }} and {{ b.maxTenure }}</strong> months
|
||||
} @else {
|
||||
Maximum Tenure is <strong>required</strong>
|
||||
}
|
||||
</mat-error>
|
||||
<mat-error>Maximum Tenure is <strong>required</strong></mat-error>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
@@ -110,17 +90,6 @@
|
||||
<mat-label>Maximum Loans per Customer</mat-label>
|
||||
<input matInput type="number" formControlName="maxLoansPerCustomer" min="1" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="adv-field">
|
||||
<mat-label>Income Multiple (× net monthly salary)</mat-label>
|
||||
<input matInput type="number" formControlName="incomeMultiple" min="0" step="0.5" />
|
||||
<mat-hint>Loan up to this many times the net salary. Policy: personal loan 10×</mat-hint>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="adv-subsection">
|
||||
<div class="adv-checks">
|
||||
<mat-checkbox formControlName="memberRequired">Applicant must be a member of the bank</mat-checkbox>
|
||||
<mat-checkbox formControlName="guarantorMustBeMember">Guarantors must be members of the bank</mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
+1
-149
@@ -6,70 +6,12 @@
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, Input, inject } from '@angular/core';
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms';
|
||||
import { MatCheckbox } from '@angular/material/checkbox';
|
||||
import { MatRadioButton, MatRadioGroup } from '@angular/material/radio';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
/**
|
||||
* Tenure band (months) and borrower age limits for a Loans A/C sub type, taken from the bank's loan policy: short term
|
||||
* up to 15 months, medium 15 to 84, long above 84, and never more than 120. Housing runs 7 to 10 years, education is
|
||||
* repaid in 84 equal instalments, vehicle 3 to 7 years, personal 3 to 5 years.
|
||||
*/
|
||||
interface LoanBand {
|
||||
label: string;
|
||||
minTenure: number;
|
||||
defaultTenure: number;
|
||||
maxTenure: number;
|
||||
minAge: number;
|
||||
maxAgeAtMaturity?: number;
|
||||
/** Loan = this many times the net monthly salary (policy: personal loan 10x). */
|
||||
incomeMultiple?: number;
|
||||
/** The guarantors must themselves be members of the bank (policy: personal loan). */
|
||||
guarantorMustBeMember?: boolean;
|
||||
}
|
||||
|
||||
const LOAN_BANDS: Record<string, LoanBand> = {
|
||||
personal: {
|
||||
label: 'Personal Loan',
|
||||
minTenure: 6,
|
||||
defaultTenure: 36,
|
||||
maxTenure: 60,
|
||||
minAge: 21,
|
||||
maxAgeAtMaturity: 60,
|
||||
incomeMultiple: 10,
|
||||
guarantorMustBeMember: true
|
||||
},
|
||||
housing: {
|
||||
label: 'Housing Loan',
|
||||
minTenure: 12,
|
||||
defaultTenure: 84,
|
||||
maxTenure: 120,
|
||||
minAge: 21,
|
||||
maxAgeAtMaturity: 70
|
||||
},
|
||||
vehicle: { label: 'Vehicle Loan', minTenure: 12, defaultTenure: 60, maxTenure: 84, minAge: 21, maxAgeAtMaturity: 65 },
|
||||
education: { label: 'Education Loan', minTenure: 12, defaultTenure: 84, maxTenure: 84, minAge: 18 },
|
||||
business: {
|
||||
label: 'Business / MSME Loan',
|
||||
minTenure: 12,
|
||||
defaultTenure: 60,
|
||||
maxTenure: 120,
|
||||
minAge: 21,
|
||||
maxAgeAtMaturity: 65
|
||||
},
|
||||
gold_emi: {
|
||||
label: 'Gold EMI Loan',
|
||||
minTenure: 6,
|
||||
defaultTenure: 12,
|
||||
maxTenure: 36,
|
||||
minAge: 21,
|
||||
maxAgeAtMaturity: 70
|
||||
},
|
||||
term: { label: 'Term Loan', minTenure: 12, defaultTenure: 60, maxTenure: 120, minAge: 21, maxAgeAtMaturity: 65 }
|
||||
};
|
||||
|
||||
/**
|
||||
* Amount & Tenure step for a Loans A/C (EMI) product (step 4): fixed sanctioned amount, tenure,
|
||||
* borrower eligibility and disbursement. Amount, tenure and tranches feed Fineract's core product
|
||||
@@ -91,27 +33,6 @@ export class LoanProductLoanAmountTenureStepComponent {
|
||||
|
||||
loanProductLoanAmountTenureForm: UntypedFormGroup;
|
||||
|
||||
private _productSubType = '';
|
||||
|
||||
/** The sub type picked on the Details step: it sets the tenure band the three tenure fields must stay inside. */
|
||||
@Input()
|
||||
set productSubType(value: string) {
|
||||
const next = value || '';
|
||||
if (next === this._productSubType) {
|
||||
return;
|
||||
}
|
||||
this._productSubType = next;
|
||||
this.applyBand();
|
||||
}
|
||||
get productSubType(): string {
|
||||
return this._productSubType;
|
||||
}
|
||||
|
||||
/** The band in force for the chosen sub type, or null for a sub type without one. */
|
||||
get band(): LoanBand | null {
|
||||
return LOAN_BANDS[this._productSubType] ?? null;
|
||||
}
|
||||
|
||||
readonly disbursementModeOptions = [
|
||||
{ id: 'transfer_dd_vendor', label: 'Transfer to A/C, DD, Direct to vendor' },
|
||||
{ id: 'transfer', label: 'Transfer to A/C only' },
|
||||
@@ -164,13 +85,6 @@ export class LoanProductLoanAmountTenureStepComponent {
|
||||
minMonthlyIncome: [''],
|
||||
maxFoir: [50],
|
||||
maxLoansPerCustomer: [1],
|
||||
// Policy: loans go to members, so the applicant must be a member; a personal loan also needs member guarantors.
|
||||
memberRequired: [true],
|
||||
guarantorMustBeMember: [false],
|
||||
incomeMultiple: [
|
||||
'',
|
||||
Validators.min(0)
|
||||
],
|
||||
disbursementType: ['single'],
|
||||
maxTranches: [
|
||||
2,
|
||||
@@ -182,68 +96,6 @@ export class LoanProductLoanAmountTenureStepComponent {
|
||||
});
|
||||
}
|
||||
|
||||
/** Sets the tenure limits and the age defaults for the sub type; a value the officer has typed is left alone. */
|
||||
private applyBand(): void {
|
||||
const form = this.loanProductLoanAmountTenureForm;
|
||||
const band = this.band;
|
||||
if (!form || !band) {
|
||||
return;
|
||||
}
|
||||
const tenureControls: [string, number][] = [
|
||||
[
|
||||
'minTenure',
|
||||
band.minTenure
|
||||
],
|
||||
[
|
||||
'defaultTenure',
|
||||
band.defaultTenure
|
||||
],
|
||||
[
|
||||
'maxTenure',
|
||||
band.maxTenure
|
||||
]
|
||||
];
|
||||
const bounds = [
|
||||
Validators.min(band.minTenure),
|
||||
Validators.max(band.maxTenure)
|
||||
];
|
||||
tenureControls.forEach(
|
||||
([
|
||||
name,
|
||||
value
|
||||
]) => {
|
||||
const control = form.get(name);
|
||||
control.setValidators(
|
||||
name === 'defaultTenure' ? bounds : [
|
||||
Validators.required,
|
||||
...bounds
|
||||
]
|
||||
);
|
||||
if (!control.dirty) {
|
||||
control.setValue(value, { emitEvent: false });
|
||||
}
|
||||
control.updateValueAndValidity({ emitEvent: false });
|
||||
}
|
||||
);
|
||||
const incomeMultiple = form.get('incomeMultiple');
|
||||
if (!incomeMultiple.dirty) {
|
||||
incomeMultiple.setValue(band.incomeMultiple ?? '', { emitEvent: false });
|
||||
}
|
||||
const guarantorMember = form.get('guarantorMustBeMember');
|
||||
if (!guarantorMember.dirty) {
|
||||
guarantorMember.setValue(band.guarantorMustBeMember ?? false, { emitEvent: false });
|
||||
}
|
||||
const minAge = form.get('minAge');
|
||||
if (!minAge.dirty) {
|
||||
minAge.setValue(band.minAge, { emitEvent: false });
|
||||
}
|
||||
const maxAge = form.get('maxAgeAtMaturity');
|
||||
// A sub type without its own limit goes back to the general 70, not the previous sub type's value.
|
||||
if (!maxAge.dirty) {
|
||||
maxAge.setValue(band.maxAgeAtMaturity ?? 70, { emitEvent: false });
|
||||
}
|
||||
}
|
||||
|
||||
get isMultiTranche(): boolean {
|
||||
return this.loanProductLoanAmountTenureForm.get('disbursementType').value === 'multiple';
|
||||
}
|
||||
|
||||
+3
-10
@@ -63,16 +63,8 @@
|
||||
<div class="adv-grid">
|
||||
<mat-form-field appearance="outline" class="adv-field">
|
||||
<mat-label>Individual · floating rate (%)</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="number"
|
||||
formControlName="foreclosureFloatingIndividual"
|
||||
min="0"
|
||||
max="0"
|
||||
step="0.01"
|
||||
readonly
|
||||
/>
|
||||
<mat-hint>Nil as per RBI. Cannot be changed</mat-hint>
|
||||
<input matInput type="number" formControlName="foreclosureFloatingIndividual" min="0" max="100" step="0.01" />
|
||||
<mat-hint>Nil as per RBI</mat-hint>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="adv-field">
|
||||
<mat-label>Individual · fixed rate (%)</mat-label>
|
||||
@@ -140,6 +132,7 @@
|
||||
</div>
|
||||
<div class="adv-subsection">
|
||||
<div class="adv-checks">
|
||||
<mat-checkbox formControlName="releaseCollateralOnClosure">Release collateral and lien on closure</mat-checkbox>
|
||||
<mat-checkbox formControlName="topUpAllowed">Top-up loan on the same account</mat-checkbox>
|
||||
<mat-checkbox formControlName="balanceTransferOutAllowed">Allow balance transfer (takeover) out</mat-checkbox>
|
||||
</div>
|
||||
|
||||
+2
-5
@@ -77,13 +77,9 @@ export class LoanProductLoanPrepaymentStepComponent {
|
||||
allowedOn: ['emi_date'],
|
||||
partPaymentsPerYear: ['unlimited'],
|
||||
partPrepaymentMode: ['reduce_tenure'],
|
||||
// RBI: no foreclosure / pre-payment charge on a floating-rate loan to an individual, so this stays at nil.
|
||||
foreclosureFloatingIndividual: [
|
||||
0,
|
||||
[
|
||||
Validators.min(0),
|
||||
Validators.max(0)
|
||||
]
|
||||
percent
|
||||
],
|
||||
foreclosureFixedIndividual: [
|
||||
2,
|
||||
@@ -109,6 +105,7 @@ export class LoanProductLoanPrepaymentStepComponent {
|
||||
autoCloseOnZeroBalance: ['YES'],
|
||||
nocLetter: ['auto'],
|
||||
restructuring: ['approval'],
|
||||
releaseCollateralOnClosure: [true],
|
||||
topUpAllowed: [false],
|
||||
balanceTransferOutAllowed: [true]
|
||||
});
|
||||
|
||||
+13
-19
@@ -49,7 +49,6 @@
|
||||
<mat-form-field appearance="outline" class="adv-field">
|
||||
<mat-label>Minimum Days to First Repayment</mat-label>
|
||||
<input matInput type="number" formControlName="minDaysToFirstRepayment" min="0" />
|
||||
<mat-hint>Policy: instalments start from the 2nd month after the loan month</mat-hint>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="adv-field">
|
||||
<mat-label>EMI Due Day</mat-label>
|
||||
@@ -70,7 +69,6 @@
|
||||
<mat-form-field appearance="outline" class="adv-field">
|
||||
<mat-label>Moratorium / Grace (months)</mat-label>
|
||||
<input matInput type="number" formControlName="moratoriumMonths" min="0" />
|
||||
<mat-hint>Interest and EMI fall due only after the moratorium ends (RBI)</mat-hint>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="adv-field">
|
||||
<mat-label>Interest during Moratorium</mat-label>
|
||||
@@ -100,16 +98,14 @@
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
@if (isAutoDebit) {
|
||||
<mat-form-field appearance="outline" class="adv-field">
|
||||
<mat-label>Linked Account Type</mat-label>
|
||||
<mat-select formControlName="linkedAccountType">
|
||||
@for (opt of linkedAccountOptions; track opt.id) {
|
||||
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
}
|
||||
<mat-form-field appearance="outline" class="adv-field">
|
||||
<mat-label>Linked Account Type</mat-label>
|
||||
<mat-select formControlName="linkedAccountType">
|
||||
@for (opt of linkedAccountOptions; track opt.id) {
|
||||
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="adv-field">
|
||||
<mat-label>PDC Collection</mat-label>
|
||||
<mat-select formControlName="pdcCollection">
|
||||
@@ -119,13 +115,11 @@
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
@if (isAutoDebit) {
|
||||
<div class="adv-subsection">
|
||||
<mat-checkbox formControlName="mandateRequired">
|
||||
Mandate registration is required before the first disbursement
|
||||
</mat-checkbox>
|
||||
</div>
|
||||
}
|
||||
<div class="adv-subsection">
|
||||
<mat-checkbox formControlName="mandateRequired">
|
||||
Mandate registration is required before the first disbursement
|
||||
</mat-checkbox>
|
||||
</div>
|
||||
<div style="padding: 0 24px 20px">
|
||||
<div class="adv-info">
|
||||
Cheques are used here to repay the EMI as post-dated cheques, not to withdraw from the loan. A cheque book is
|
||||
|
||||
+9
-45
@@ -6,7 +6,7 @@
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms';
|
||||
import { MatCheckbox } from '@angular/material/checkbox';
|
||||
import { MatRadioButton, MatRadioGroup } from '@angular/material/radio';
|
||||
@@ -28,17 +28,17 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
MatRadioButton
|
||||
]
|
||||
})
|
||||
export class LoanProductLoanRepaymentStepComponent implements OnInit {
|
||||
export class LoanProductLoanRepaymentStepComponent {
|
||||
private formBuilder = inject(UntypedFormBuilder);
|
||||
|
||||
loanProductLoanRepaymentForm: UntypedFormGroup;
|
||||
|
||||
// Bullet / Step-up / Balloon live on the Advances A/C "Limit & Tenure" step instead — a Loans A/C
|
||||
// (EMI) product always repays on a fixed instalment schedule, so only these three belong here.
|
||||
readonly repaymentTypeOptions = [
|
||||
{ id: 'emi', label: 'EMI', hint: 'Reducing balance, equal instalments' },
|
||||
{ id: 'equal_principal', label: 'Equal principal', hint: 'Falling instalments' },
|
||||
{ id: 'flat', label: 'Flat rate', hint: 'Interest on original amount' }
|
||||
{ id: 'flat', label: 'Flat rate', hint: 'Interest on original amount' },
|
||||
{ id: 'bullet', label: 'Bullet', hint: 'Pay everything at maturity' },
|
||||
{ id: 'step', label: 'Step-up / Balloon', hint: 'Custom schedule' }
|
||||
];
|
||||
|
||||
/** `fineractId` is Fineract's repaymentFrequencyType (0 days, 1 weeks, 2 months). */
|
||||
@@ -50,7 +50,7 @@ export class LoanProductLoanRepaymentStepComponent implements OnInit {
|
||||
|
||||
readonly emiDueDayOptions = [
|
||||
{ id: 'disbursement_day', label: 'Same day as disbursement' },
|
||||
{ id: 'fixed_day', label: 'Fixed day of the month (day set on each account)' }
|
||||
{ id: 'fixed_day', label: 'Fixed day of month (5th)' }
|
||||
];
|
||||
|
||||
readonly holidayRuleOptions = [
|
||||
@@ -114,52 +114,16 @@ export class LoanProductLoanRepaymentStepComponent implements OnInit {
|
||||
],
|
||||
moratoriumInterest: ['pre_emi'],
|
||||
variableInstallments: [false],
|
||||
// Loan policy: cheques for the instalments are taken together in advance (quarterly), so cheques are the default.
|
||||
repaymentMode: ['pdc'],
|
||||
repaymentMode: ['nach'],
|
||||
linkedAccountType: ['savings_current'],
|
||||
pdcCollection: ['mandatory'],
|
||||
mandateRequired: [false],
|
||||
mandateRequired: [true],
|
||||
appropriationOrder: ['charges_penal_interest_principal'],
|
||||
advanceEmi: ['adjust_next']
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
// The mode decides how the EMI is collected, so it sets the cheque and mandate defaults that go with it.
|
||||
this.loanProductLoanRepaymentForm
|
||||
.get('repaymentMode')
|
||||
.valueChanges.subscribe((mode: string) => this.applyMode(mode));
|
||||
}
|
||||
|
||||
private applyMode(mode: string): void {
|
||||
const defaults: Record<string, { pdcCollection: string; mandateRequired: boolean }> = {
|
||||
nach: { pdcCollection: 'optional', mandateRequired: true },
|
||||
pdc: { pdcCollection: 'mandatory', mandateRequired: false },
|
||||
cash: { pdcCollection: 'none', mandateRequired: false }
|
||||
};
|
||||
const fit = defaults[mode];
|
||||
if (!fit) {
|
||||
return;
|
||||
}
|
||||
(Object.keys(fit) as (keyof typeof fit)[]).forEach((name) => {
|
||||
const control = this.loanProductLoanRepaymentForm.get(name);
|
||||
if (control && !control.dirty) {
|
||||
control.setValue(fit[name], { emitEvent: false });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Auto-debit needs a linked account and a registered mandate; the other modes do not. */
|
||||
get isAutoDebit(): boolean {
|
||||
return this.loanProductLoanRepaymentForm.get('repaymentMode').value === 'nach';
|
||||
}
|
||||
|
||||
get loanProductLoanRepayment() {
|
||||
const value = this.loanProductLoanRepaymentForm.getRawValue();
|
||||
// A mandate only exists for auto-debit; never save it as required for cheques or cash.
|
||||
if (!this.isAutoDebit) {
|
||||
value.mandateRequired = false;
|
||||
}
|
||||
return value;
|
||||
return this.loanProductLoanRepaymentForm.getRawValue();
|
||||
}
|
||||
}
|
||||
|
||||
+54
-22
@@ -28,9 +28,6 @@
|
||||
<mat-form-field appearance="outline" class="adv-field">
|
||||
<mat-label>Margin (%)</mat-label>
|
||||
<input matInput type="number" formControlName="margin" min="0" max="100" step="0.01" required />
|
||||
@if (marginHint) {
|
||||
<mat-hint>{{ marginHint }}</mat-hint>
|
||||
}
|
||||
<mat-error>Margin must be between <strong>0 and 100</strong></mat-error>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="adv-field">
|
||||
@@ -41,6 +38,40 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="adv-section">
|
||||
<div class="adv-section__header">
|
||||
<i class="fas fa-table"></i>
|
||||
<h3>Collateral Rules by Security Type</h3>
|
||||
<span class="adv-section__hint">Which security this product may accept</span>
|
||||
</div>
|
||||
<table class="adv-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Security</th>
|
||||
<th>Allowed</th>
|
||||
<th>Max LTV %</th>
|
||||
<th>Valuation</th>
|
||||
<th>Insurance</th>
|
||||
<th>Charge / lien</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (rule of collateralRules; track rule.security) {
|
||||
<tr>
|
||||
<td>{{ rule.security }}</td>
|
||||
<td [class.adv-table__yes]="rule.allowed" [class.adv-table__no]="!rule.allowed">
|
||||
{{ rule.allowed ? 'Yes' : 'No' }}
|
||||
</td>
|
||||
<td [class.adv-table__no]="!rule.allowed">{{ rule.ltv }}</td>
|
||||
<td [class.adv-table__no]="!rule.allowed">{{ rule.valuation }}</td>
|
||||
<td [class.adv-table__no]="!rule.allowed">{{ rule.insurance }}</td>
|
||||
<td [class.adv-table__no]="!rule.allowed">{{ rule.charge }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="adv-section">
|
||||
<div class="adv-section__header">
|
||||
<i class="fas fa-user-plus"></i>
|
||||
@@ -59,16 +90,14 @@
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
@if (isSecured) {
|
||||
<mat-form-field appearance="outline" class="adv-field">
|
||||
<mat-label>Third-party Security</mat-label>
|
||||
<mat-select formControlName="thirdPartySecurity">
|
||||
@for (opt of thirdPartyOptions; track opt.id) {
|
||||
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
}
|
||||
<mat-form-field appearance="outline" class="adv-field">
|
||||
<mat-label>Third-party Security</mat-label>
|
||||
<mat-select formControlName="thirdPartySecurity">
|
||||
@for (opt of thirdPartyOptions; track opt.id) {
|
||||
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -78,15 +107,18 @@
|
||||
<h3>Conditions</h3>
|
||||
</div>
|
||||
<div class="adv-subsection">
|
||||
@if (shownConditions.length > 0) {
|
||||
<div class="adv-checks">
|
||||
@for (rule of shownConditions; track rule.control) {
|
||||
<mat-checkbox [formControlName]="rule.control">{{ rule.label }}</mat-checkbox>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="adv-info">This loan is unsecured, so it carries no security conditions.</div>
|
||||
}
|
||||
<div class="adv-checks">
|
||||
<mat-checkbox formControlName="legalOpinionRequired">
|
||||
Legal opinion / title search mandatory before sanction
|
||||
</mat-checkbox>
|
||||
<mat-checkbox formControlName="propertyInsuranceRequired">
|
||||
Property insurance mandatory for the whole tenure
|
||||
</mat-checkbox>
|
||||
<mat-checkbox formControlName="registerCharge">Register charge (CERSAI) before disbursement</mat-checkbox>
|
||||
<mat-checkbox formControlName="autoReleaseOnClosure">Release security automatically on closure</mat-checkbox>
|
||||
<mat-checkbox formControlName="allowMultipleSecurities">Allow multiple securities for one loan</mat-checkbox>
|
||||
<mat-checkbox formControlName="allowThirdPartyDeposit">Allow third-party deposit as security</mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding: 0 24px 20px">
|
||||
<div class="adv-info">
|
||||
|
||||
+43
-232
@@ -6,157 +6,16 @@
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, Input, OnInit, inject } from '@angular/core';
|
||||
import { UntypedFormGroup, UntypedFormBuilder, UntypedFormControl, Validators } from '@angular/forms';
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms';
|
||||
import { MatCheckbox } from '@angular/material/checkbox';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
interface SecurityProfile {
|
||||
securityType: string;
|
||||
securityMandatory: 'yes' | 'no';
|
||||
margin: number;
|
||||
guarantorsRequired: number;
|
||||
coApplicant: string;
|
||||
thirdPartySecurity: string;
|
||||
}
|
||||
|
||||
/** One condition a sub type's security carries: the checkbox it controls, its wording, and whether it starts ticked. */
|
||||
interface ConditionRule {
|
||||
control: string;
|
||||
label: string;
|
||||
defaultOn: boolean;
|
||||
}
|
||||
|
||||
/** Every condition checkbox the form holds. One not listed for a sub type does not apply to it and is saved as off. */
|
||||
const CONDITION_CONTROLS = [
|
||||
'legalOpinionRequired',
|
||||
'propertyInsuranceRequired',
|
||||
'registerCharge',
|
||||
'autoReleaseOnClosure',
|
||||
'allowMultipleSecurities',
|
||||
'allowThirdPartyDeposit'
|
||||
];
|
||||
|
||||
/** Immovable property (Housing, Business, Term): title check, property bima, CERSAI charge. */
|
||||
const PROPERTY_CONDITIONS: ConditionRule[] = [
|
||||
{ control: 'legalOpinionRequired', label: 'Legal opinion / title search mandatory before sanction', defaultOn: true },
|
||||
{ control: 'propertyInsuranceRequired', label: 'Property insurance mandatory for the whole tenure', defaultOn: true },
|
||||
{ control: 'registerCharge', label: 'Register charge (CERSAI) before disbursement', defaultOn: true },
|
||||
{ control: 'autoReleaseOnClosure', label: 'Release the mortgage automatically on closure', defaultOn: true },
|
||||
{ control: 'allowMultipleSecurities', label: 'Allow multiple securities for one loan', defaultOn: false }
|
||||
];
|
||||
|
||||
/** Vehicle: the vehicle is hypothecated, insured, and the lien is noted on the RC book. */
|
||||
const VEHICLE_CONDITIONS: ConditionRule[] = [
|
||||
{ control: 'propertyInsuranceRequired', label: 'Vehicle insurance mandatory for the whole tenure', defaultOn: true },
|
||||
{ control: 'registerCharge', label: 'Note the hypothecation on the RC book before disbursement', defaultOn: true },
|
||||
{ control: 'autoReleaseOnClosure', label: 'Release the hypothecation automatically on closure', defaultOn: true }
|
||||
];
|
||||
|
||||
/** Gold EMI: the gold is pledged and insured, and handed back on closure. No registered charge. */
|
||||
const GOLD_CONDITIONS: ConditionRule[] = [
|
||||
{ control: 'propertyInsuranceRequired', label: 'Gold insurance mandatory for the whole tenure', defaultOn: true },
|
||||
{ control: 'autoReleaseOnClosure', label: 'Return the gold automatically on closure', defaultOn: true }
|
||||
];
|
||||
|
||||
/** Wording used when a sub type has no list of its own but the officer made security mandatory. */
|
||||
const GENERIC_CONDITIONS: ConditionRule[] = [
|
||||
{
|
||||
control: 'legalOpinionRequired',
|
||||
label: 'Legal opinion / title search mandatory before sanction',
|
||||
defaultOn: false
|
||||
},
|
||||
{
|
||||
control: 'propertyInsuranceRequired',
|
||||
label: 'Security insurance mandatory for the whole tenure',
|
||||
defaultOn: false
|
||||
},
|
||||
{ control: 'registerCharge', label: 'Register the charge on the security before disbursement', defaultOn: false },
|
||||
{ control: 'autoReleaseOnClosure', label: 'Release the security automatically on closure', defaultOn: false },
|
||||
{ control: 'allowMultipleSecurities', label: 'Allow multiple securities for one loan', defaultOn: false },
|
||||
{ control: 'allowThirdPartyDeposit', label: 'Allow third-party deposit as security', defaultOn: false }
|
||||
];
|
||||
|
||||
/** Personal and Education are unsecured, so they carry no security conditions unless security is made mandatory. */
|
||||
const CONDITIONS_BY_SUB_TYPE: Record<string, ConditionRule[]> = {
|
||||
housing: PROPERTY_CONDITIONS,
|
||||
business: PROPERTY_CONDITIONS,
|
||||
term: PROPERTY_CONDITIONS,
|
||||
vehicle: VEHICLE_CONDITIONS,
|
||||
gold_emi: GOLD_CONDITIONS,
|
||||
personal: [],
|
||||
education: PROPERTY_CONDITIONS
|
||||
};
|
||||
|
||||
/** Sensible defaults by Loans A/C sub type — the officer can still override every field. */
|
||||
const SECURITY_PROFILES: Record<string, SecurityProfile> = {
|
||||
// Policy: a personal loan takes two guarantors who are bank members.
|
||||
personal: {
|
||||
securityType: 'None (unsecured)',
|
||||
securityMandatory: 'no',
|
||||
margin: 0,
|
||||
guarantorsRequired: 2,
|
||||
coApplicant: 'optional',
|
||||
thirdPartySecurity: 'no'
|
||||
},
|
||||
// Policy: 90% of the expense is lent (the student bears 10%), against a house / plot / NSC worth double the loan,
|
||||
// with the parent or guardian applying jointly.
|
||||
education: {
|
||||
securityType: 'Property / NSC (double the loan value)',
|
||||
securityMandatory: 'yes',
|
||||
margin: 10,
|
||||
guarantorsRequired: 1,
|
||||
coApplicant: 'always',
|
||||
thirdPartySecurity: 'consent'
|
||||
},
|
||||
housing: {
|
||||
securityType: 'Immovable property (mortgage)',
|
||||
securityMandatory: 'yes',
|
||||
margin: 25,
|
||||
guarantorsRequired: 1,
|
||||
coApplicant: 'joint_property',
|
||||
thirdPartySecurity: 'consent'
|
||||
},
|
||||
vehicle: {
|
||||
securityType: 'Vehicle (hypothecation)',
|
||||
securityMandatory: 'yes',
|
||||
margin: 20,
|
||||
guarantorsRequired: 0,
|
||||
coApplicant: 'optional',
|
||||
thirdPartySecurity: 'no'
|
||||
},
|
||||
business: {
|
||||
securityType: 'Immovable property / business assets',
|
||||
securityMandatory: 'yes',
|
||||
margin: 25,
|
||||
guarantorsRequired: 1,
|
||||
coApplicant: 'joint_property',
|
||||
thirdPartySecurity: 'consent'
|
||||
},
|
||||
gold_emi: {
|
||||
securityType: 'Gold ornaments (pledge)',
|
||||
securityMandatory: 'yes',
|
||||
margin: 25,
|
||||
guarantorsRequired: 0,
|
||||
coApplicant: 'optional',
|
||||
thirdPartySecurity: 'no'
|
||||
},
|
||||
term: {
|
||||
securityType: 'Immovable property (mortgage)',
|
||||
securityMandatory: 'yes',
|
||||
margin: 20,
|
||||
guarantorsRequired: 1,
|
||||
coApplicant: 'joint_property',
|
||||
thirdPartySecurity: 'consent'
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Security & Collateral step for a Loans A/C (EMI) product (step 3).
|
||||
*
|
||||
* Unlike an Advance, the security only decides eligibility at sanction — it does not set a running
|
||||
* limit. Defaults adapt to the chosen sub type (e.g. Personal/Education default to unsecured,
|
||||
* Housing/Vehicle/Gold default to their usual collateral) but every field stays editable.
|
||||
* limit. UI-only for now: not sent in the create payload.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-loan-product-loan-security-step',
|
||||
@@ -167,46 +26,11 @@ const SECURITY_PROFILES: Record<string, SecurityProfile> = {
|
||||
MatCheckbox
|
||||
]
|
||||
})
|
||||
export class LoanProductLoanSecurityStepComponent implements OnInit {
|
||||
export class LoanProductLoanSecurityStepComponent {
|
||||
private formBuilder = inject(UntypedFormBuilder);
|
||||
|
||||
@Input() productTypeSubGroup: UntypedFormControl;
|
||||
|
||||
loanProductLoanSecurityForm: UntypedFormGroup;
|
||||
|
||||
/** What the bank's loan policy says about the margin for a sub type, shown under the Margin field. */
|
||||
private readonly marginHints: Record<string, string> = {
|
||||
housing: 'Policy: house construction 25%, flat / house purchase 20%, repair or renovation 10%',
|
||||
vehicle: 'Policy: new vehicle 20–25%, old vehicle 50%',
|
||||
education: 'Policy: the student bears 10% of the expense',
|
||||
business: 'Policy: machinery 25% (old machinery 40–50%)',
|
||||
personal: 'Unsecured: no margin'
|
||||
};
|
||||
|
||||
get marginHint(): string {
|
||||
return this.marginHints[this.productTypeSubGroup?.value] ?? '';
|
||||
}
|
||||
|
||||
/** The conditions that apply to the chosen sub type; the template draws one checkbox for each. */
|
||||
applicableConditions: ConditionRule[] = [];
|
||||
|
||||
/**
|
||||
* The conditions drawn on screen: nothing while the loan is unsecured; the sub type's own list when it has one; and
|
||||
* every condition (all off) when the officer makes security mandatory on a sub type that has no list of its own,
|
||||
* so the officer can always set them.
|
||||
*/
|
||||
get shownConditions(): ConditionRule[] {
|
||||
if (!this.isSecured) {
|
||||
return [];
|
||||
}
|
||||
return this.applicableConditions.length > 0 ? this.applicableConditions : GENERIC_CONDITIONS;
|
||||
}
|
||||
|
||||
/** True when sanction needs security; an unsecured product shows no security conditions. */
|
||||
get isSecured(): boolean {
|
||||
return this.loanProductLoanSecurityForm.get('securityMandatory').value === 'yes';
|
||||
}
|
||||
|
||||
readonly securityMandatoryOptions = [
|
||||
{ id: 'yes', label: 'Yes — no sanction without security' },
|
||||
{ id: 'no', label: 'No — unsecured' }
|
||||
@@ -223,6 +47,44 @@ export class LoanProductLoanSecurityStepComponent implements OnInit {
|
||||
{ id: 'no', label: 'Not allowed' }
|
||||
];
|
||||
|
||||
/** Reference table: which security a loan on this product may accept. */
|
||||
readonly collateralRules = [
|
||||
{
|
||||
security: 'Residential property',
|
||||
allowed: true,
|
||||
ltv: '80',
|
||||
valuation: 'Empanelled valuer',
|
||||
insurance: 'Mandatory',
|
||||
charge: 'Mortgage + CERSAI'
|
||||
},
|
||||
{
|
||||
security: 'Commercial property',
|
||||
allowed: true,
|
||||
ltv: '70',
|
||||
valuation: 'Empanelled valuer',
|
||||
insurance: 'Mandatory',
|
||||
charge: 'Mortgage + CERSAI'
|
||||
},
|
||||
{
|
||||
security: 'Fixed / Recurring Deposit',
|
||||
allowed: true,
|
||||
ltv: '90',
|
||||
valuation: 'Not needed',
|
||||
insurance: 'Not needed',
|
||||
charge: 'Lien marked'
|
||||
},
|
||||
{
|
||||
security: 'LIC policy',
|
||||
allowed: true,
|
||||
ltv: '90',
|
||||
valuation: 'Surrender value',
|
||||
insurance: 'Not needed',
|
||||
charge: 'Assignment'
|
||||
},
|
||||
{ security: 'Vehicle', allowed: false, ltv: '—', valuation: '—', insurance: '—', charge: '—' },
|
||||
{ security: 'Gold ornaments', allowed: false, ltv: '—', valuation: '—', insurance: '—', charge: '—' }
|
||||
];
|
||||
|
||||
constructor() {
|
||||
this.loanProductLoanSecurityForm = this.formBuilder.group({
|
||||
securityType: ['Immovable property (mortgage)'],
|
||||
@@ -250,49 +112,6 @@ export class LoanProductLoanSecurityStepComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.applySubGroup(this.productTypeSubGroup?.value);
|
||||
// Keep the security defaults in step with the sub type chosen on the Details step.
|
||||
this.productTypeSubGroup?.valueChanges.subscribe((subGroup: string) => this.applySubGroup(subGroup));
|
||||
}
|
||||
|
||||
private applySubGroup(subGroup: string): void {
|
||||
const profile = SECURITY_PROFILES[subGroup];
|
||||
if (!profile) {
|
||||
return;
|
||||
}
|
||||
this.loanProductLoanSecurityForm.get('securityType').setValue(profile.securityType, { emitEvent: false });
|
||||
this.loanProductLoanSecurityForm.get('securityMandatory').setValue(profile.securityMandatory, { emitEvent: false });
|
||||
if (!this.loanProductLoanSecurityForm.get('margin').dirty) {
|
||||
this.loanProductLoanSecurityForm.get('margin').setValue(profile.margin, { emitEvent: false });
|
||||
}
|
||||
if (!this.loanProductLoanSecurityForm.get('guarantorsRequired').dirty) {
|
||||
this.loanProductLoanSecurityForm
|
||||
.get('guarantorsRequired')
|
||||
.setValue(profile.guarantorsRequired, { emitEvent: false });
|
||||
}
|
||||
([
|
||||
'coApplicant',
|
||||
'thirdPartySecurity'
|
||||
] as const).forEach((name) => {
|
||||
const control = this.loanProductLoanSecurityForm.get(name);
|
||||
if (!control.dirty) {
|
||||
control.setValue(profile[name], { emitEvent: false });
|
||||
}
|
||||
});
|
||||
|
||||
// Only the conditions that fit this kind of security apply; the rest are off and not shown.
|
||||
const rules = CONDITIONS_BY_SUB_TYPE[subGroup] ?? [];
|
||||
this.applicableConditions = rules;
|
||||
CONDITION_CONTROLS.forEach((name) => {
|
||||
const control = this.loanProductLoanSecurityForm.get(name);
|
||||
if (!control.dirty) {
|
||||
const rule = rules.find((entry) => entry.control === name);
|
||||
control.setValue(rule ? rule.defaultOn : false, { emitEvent: false });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Maximum LTV is derived from the margin (100 − margin) and shown read-only. */
|
||||
get ltv(): number {
|
||||
const margin = Number(this.loanProductLoanSecurityForm.get('margin').value);
|
||||
@@ -300,16 +119,8 @@ export class LoanProductLoanSecurityStepComponent implements OnInit {
|
||||
}
|
||||
|
||||
get loanProductLoanSecurity() {
|
||||
const value = this.loanProductLoanSecurityForm.getRawValue();
|
||||
// A condition that is not shown (unsecured, or not part of this security) is never saved as on.
|
||||
const shown = new Set(this.shownConditions.map((rule) => rule.control));
|
||||
CONDITION_CONTROLS.forEach((name) => {
|
||||
if (!shown.has(name)) {
|
||||
value[name] = false;
|
||||
}
|
||||
});
|
||||
return {
|
||||
...value,
|
||||
...this.loanProductLoanSecurityForm.getRawValue(),
|
||||
ltv: this.ltv
|
||||
};
|
||||
}
|
||||
|
||||
+25
-73
@@ -7,14 +7,13 @@
|
||||
-->
|
||||
|
||||
<form [formGroup]="loanProductPenalNpaForm">
|
||||
<!-- 1. PENAL -->
|
||||
<div class="adv-section pn-card pn-card--penal">
|
||||
<div class="adv-section">
|
||||
<div class="adv-section__header">
|
||||
<i class="fas fa-percent"></i>
|
||||
<h3>Penal Charges</h3>
|
||||
</div>
|
||||
<div class="adv-grid adv-grid--3">
|
||||
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic">
|
||||
<mat-form-field appearance="outline" class="adv-field">
|
||||
<mat-label>Penal Charges Applicable</mat-label>
|
||||
<mat-select formControlName="penalApplicable">
|
||||
@for (opt of yesNoOptions; track opt) {
|
||||
@@ -22,11 +21,11 @@
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic">
|
||||
<mat-form-field appearance="outline" class="adv-field">
|
||||
<mat-label>Penal Rate (% p.a.)</mat-label>
|
||||
<input matInput type="number" formControlName="penalRate" min="0" max="100" step="0.01" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic">
|
||||
<mat-form-field appearance="outline" class="adv-field">
|
||||
<mat-label>Applied On</mat-label>
|
||||
<mat-select formControlName="penalAppliedOn">
|
||||
@for (opt of appliedOnOptions; track opt.id) {
|
||||
@@ -34,7 +33,7 @@
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic">
|
||||
<mat-form-field appearance="outline" class="adv-field">
|
||||
<mat-label>Penal Grace Days</mat-label>
|
||||
<input matInput type="number" formControlName="penalGraceDays" min="0" />
|
||||
</mat-form-field>
|
||||
@@ -46,14 +45,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. SMA -->
|
||||
<div class="adv-section pn-card pn-card--sma">
|
||||
<div class="adv-section">
|
||||
<div class="adv-section__header">
|
||||
<i class="fas fa-bell"></i>
|
||||
<h3>SMA (Special Mention Account)</h3>
|
||||
<i class="fas fa-triangle-exclamation"></i>
|
||||
<h3>Asset Classification (SMA / NPA)</h3>
|
||||
</div>
|
||||
<div class="adv-grid adv-grid--3">
|
||||
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic">
|
||||
<mat-form-field appearance="outline" class="adv-field">
|
||||
<mat-label>Delinquency Bucket (SMA)</mat-label>
|
||||
<mat-select formControlName="delinquencyBucketId">
|
||||
<mat-option [value]="''">— None —</mat-option>
|
||||
@@ -63,38 +61,11 @@
|
||||
</mat-select>
|
||||
<mat-hint>SMA 0–30 / 31–60 / 61–90</mat-hint>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic">
|
||||
<mat-label>SMA Counting Starts After (months)</mat-label>
|
||||
<input matInput type="number" formControlName="smaStartAfterMonths" min="0" required />
|
||||
<mat-hint>
|
||||
{{ isAdvanceAccount ? 'Advances: from disbursement (default 9)' : 'Loans: first EMI due (default 1)' }}
|
||||
</mat-hint>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic">
|
||||
<mat-label>In-Arrears Tolerance (₹)</mat-label>
|
||||
<input matInput type="number" formControlName="inArrearsTolerance" min="0" />
|
||||
<mat-hint>Small balance ignored for arrears</mat-hint>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="pn-stages">
|
||||
<span class="pn-stage pn-stage--0">SMA-0 · 1–30 days</span>
|
||||
<span class="pn-stage pn-stage--1">SMA-1 · 31–60 days</span>
|
||||
<span class="pn-stage pn-stage--2">SMA-2 · 61–90 days</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. NPA -->
|
||||
<div class="adv-section pn-card pn-card--npa">
|
||||
<div class="adv-section__header">
|
||||
<i class="fas fa-triangle-exclamation"></i>
|
||||
<h3>NPA (Non-Performing Asset)</h3>
|
||||
</div>
|
||||
<div class="adv-grid adv-grid--3">
|
||||
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic">
|
||||
<mat-form-field appearance="outline" class="adv-field">
|
||||
<mat-label>NPA After (days overdue)</mat-label>
|
||||
<input matInput type="number" formControlName="npaDays" min="0" required />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic">
|
||||
<mat-form-field appearance="outline" class="adv-field">
|
||||
<mat-label>NPA Trigger</mat-label>
|
||||
<mat-select formControlName="npaTrigger">
|
||||
@for (opt of npaTriggerOptions; track opt.id) {
|
||||
@@ -102,7 +73,7 @@
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic">
|
||||
<mat-form-field appearance="outline" class="adv-field">
|
||||
<mat-label>Interest after NPA</mat-label>
|
||||
<mat-select formControlName="interestAfterNpa">
|
||||
@for (opt of interestAfterNpaOptions; track opt.id) {
|
||||
@@ -110,45 +81,26 @@
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
@if (isRevolvingSubType) {
|
||||
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic">
|
||||
<mat-label>NPA Out-of-Order Basis (CC/OD)</mat-label>
|
||||
<mat-select formControlName="npaOutOfOrderBasis">
|
||||
@for (opt of npaOutOfOrderBasisOptions; track opt.id) {
|
||||
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic">
|
||||
<mat-label>Stock Statement Validity (days)</mat-label>
|
||||
<input matInput type="number" formControlName="stockStatementValidityDays" min="0" />
|
||||
<mat-hint>Older statement + drawings 90 days = NPA</mat-hint>
|
||||
</mat-form-field>
|
||||
}
|
||||
@if (isKccSubType) {
|
||||
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic">
|
||||
<mat-label>Crop Duration (KCC)</mat-label>
|
||||
<mat-select formControlName="cropDuration">
|
||||
@for (opt of cropDurationOptions; track opt.id) {
|
||||
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
}
|
||||
<mat-form-field appearance="outline" class="adv-field">
|
||||
<mat-label>Grace on Arrears Ageing (days)</mat-label>
|
||||
<input matInput type="number" formControlName="graceOnArrearsAgeing" min="0" />
|
||||
<mat-hint>Days before an amount counts as overdue</mat-hint>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="adv-field">
|
||||
<mat-label>In-Arrears Tolerance (₹)</mat-label>
|
||||
<input matInput type="number" formControlName="inArrearsTolerance" min="0" />
|
||||
<mat-hint>Small balance ignored for arrears</mat-hint>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="adv-subsection">
|
||||
<div class="adv-checks">
|
||||
<mat-checkbox formControlName="upgradeOnlyAllArrears">
|
||||
Upgrade from NPA only when all arrears are cleared
|
||||
</mat-checkbox>
|
||||
@if (isDepositBacked) {
|
||||
<mat-checkbox formControlName="keepStandardIfDepositCovers">
|
||||
Keep standard while the pledged deposit fully covers the dues
|
||||
</mat-checkbox>
|
||||
}
|
||||
<mat-checkbox formControlName="keepStandardIfDepositCovers">
|
||||
Keep standard while the pledged deposit fully covers the dues
|
||||
</mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 4. ECL (SICR & LGD): hidden for now. The form controls stay in the .ts so the card can be restored. -->
|
||||
</form>
|
||||
|
||||
+8
-188
@@ -6,16 +6,14 @@
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, Input, OnInit, inject } from '@angular/core';
|
||||
import { UntypedFormGroup, UntypedFormBuilder, UntypedFormControl, Validators } from '@angular/forms';
|
||||
import { Component, Input, inject } from '@angular/core';
|
||||
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatCheckbox } from '@angular/material/checkbox';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
/**
|
||||
* Penal charges & asset-classification (SMA / NPA / SICR / LGD) step for a loan product (Figma step 6).
|
||||
* Shared by both Advances A/C and Loans A/C flows — fields are sent in the create/update payload via
|
||||
* `loanProductPenalNpa`, mapped onto the core product fields and the LoanProductAdvanceExt satellite params
|
||||
* in create-loan-product.component.ts.
|
||||
* Penal charges & asset-classification (SMA / NPA) step for an Advances A/C product (Figma step 6).
|
||||
* UI-only for now — not sent in the create payload.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-loan-product-penal-npa-step',
|
||||
@@ -26,40 +24,13 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
MatCheckbox
|
||||
]
|
||||
})
|
||||
export class LoanProductPenalNpaStepComponent implements OnInit {
|
||||
export class LoanProductPenalNpaStepComponent {
|
||||
private formBuilder = inject(UntypedFormBuilder);
|
||||
|
||||
@Input() loanProductsTemplate: any;
|
||||
@Input() productTypeGroup: UntypedFormControl;
|
||||
@Input() productTypeSubGroup: UntypedFormControl;
|
||||
|
||||
loanProductPenalNpaForm: UntypedFormGroup;
|
||||
|
||||
/** True for Advances A/C (cc/overdraft/kcc style limit products); drives which SICR/LGD fields show. */
|
||||
isAdvanceAccount = false;
|
||||
/** The ECL card is optional and starts collapsed. */
|
||||
eclOpen = false;
|
||||
|
||||
/** One line for the collapsed ECL card. */
|
||||
get eclSummary(): string {
|
||||
const v = this.loanProductPenalNpaForm.getRawValue();
|
||||
const days = this.isRevolvingSubType ? v.sicrRevolvingDaysThreshold : v.sicrDaysThreshold;
|
||||
return `Stage 2 after ${days} days late · loss ${v.lgdSecuredPct}% with security, ${v.lgdUnsecuredPct}% without`;
|
||||
}
|
||||
|
||||
/** The chosen sub type (e.g. 'cc', 'overdraft', 'kcc', 'gold') within the current product type group. */
|
||||
subType: string | null = null;
|
||||
|
||||
/** FD / RD / DDS / LIC are lent against a deposit or policy; Gold, CC and the rest are pledged or hypothecated. */
|
||||
get isDepositBacked(): boolean {
|
||||
return [
|
||||
'fd',
|
||||
'rd',
|
||||
'dds',
|
||||
'lic'
|
||||
].includes(this.subType ?? '');
|
||||
}
|
||||
|
||||
readonly yesNoOptions = [
|
||||
'YES',
|
||||
'NO'
|
||||
@@ -72,8 +43,7 @@ export class LoanProductPenalNpaStepComponent implements OnInit {
|
||||
|
||||
readonly npaTriggerOptions = [
|
||||
{ id: 'maturity', label: 'Dues overdue after maturity' },
|
||||
{ id: 'out_of_order', label: 'Out of order 90 days (CC/OD)' },
|
||||
{ id: 'crop_season', label: 'Overdue for crop season(s) (KCC)' }
|
||||
{ id: 'out_of_order', label: 'Out of order 90 days (CC/OD)' }
|
||||
];
|
||||
|
||||
readonly interestAfterNpaOptions = [
|
||||
@@ -81,48 +51,10 @@ export class LoanProductPenalNpaStepComponent implements OnInit {
|
||||
{ id: 'suspense', label: 'Move to Interest Suspense GL' }
|
||||
];
|
||||
|
||||
readonly sicrAssessmentBasisOptions = [
|
||||
{ id: 'DPD', label: 'Days-Past-Due threshold only' },
|
||||
{ id: 'RATING_DOWNGRADE', label: 'Internal rating downgrade only' },
|
||||
{ id: 'PRICING_INCREASE', label: 'Risk-based repricing only' },
|
||||
{ id: 'DPD_AND_RATING', label: 'DPD + rating downgrade (either triggers)' }
|
||||
];
|
||||
|
||||
/** RBI 2026: CC/OD "out of order" basis (para on revolving facilities). */
|
||||
readonly npaOutOfOrderBasisOptions = [
|
||||
{ id: 'LIMIT_EXCEEDED', label: 'Outstanding continuously exceeds limit/drawing power' },
|
||||
{ id: 'NO_CREDITS_90_DAYS', label: 'No credits for 90 days, or credits < interest debited' },
|
||||
{ id: 'DP_NOT_RENEWED', label: 'Drawing power not reassessed/renewed within 180 days' }
|
||||
];
|
||||
|
||||
readonly cropDurationOptions = [
|
||||
{ id: 'KHARIF', label: 'Kharif (short duration)' },
|
||||
{ id: 'RABI', label: 'Rabi (short duration)' },
|
||||
{ id: 'LONG_DURATION', label: 'Long duration crop' }
|
||||
];
|
||||
|
||||
readonly eligibleCollateralTypeOptions = [
|
||||
{ id: 'NONE', label: 'None' },
|
||||
{ id: 'GOLD', label: 'Gold / gold jewellery' },
|
||||
{ id: 'COMMODITY', label: 'Commodity (warehouse receipt)' },
|
||||
{ id: 'GOVERNMENT_SECURITIES', label: 'Government securities' },
|
||||
{ id: 'BANK_DEPOSIT', label: 'Bank term deposit (lien-marked)' },
|
||||
{ id: 'LIFE_INSURANCE', label: 'Life insurance policy (surrender value)' }
|
||||
];
|
||||
|
||||
get delinquencyBucketOptions(): any[] {
|
||||
return this.loanProductsTemplate?.delinquencyBucketOptions ?? [];
|
||||
}
|
||||
|
||||
/** CC/OD are revolving facilities — RBI's "out of order" NPA basis and 60-day SICR window apply to them. */
|
||||
get isRevolvingSubType(): boolean {
|
||||
return this.subType === 'cc' || this.subType === 'overdraft';
|
||||
}
|
||||
|
||||
get isKccSubType(): boolean {
|
||||
return this.subType === 'kcc';
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this.loanProductPenalNpaForm = this.formBuilder.group({
|
||||
penalApplicable: ['YES'],
|
||||
@@ -146,125 +78,13 @@ export class LoanProductPenalNpaStepComponent implements OnInit {
|
||||
npaTrigger: ['maturity'],
|
||||
interestAfterNpa: ['undebited'],
|
||||
graceOnArrearsAgeing: [0],
|
||||
smaStartAfterMonths: [
|
||||
1,
|
||||
[
|
||||
Validators.required,
|
||||
Validators.min(0)
|
||||
]
|
||||
],
|
||||
stockStatementValidityDays: [
|
||||
90,
|
||||
[Validators.min(0)]
|
||||
],
|
||||
inArrearsTolerance: [0],
|
||||
upgradeOnlyAllArrears: [true],
|
||||
keepStandardIfDepositCovers: [true],
|
||||
|
||||
// ---- SICR (Significant Increase in Credit Risk) — RBI 2026 ECL framework ----
|
||||
sicrDaysThreshold: [
|
||||
30,
|
||||
[Validators.min(0)]
|
||||
],
|
||||
sicrRevolvingDaysThreshold: [
|
||||
60,
|
||||
[Validators.min(0)]
|
||||
],
|
||||
sicrRatingDowngradeNotches: [
|
||||
2,
|
||||
[Validators.min(0)]
|
||||
],
|
||||
sicrPricingIncreaseTriggerPct: [
|
||||
1.5,
|
||||
[
|
||||
Validators.min(0),
|
||||
Validators.max(100)
|
||||
]
|
||||
],
|
||||
sicrAssessmentBasis: ['DPD'],
|
||||
sicrRebuttalAllowed: [false],
|
||||
npaOutOfOrderBasis: ['LIMIT_EXCEEDED'],
|
||||
cropDuration: [''],
|
||||
|
||||
// ---- LGD (Loss Given Default) — regulatory backstop vs bank's own model ----
|
||||
useOwnLgdModel: [false],
|
||||
lgdSecuredPct: [
|
||||
65,
|
||||
[
|
||||
Validators.min(0),
|
||||
Validators.max(100)
|
||||
]
|
||||
],
|
||||
lgdUnsecuredPct: [
|
||||
70,
|
||||
[
|
||||
Validators.min(0),
|
||||
Validators.max(100)
|
||||
]
|
||||
],
|
||||
eligibleCollateralType: ['NONE']
|
||||
keepStandardIfDepositCovers: [true]
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
if (this.productTypeGroup) {
|
||||
this.applyProductTypeRules(this.productTypeGroup.value === 'advances', this.productTypeSubGroup?.value ?? null);
|
||||
this.productTypeGroup.valueChanges.subscribe((group: string) => {
|
||||
this.applyProductTypeRules(group === 'advances', this.productTypeSubGroup?.value ?? null);
|
||||
});
|
||||
}
|
||||
if (this.productTypeSubGroup) {
|
||||
this.productTypeSubGroup.valueChanges.subscribe((subGroup: string) => {
|
||||
this.applyProductTypeRules(this.productTypeGroup?.value === 'advances', subGroup);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CC/OD (revolving) default to the 60-day SICR window and show the "out of order" NPA basis; KCC shows
|
||||
* the crop-duration field instead. Term/EMI loans use the plain 30-day DPD-based SICR window and hide
|
||||
* both revolving-only fields. Only defaults the value the first time a sub type becomes relevant, so it
|
||||
* doesn't clobber a value the user already edited.
|
||||
*/
|
||||
private applyProductTypeRules(isAdvance: boolean, subType: string | null): void {
|
||||
this.isAdvanceAccount = isAdvance;
|
||||
this.subType = isAdvance ? subType : null;
|
||||
|
||||
if (this.isRevolvingSubType && !this.loanProductPenalNpaForm.get('sicrAssessmentBasis')?.dirty) {
|
||||
this.loanProductPenalNpaForm.patchValue({ sicrAssessmentBasis: 'DPD_AND_RATING' });
|
||||
}
|
||||
|
||||
// The security that backs the product decides which collateral counts for ECL.
|
||||
const collateralBySubType: Record<string, string> = {
|
||||
fd: 'BANK_DEPOSIT',
|
||||
rd: 'BANK_DEPOSIT',
|
||||
dds: 'BANK_DEPOSIT',
|
||||
lic: 'LIFE_INSURANCE',
|
||||
gold: 'GOLD',
|
||||
warehouse: 'COMMODITY',
|
||||
gold_emi: 'GOLD'
|
||||
};
|
||||
const collateral = subType ? collateralBySubType[subType] : undefined;
|
||||
if (!this.loanProductPenalNpaForm.get('eligibleCollateralType')?.dirty) {
|
||||
this.loanProductPenalNpaForm.patchValue({ eligibleCollateralType: collateral ?? 'NONE' });
|
||||
}
|
||||
|
||||
// SMA counting starts 9 months after disbursement for Advances, 1 month for Loans (EMI).
|
||||
if (!this.loanProductPenalNpaForm.get('smaStartAfterMonths')?.dirty) {
|
||||
this.loanProductPenalNpaForm.patchValue({ smaStartAfterMonths: isAdvance ? 9 : 1 });
|
||||
}
|
||||
// NPA trigger follows the facility: CC/OD out-of-order, KCC crop season, everything else overdue days.
|
||||
if (!this.loanProductPenalNpaForm.get('npaTrigger')?.dirty) {
|
||||
const trigger = this.isRevolvingSubType ? 'out_of_order' : this.isKccSubType ? 'crop_season' : 'maturity';
|
||||
this.loanProductPenalNpaForm.patchValue({ npaTrigger: trigger });
|
||||
}
|
||||
}
|
||||
|
||||
get loanProductPenalNpa() {
|
||||
const value = this.loanProductPenalNpaForm.getRawValue();
|
||||
// Advances have no instalments, so the 9-month start is carried as arrears-ageing grace (30-day months).
|
||||
// Loans (EMI) need none: the first instalment falls due one month after disbursement already.
|
||||
const months = Number(value.smaStartAfterMonths) || 0;
|
||||
return { ...value, graceOnArrearsAgeing: this.isAdvanceAccount ? months * 30 : 0 };
|
||||
return this.loanProductPenalNpaForm.getRawValue();
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user