Compare commits

..
Author SHA1 Message Date
niteshandClaude Opus 5.5 fca550f4c4 feat(cfcfrms): I4C Live screens for the I4C-MHA integration
New CFCFRMS > I4C Live section backed by /v1/i4c/admin and /v1/i4c/cases
(the demo mock interceptor does not match these paths):

- Dashboard, complaints from I4C, holds register, approvals, responses and
  API log, bank code master, intimation settings.
- Hold popup: overview, hold transactions, customer intimation (automatic and
  manual), maker request (full / partial release, freeze, unfreeze), GRM / MRM
  and documents, request history.
- GRM grievances, MRM restorations and MRM balance report pages; shared date
  range filter (today, yesterday, week, month, custom), server-side paging and
  filters on every list; document upload / download.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 11:43:30 +05:30
niteshandClaude Opus 5.5 3e5cc26e49 feat(fixed-deposits): FD quote from the account engine, FD Special payout schedule popup, Special terms locked
- Rate chart step (account opening): the rate, maturity date, maturity amount
  and interest now come from POST fixeddepositaccounts/account-preview for
  every FD type, so the quote matches the account once created (the old
  calculate-fd-interest call counted quarters from the deposit date and was
  off, e.g. FD DOUBLE by 36,979.23). The client-side formula fallback is
  removed: on an error the message is shown, nothing is estimated.
- Dates are sent as the local calendar day (toISOString() turned 21 Oct at
  local midnight into 20 Oct).
- FD Special: total interest paid out monthly, monthly payout, principal at
  maturity, maturity date, number of payouts, and "View payout schedule",
  which opens the existing Monthly Interest Payout Schedule dialog in a new
  preview mode (Days column, no paid / pending status).
- Preview step: FD Special labels (total interest paid out monthly,
  principal at maturity).
- FD product terms: choosing FD Special locks Interest Calculation Type to
  Simple, as Compounding and Posting are locked to Monthly (the backend
  refuses anything else).
- Preview step: three existing `!= null` template checks rewritten for the
  eqeqeq lint rule, same meaning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:44:47 +05:30
niteshandClaude Opus 5.5 c3713f8a90 Merge feature/approval-live-badge-counts into dev-latest
Live pending-count badges on the Approval Center tabs and the Approvals menu,
served over one SSE stream.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:09:08 +05:30
niteshandClaude Sonnet 5.5 f804d356d7 fix(rd-products): save RD Interest Paid under expenses, as on FD and savings
The backend finds the account by name in any group. Products saved while it was under liabilities are still read from there, so the name is not left behind as an "other" liability row.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 10:48:37 +05:30
niteshandClaude Sonnet 5.5 1daa8e60a0 fix(cashier): keep the A/C TP kind on SD/FD/RD lookups in transaction entry
SD, FD and RD share one /savingsaccounts list that returns a deposit account of any kind for a number. The lookup now keeps only the deposit type chosen in A/C TP, and when the number belongs to another kind the error says so, so the clerk can fix A/C TP.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 10:48:23 +05:30
niteshandClaude Opus 5.5 55f5c1cf32 feat(ui): longer startup splash and updated loading animation
The startup splash now holds for at least 4 seconds (progress 2% per tick up to 90%)
instead of 2, and the loading animation is updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 12:31:24 +05:30
niteshandClaude Opus 5.5 6507e68203 feat(cashier): FD is credit-only on the transaction entry, and a Bank Transfer can fund an FD
- A Fixed Deposit row is credit-only on the clerk and cashier screens (isFdCreditOnly):
  its DR button is disabled, and Save refuses any FD row left on DR (for example by a
  Withdrawal Slip TYPE). Money leaves an FD only through its maturity or premature
  closure; the clerk's "Close this account" flow is the one exception.
- Bank Transfer / Fund Transfer accepts an FD on the CR side, so an FD can be funded from
  the customer's own Savings or Current account. The server enforces the funding rules
  (first and only funding, exactly the principal, the source account's owner, freeze
  status and available balance).

Requires the backend change "FD funding rules and no FD withdrawals from clerk/cashier requests".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 12:31:03 +05:30
niteshandClaude Sonnet 5.5 4d205672df feat(approvals): live pending-count badges on every Approval Center tab and the Approvals menu
The badges are pushed, not polled. ApprovalCountsService opens one
Server-Sent Events stream (/approvals/counts/stream) through HttpClient so the
login and tenant headers apply; the server writes a `counts` event the moment
the numbers change.

- Total badge on the Approvals menu, count badge on 26 Approval Center tabs
  (Reject Account and Terminated Account are lists, not requests: no badge).
- Last numbers are kept per login in localStorage, so a page refresh shows them
  at once until the stream's first event makes them exact.
- Stream recycles every few minutes and reconnects at once; after a failure it
  retries with a growing wait and reads /approvals/counts meanwhile.
- NO_PROGRESS_BAR token: the background stream does not run the top progress bar.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 15:49:36 +05:30
137 changed files with 14340 additions and 7847 deletions
@@ -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;
+73
View File
@@ -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"
+144
View File
@@ -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);
}
}
}
+789
View File
@@ -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 &amp; 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 &amp; 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 &amp; Bank Master Directory</span>
<span class="head-hint">Official MHA-I4C 2.0 routing table for beneficiary &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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>
@@ -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 &amp; 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 &amp; 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: '' } });
}
}
+384
View File
@@ -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(() => {
+5
View File
@@ -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
+15
View File
@@ -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;
}
+15
View File
@@ -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();
}
@@ -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.
@@ -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;
}
@@ -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;
}
}
@@ -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>
@@ -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;
}
@@ -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>
@@ -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
};
@@ -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">
@@ -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();
}
}
@@ -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
@@ -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 {
@@ -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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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;
}
}
@@ -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;
}
}
@@ -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>
@@ -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
},
{
@@ -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>
}
@@ -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,
@@ -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>
@@ -62,9 +62,11 @@ export class LoanProductChequeBookStepComponent {
],
defaultBookSize: ['25'],
maxBooksOutstanding: [1],
reissueWhenLeaves: [5],
firstBookFree: ['YES'],
freeBooksPerYear: [1],
namePrinted: ['Account holder name'],
chequePrintDescription: [''],
ppsThreshold: [
50000,
[
@@ -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;
@@ -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>
@@ -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(',') };
}
}
@@ -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>
@@ -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';
}
@@ -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>
@@ -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]
});
@@ -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
@@ -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();
}
}
@@ -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">
@@ -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
};
}
@@ -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>
@@ -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