feat(digital+toolbar): NEFT/RTGS module, AI menu, polished header icons

- Add /digital/neft-rtgs (entry) and /digital/neft-rtgs-report (analytics):
  KPIs, tabbed views (Trends, Bank-wise, Date-wise, Pending/Pass/Return,
  Branch/Mobile, User-wise), filterable transaction table with CSV export,
  and a non-dismissible Initiate dialog launched from a floating action.
- Make BANL verification mandatory before NEFT/RTGS submit; auto-fill and
  lock beneficiary name from the lookup response, invalidate match on
  A/C or IFSC edit, demo fallback for unknown accounts.
- Add Digital menu links (NEFT/RTGS + Report) in shell top-nav.
- Replace toolbar theme toggle with AI / Intelligence Modules mega menu:
  4 colour-coded columns covering Lending & Risk, Investment & Profit,
  Compliance & Advisory, Governance (9 modules total). Wire RBI
  Notifications Monitoring to /rbi-circulars.
- Unify notifications, settings, AI, and profile toolbar buttons into a
  26px ring style; colourful gradient bell SVG for notifications,
  custom gear+petals AI mark, online dot on the avatar.
- Trim ISO 27001 chip in the top bar.
- Add MPL header to generated .env.ts and its template in version.js.
- Bundles other pending working-tree changes (clients family/nominee
  tab tweaks, auth/2FA fixes, translations) into the same push.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aditya
2026-05-15 11:50:26 +05:30
co-authored by Claude Opus 4.7
parent 6714897688
commit 595054b0b8
35 changed files with 4409 additions and 194 deletions
+35
View File
@@ -0,0 +1,35 @@
/**
* 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 { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { Route } from '../core/route/route.service';
import { NeftRtgsEntryComponent } from './neft-rtgs-entry/neft-rtgs-entry.component';
import { NeftRtgsReportComponent } from './neft-rtgs-report/neft-rtgs-report.component';
const routes: Routes = [
Route.withShell([
{
path: 'digital/neft-rtgs',
component: NeftRtgsEntryComponent,
data: { title: 'NEFT / RTGS Entry', breadcrumb: 'NEFT / RTGS' }
},
{
path: 'digital/neft-rtgs-report',
component: NeftRtgsReportComponent,
data: { title: 'NEFT / RTGS Report', breadcrumb: 'NEFT / RTGS Report' }
}
])
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class DigitalRoutingModule {}
+24
View File
@@ -0,0 +1,24 @@
/**
* 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 { NgModule } from '@angular/core';
import { SharedModule } from '../shared/shared.module';
import { DigitalRoutingModule } from './digital-routing.module';
import { NeftRtgsEntryComponent } from './neft-rtgs-entry/neft-rtgs-entry.component';
import { NeftRtgsReportComponent } from './neft-rtgs-report/neft-rtgs-report.component';
@NgModule({
imports: [
SharedModule,
DigitalRoutingModule,
NeftRtgsEntryComponent,
NeftRtgsReportComponent
]
})
export class DigitalModule {}
@@ -0,0 +1,304 @@
<!--
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="nr-root" [class.nr-in-dialog]="isDialog">
<!-- HEADER -->
<div class="nr-head">
<div class="nr-head-left">
<fa-icon icon="exchange" class="nr-head-ic"></fa-icon>
<div>
<h2 class="nr-head-t">NEFT / RTGS — Outward Remittance</h2>
<div class="nr-head-s">
<span [class.is-rtgs]="isRtgs" [class.is-neft]="!isRtgs">{{ entryForm?.get('transactionType')?.value }}</span>
window ·
<fa-icon [icon]="isRtgs ? 'bolt' : 'clock'" class="nr-head-s-ic"></fa-icon>
{{ isRtgs ? 'Real-time gross settlement' : 'Half-hourly NEFT batches · 24×7' }}
</div>
</div>
</div>
<div class="nr-head-meta">
<div class="nr-head-pill" [class.is-rtgs]="isRtgs">
<span class="nr-head-pill-l">RTGS Floor</span>
<span class="nr-head-pill-v mono">₹{{ RTGS_MIN | number: '1.0-0' }}</span>
</div>
@if (isDialog) {
<button type="button" class="nr-dialog-x" (click)="back()" matTooltip="Close">
<fa-icon icon="times"></fa-icon>
</button>
}
</div>
</div>
@if (entryForm) {
<form [formGroup]="entryForm" class="nr-form" (ngSubmit)="submit()">
<!-- SECTION 1: Transaction Setup -->
<section class="nr-section">
<div class="nr-section-h">
<fa-icon icon="list-alt" class="nr-section-ic"></fa-icon>
<span>Transaction Setup</span>
</div>
<div class="nr-grid">
<mat-form-field appearance="outline" class="nr-field">
<mat-label>Transaction Type</mat-label>
<mat-select formControlName="transactionType" required>
<mat-option value="NEFT">NEFT — National EFT</mat-option>
<mat-option value="RTGS">RTGS — Real-Time Gross</mat-option>
</mat-select>
<fa-icon
matSuffix
icon="info-circle"
class="nr-hint-ic"
matTooltip="Determines clearing window. RTGS settles in real time; NEFT settles in half-hourly batches."
matTooltipPosition="left"
></fa-icon>
@if (entryForm.get('transactionType')?.hasError('required')) {
<mat-error>Transaction Type is <strong>required</strong></mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline" class="nr-field" (click)="valueDatePicker.open()">
<mat-label>Value Date</mat-label>
<input
matInput
[min]="minDate"
[max]="maxDate"
[matDatepicker]="valueDatePicker"
required
formControlName="valueDate"
/>
<mat-datepicker-toggle matSuffix [for]="valueDatePicker"></mat-datepicker-toggle>
<mat-datepicker #valueDatePicker></mat-datepicker>
@if (entryForm.get('valueDate')?.hasError('required')) {
<mat-error>Value Date is <strong>required</strong></mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline" class="nr-field">
<mat-label>Amount (₹)</mat-label>
<span matPrefix class="nr-prefix">₹&nbsp;</span>
<input matInput type="number" min="1" step="0.01" required formControlName="amount" placeholder="0.00" />
@if (entryForm.get('amount')?.hasError('required')) {
<mat-error>Amount is <strong>required</strong></mat-error>
}
@if (entryForm.get('amount')?.hasError('min') && !entryForm.get('amount')?.hasError('required')) {
<mat-error>
@if (rtgsUnderMin) {
RTGS min ₹{{ RTGS_MIN | number: '1.0-0' }}
} @else {
Amount must be > 0
}
</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline" class="nr-field">
<mat-label>Sender Account No.</mat-label>
<input matInput required formControlName="senderAccount" placeholder="e.g. SB10029384" autocomplete="off" />
@if (entryForm.get('senderAccount')?.hasError('required')) {
<mat-error>Sender Account is <strong>required</strong></mat-error>
}
@if (
entryForm.get('senderAccount')?.hasError('pattern') &&
!entryForm.get('senderAccount')?.hasError('required')
) {
<mat-error>Use 6–20 alphanumeric chars</mat-error>
}
</mat-form-field>
</div>
</section>
<!-- SECTION 2: Beneficiary -->
<section class="nr-section">
<div class="nr-section-h">
<fa-icon icon="users" class="nr-section-ic"></fa-icon>
<span>Beneficiary Details</span>
@if (banlVerified) {
<span class="nr-banl-status-pill verified" matTooltip="Resolved via BANL / IMPS Switch">
<fa-icon icon="check-circle"></fa-icon> Verified
</span>
} @else {
<span class="nr-banl-status-pill pending" matTooltip="BANL Look-up is mandatory before initiating">
<fa-icon icon="exclamation-circle"></fa-icon> Verification Required
</span>
}
<button
type="button"
class="nr-banl-btn"
[class.is-pending]="!banlVerified && canPerformBanl"
(click)="performBanl()"
[disabled]="banlLooking || !canPerformBanl"
matTooltip="Look up the beneficiary via account number + IFSC (mocks the IMPS Switch API for the demo)"
>
@if (banlLooking) {
<fa-icon icon="spinner" class="fa-spin"></fa-icon> Looking up…
} @else if (banlVerified) {
<fa-icon icon="sync-alt"></fa-icon> Re-verify
} @else {
<fa-icon icon="search"></fa-icon> BANL Look-up
}
</button>
</div>
<div class="nr-grid">
<mat-form-field appearance="outline" class="nr-field">
<mat-label>Beneficiary Account</mat-label>
<input
matInput
required
formControlName="beneficiaryAccount"
placeholder="Credit A/C number"
autocomplete="off"
/>
@if (entryForm.get('beneficiaryAccount')?.hasError('required')) {
<mat-error>Beneficiary Account is <strong>required</strong></mat-error>
}
@if (
entryForm.get('beneficiaryAccount')?.hasError('pattern') &&
!entryForm.get('beneficiaryAccount')?.hasError('required')
) {
<mat-error>Use 6–20 alphanumeric chars</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline" class="nr-field">
<mat-label>IFSC Code</mat-label>
<input
matInput
required
maxlength="11"
formControlName="ifscCode"
placeholder="e.g. HDFC0001234"
style="text-transform: uppercase"
(input)="
entryForm.get('ifscCode')?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })
"
/>
@if (entryForm.get('ifscCode')?.hasError('required')) {
<mat-error>IFSC Code is <strong>required</strong></mat-error>
}
@if (entryForm.get('ifscCode')?.hasError('pattern') && !entryForm.get('ifscCode')?.hasError('required')) {
<mat-error>Invalid IFSC — 11 chars (e.g. HDFC0001234)</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline" class="nr-field nr-span-2 nr-name-field">
<mat-label>Beneficiary Name (auto-filled by BANL)</mat-label>
<input
matInput
required
readonly
formControlName="beneficiaryName"
placeholder="Run BANL Look-up to fetch the registered name"
autocomplete="off"
/>
@if (banlVerified) {
<fa-icon matSuffix icon="check-circle" class="nr-name-ok"></fa-icon>
} @else {
<fa-icon matSuffix icon="lock" class="nr-name-lock"></fa-icon>
}
<mat-hint>Fetched from the IMPS Switch via account no. + IFSC — cannot be typed manually.</mat-hint>
</mat-form-field>
</div>
<!-- BANL result card (compact) -->
@if (banlMatch) {
<div class="nr-banl-card success">
<fa-icon icon="check-circle" class="nr-banl-card-ic"></fa-icon>
<div class="nr-banl-card-body">
<strong>{{ banlMatch.beneficiaryName }}</strong> ·
<span class="mono">{{ banlMatch.beneficiaryAccount }}</span> ·
<span class="mono">{{ banlMatch.ifscCode }}</span> · {{ banlMatch.bankName }},
{{ banlMatch.branchName }}
<span class="nr-banl-status" [class.dormant]="banlMatch.status === 'DORMANT'"
>· {{ banlMatch.status }}</span
>
</div>
<button type="button" class="nr-banl-card-x" (click)="clearBanl()" matTooltip="Dismiss">
<fa-icon icon="times"></fa-icon>
</button>
</div>
}
@if (banlError) {
<div class="nr-banl-card error">
<fa-icon icon="exclamation-triangle" class="nr-banl-card-ic"></fa-icon>
<div class="nr-banl-card-body">{{ banlError }}</div>
<button type="button" class="nr-banl-card-x" (click)="clearBanl()" matTooltip="Dismiss">
<fa-icon icon="times"></fa-icon>
</button>
</div>
}
</section>
<!-- SECTION 3: Purpose & Remarks -->
<section class="nr-section">
<div class="nr-section-h">
<fa-icon icon="comment-alt" class="nr-section-ic"></fa-icon>
<span>Purpose &amp; Notes</span>
@if (purposeMandatory) {
<span class="nr-required-pill">Mandatory · {{ isRtgs ? 'RTGS' : 'High-Value NEFT' }}</span>
}
</div>
<div class="nr-grid">
<mat-form-field appearance="outline" class="nr-field nr-span-2">
<mat-label>
Purpose of Remittance
@if (purposeMandatory) {
<span class="nr-req-star"> *</span>
}
</mat-label>
<mat-select formControlName="purposeOfRemittance" [required]="purposeMandatory">
@for (p of purposeOptions; track p.code) {
<mat-option [value]="p.code">{{ p.label }}</mat-option>
}
</mat-select>
@if (entryForm.get('purposeOfRemittance')?.hasError('required')) {
<mat-error>Purpose required for {{ isRtgs ? 'RTGS' : 'high-value NEFT' }}</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline" class="nr-field nr-span-2">
<mat-label>Remarks (optional)</mat-label>
<input matInput formControlName="remarks" maxlength="140" placeholder="Internal note · max 140 chars" />
</mat-form-field>
</div>
</section>
<!-- ACTIONS -->
<div class="nr-actions">
<button type="button" mat-stroked-button class="nr-btn-ghost" (click)="loadSample()">
<fa-icon icon="lightbulb"></fa-icon> Load Sample
</button>
<button type="button" mat-stroked-button class="nr-btn-ghost" (click)="resetForm()">
<fa-icon icon="undo"></fa-icon> Reset
</button>
<span class="nr-actions-spacer"></span>
@if (!banlVerified) {
<span class="nr-submit-hint">
<fa-icon icon="exclamation-circle"></fa-icon>
BANL Look-up required to enable Initiate
</span>
}
<button type="button" mat-stroked-button (click)="back()">Cancel</button>
<button
type="submit"
mat-flat-button
color="primary"
[disabled]="!canSubmit"
class="nr-btn-submit"
[matTooltip]="!banlVerified ? 'Run BANL Look-up first to verify the beneficiary' : ''"
matTooltipPosition="above"
>
<fa-icon icon="paper-plane"></fa-icon>
{{ isRtgs ? 'Initiate RTGS' : 'Initiate NEFT' }}
</button>
</div>
</form>
}
</div>
@@ -0,0 +1,595 @@
/**
* 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 {
--nr-navy: #0b1e3d;
--nr-blue: #1d4ed8;
--nr-blue-d: #1e40af;
--nr-blue-l: #eff6ff;
--nr-teal: #0e7e6b;
--nr-teal-l: #e0f4f0;
--nr-green: #15803d;
--nr-green-l: #dcfce7;
--nr-amber: #d97706;
--nr-amber-l: #fef3c7;
--nr-red: #c0392b;
--nr-red-l: #fdecea;
--nr-purple: #6d28d9;
--nr-purple-l: #ede9fe;
--nr-g50: #f8fafc;
--nr-g100: #f1f5f9;
--nr-g200: #e2e8f0;
--nr-g300: #cbd5e1;
--nr-g500: #64748b;
--nr-g600: #475569;
--nr-g700: #334155;
--nr-g900: #0f172a;
display: block;
font-family:
'DM Sans',
system-ui,
-apple-system,
sans-serif;
background: var(--nr-g50);
color: var(--nr-g900);
font-size: 13.5px;
line-height: 1.5;
}
/* ───────── ROUTED PAGE LAYOUT ───────── */
.nr-root {
padding: 18px 22px;
max-width: 1100px;
margin: 0 auto;
}
.nr-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 14px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.nr-head-left {
display: flex;
align-items: center;
gap: 12px;
}
.nr-head-ic {
font-size: 24px;
color: var(--nr-blue);
}
.nr-head-t {
margin: 0;
font-size: 17px;
font-weight: 700;
color: var(--nr-g900);
}
.nr-head-s {
font-size: 12px;
color: var(--nr-g500);
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
.is-rtgs {
color: var(--nr-purple);
font-weight: 700;
}
.is-neft {
color: var(--nr-blue);
font-weight: 700;
}
}
.nr-head-s-ic {
font-size: 10px;
color: var(--nr-g500);
}
.nr-head-meta {
display: flex;
align-items: center;
gap: 8px;
}
.nr-head-pill {
display: inline-flex;
align-items: center;
gap: 8px;
background: var(--nr-blue-l);
border: 1px solid #dbeafe;
border-radius: 999px;
padding: 5px 12px 5px 14px;
color: var(--nr-blue-d);
&.is-rtgs {
background: var(--nr-purple-l);
border-color: #ddd6fe;
color: #4c1d95;
}
}
.nr-head-pill-l {
font-size: 9.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
opacity: 0.8;
}
.nr-head-pill-v {
font-size: 12.5px;
font-weight: 700;
}
.nr-dialog-x {
background: transparent;
border: 1px solid var(--nr-g200);
color: var(--nr-g600);
width: 30px;
height: 30px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.15s ease;
margin-left: 4px;
&:hover {
background: var(--nr-red-l);
color: var(--nr-red);
border-color: #fecaca;
}
}
/* ───────── SECTION ───────── */
.nr-section {
background: #fff;
border: 1px solid var(--nr-g200);
border-radius: 12px;
padding: 14px 16px;
margin-bottom: 12px;
box-shadow: 0 1px 2px rgb(15 23 42 / 4%);
}
.nr-section-h {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px dashed var(--nr-g200);
font-size: 12px;
font-weight: 700;
color: var(--nr-g700);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.nr-section-ic {
color: var(--nr-blue);
font-size: 13px;
}
.nr-required-pill {
margin-left: auto;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 3px 9px;
border-radius: 999px;
background: var(--nr-amber-l);
color: var(--nr-amber);
}
.nr-banl-status-pill {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 10.5px;
font-weight: 700;
padding: 3px 10px;
border-radius: 999px;
text-transform: uppercase;
letter-spacing: 0.04em;
fa-icon {
font-size: 10px;
}
&.verified {
background: var(--nr-green-l);
color: var(--nr-green);
border: 1px solid #bbf7d0;
}
&.pending {
background: var(--nr-amber-l);
color: var(--nr-amber);
border: 1px solid #fde68a;
}
}
.nr-banl-btn {
margin-left: 6px;
display: inline-flex;
align-items: center;
gap: 6px;
background: var(--nr-teal-l);
color: var(--nr-teal);
border: 1px solid #b8e5dc;
border-radius: 999px;
padding: 4px 12px;
font-size: 11.5px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
text-transform: none;
letter-spacing: 0;
fa-icon {
font-size: 11px;
}
&:hover:not([disabled]) {
background: var(--nr-teal);
color: #fff;
border-color: var(--nr-teal);
}
&[disabled] {
opacity: 0.55;
cursor: not-allowed;
}
/* When verification is pending but the operator has entered a valid A/C — nudge them */
&.is-pending {
background: var(--nr-amber);
color: #fff;
border-color: var(--nr-amber);
animation: nr-banl-pulse 2s ease-in-out infinite;
&:hover:not([disabled]) {
background: #b45309;
border-color: #b45309;
}
}
}
@keyframes nr-banl-pulse {
0%,
100% {
box-shadow: 0 0 0 0 rgb(217 119 6 / 45%);
}
50% {
box-shadow: 0 0 0 6px rgb(217 119 6 / 0%);
}
}
.nr-name-field .nr-name-ok {
color: var(--nr-green);
font-size: 14px;
}
.nr-name-field .nr-name-lock {
color: var(--nr-g500);
font-size: 12px;
}
/* Read-only beneficiary name: full-strength text, no caret, subtle tint when verified */
/* stylelint-disable selector-no-qualifying-type */
:host ::ng-deep .nr-name-field input[readonly] {
cursor: default;
color: var(--nr-g900);
-webkit-text-fill-color: var(--nr-g900);
caret-color: transparent;
font-weight: 600;
}
/* stylelint-enable selector-no-qualifying-type */
.nr-submit-hint {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11.5px;
font-weight: 600;
color: var(--nr-amber);
padding: 4px 10px;
background: var(--nr-amber-l);
border: 1px solid #fde68a;
border-radius: 999px;
margin-right: 6px;
fa-icon {
font-size: 11px;
}
}
/* ───────── GRID (auto-fit) ───────── */
.nr-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 8px 12px;
}
.nr-field {
width: 100%;
}
.nr-span-2 {
grid-column: span 2;
@media (width <= 560px) {
grid-column: span 1;
}
}
.nr-prefix {
color: var(--nr-g500);
font-weight: 600;
font-size: 13px;
}
.nr-hint-ic {
color: var(--nr-g500);
font-size: 12px;
cursor: help;
}
.nr-req-star {
color: var(--nr-red);
font-weight: 700;
}
/* ───────── BANL RESULT (compact, single-line) ───────── */
.nr-banl-card {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border-radius: 8px;
margin-top: 6px;
font-size: 12.5px;
line-height: 1.4;
&.success {
background: var(--nr-green-l);
border: 1px solid #bbf7d0;
color: var(--nr-g900);
.nr-banl-card-ic {
color: var(--nr-green);
}
}
&.error {
background: var(--nr-red-l);
border: 1px solid #fecaca;
color: var(--nr-red);
.nr-banl-card-ic {
color: var(--nr-red);
}
}
}
.nr-banl-card-ic {
font-size: 16px;
flex-shrink: 0;
}
.nr-banl-card-body {
flex: 1;
.mono {
font-family: 'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace;
font-size: 12px;
color: var(--nr-g700);
}
}
.nr-banl-status {
font-weight: 700;
color: var(--nr-green);
&.dormant {
color: var(--nr-amber);
}
}
.nr-banl-card-x {
background: transparent;
border: 0;
color: var(--nr-g500);
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
&:hover {
background: rgb(0 0 0 / 5%);
color: var(--nr-g700);
}
}
/* ───────── ACTIONS ───────── */
.nr-actions {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 4px 4px;
flex-wrap: wrap;
}
.nr-actions-spacer {
flex: 1;
}
.nr-btn-ghost {
color: var(--nr-g600) !important;
fa-icon {
margin-right: 4px;
}
}
.nr-btn-submit fa-icon {
margin-right: 6px;
}
.mono {
font-family: 'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace;
font-size: 12.5px;
}
/* ═══════════════════════════════════════════════════
DIALOG MODE — sticky header/footer, compact body
═══════════════════════════════════════════════════ */
.nr-root.nr-in-dialog {
padding: 0;
background: #fff;
max-width: none;
display: flex;
flex-direction: column;
max-height: min(720px, 88vh);
/* compact sticky header */
.nr-head {
position: sticky;
top: 0;
z-index: 5;
background: linear-gradient(180deg, #fff 0%, var(--nr-g50) 100%);
border-bottom: 1px solid var(--nr-g200);
padding: 12px 18px;
margin-bottom: 0;
flex-wrap: nowrap;
}
.nr-head-ic {
font-size: 20px;
}
.nr-head-t {
font-size: 15px;
}
.nr-head-s {
font-size: 11.5px;
}
.nr-head-pill {
padding: 4px 10px 4px 12px;
}
.nr-head-pill-l {
font-size: 9px;
}
.nr-head-pill-v {
font-size: 11.5px;
}
/* scrollable body */
.nr-form {
flex: 1;
overflow-y: auto;
padding: 14px 18px 12px;
background: var(--nr-g50);
}
/* sections become subtle blocks with smaller padding */
.nr-section {
background: #fff;
border: 1px solid var(--nr-g200);
border-radius: 10px;
padding: 10px 14px;
margin-bottom: 10px;
box-shadow: 0 1px 2px rgb(15 23 42 / 4%);
&:last-of-type {
margin-bottom: 0;
}
}
.nr-section-h {
font-size: 11px;
padding-bottom: 6px;
margin-bottom: 10px;
}
.nr-section-ic {
font-size: 11px;
}
.nr-banl-btn {
padding: 3px 10px;
font-size: 11px;
fa-icon {
font-size: 10px;
}
}
.nr-required-pill {
font-size: 9.5px;
padding: 2px 8px;
}
/* tighter grid in dialog — packs 3+ columns at full width */
.nr-grid {
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: 4px 12px;
}
/* sticky footer actions */
.nr-actions {
position: sticky;
bottom: 0;
background: #fff;
border-top: 1px solid var(--nr-g200);
padding: 10px 18px;
margin: 0;
z-index: 5;
box-shadow: 0 -2px 6px rgb(15 23 42 / 4%);
}
.nr-banl-card {
margin-top: 6px;
padding: 7px 11px;
font-size: 12px;
}
}
/* Density: shrink Material outlined form-field internals in dialog mode */
:host ::ng-deep .nr-root.nr-in-dialog .mat-mdc-form-field {
.mat-mdc-text-field-wrapper {
padding-top: 2px;
padding-bottom: 2px;
}
.mat-mdc-form-field-infix {
min-height: 42px;
padding-top: 8px;
padding-bottom: 8px;
}
.mat-mdc-form-field-subscript-wrapper {
height: 14px;
}
}
@@ -0,0 +1,434 @@
/**
* 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, inject, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatTooltip } from '@angular/material/tooltip';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatDialogRef } from '@angular/material/dialog';
import { Router } from '@angular/router';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
type TxnType = 'NEFT' | 'RTGS';
interface PurposeOption {
code: string;
label: string;
highValueOnly?: boolean;
}
interface BanlMatch {
beneficiaryName: string;
beneficiaryAccount: string;
ifscCode: string;
bankName: string;
branchName: string;
status: 'ACTIVE' | 'DORMANT';
}
@Component({
selector: 'mifosx-neft-rtgs-entry',
templateUrl: './neft-rtgs-entry.component.html',
styleUrls: ['./neft-rtgs-entry.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent,
MatTooltip
]
})
export class NeftRtgsEntryComponent implements OnInit {
private fb = inject(FormBuilder);
private snack = inject(MatSnackBar);
private router = inject(Router);
/** Present when this component is opened inside a dialog. */
private dialogRef = inject(MatDialogRef<NeftRtgsEntryComponent>, { optional: true });
get isDialog(): boolean {
return this.dialogRef !== null;
}
/** RTGS minimum threshold (₹2 Lakhs). */
readonly RTGS_MIN = 200000;
/** Threshold above which Purpose of Remittance is mandatory for NEFT (cross-border-style high value). */
readonly HIGH_VALUE_THRESHOLD = 200000;
minDate = new Date(2000, 0, 1);
maxDate = new Date(2100, 11, 31);
entryForm: FormGroup;
/** BANL lookup state */
banlLooking = false;
banlMatch: BanlMatch | null = null;
banlError: string | null = null;
/** Mock BANL directory — replace with API call later. */
private banlDirectory: BanlMatch[] = [
{
beneficiaryName: 'Rohan S. Mehta',
beneficiaryAccount: '50100123456789',
ifscCode: 'HDFC0001234',
bankName: 'HDFC Bank',
branchName: 'Mumbai — Andheri (E)',
status: 'ACTIVE'
},
{
beneficiaryName: 'Bluestone Trading Co.',
beneficiaryAccount: '345678901234',
ifscCode: 'ICIC0000567',
bankName: 'ICICI Bank',
branchName: 'Bengaluru — MG Road',
status: 'ACTIVE'
},
{
beneficiaryName: 'Anita Verma',
beneficiaryAccount: '912345678901',
ifscCode: 'SBIN0011223',
bankName: 'State Bank of India',
branchName: 'New Delhi — Connaught Place',
status: 'DORMANT'
}
];
/** RBI standard purpose-of-remittance categories (subset). */
purposeOptions: PurposeOption[] = [
{ code: 'CAPITAL_TRANSFER', label: 'Capital Transfer' },
{ code: 'FAMILY_MAINTENANCE', label: 'Family Maintenance' },
{ code: 'TRADE_SETTLEMENT', label: 'Trade Settlement' },
{ code: 'SALARY_PAYMENT', label: 'Salary Payment' },
{ code: 'LOAN_REPAYMENT', label: 'Loan Repayment' },
{ code: 'TAX_PAYMENT', label: 'Tax Payment' },
{ code: 'VENDOR_PAYMENT', label: 'Vendor / Supplier Payment' },
{ code: 'INVESTMENT', label: 'Investment / Securities' },
{ code: 'GIFT', label: 'Gift / Personal Remittance' },
{ code: 'OTHER', label: 'Other' }
];
ngOnInit(): void {
this.entryForm = this.fb.group({
transactionType: [
'NEFT' as TxnType,
Validators.required
],
valueDate: [
new Date(),
Validators.required
],
amount: [
null,
[
Validators.required,
Validators.min(1)
]
],
senderAccount: [
'',
[
Validators.required,
Validators.pattern(/^[A-Za-z0-9]{6,20}$/)
]
],
beneficiaryName: [
'',
[
Validators.required,
Validators.maxLength(120)
]
],
beneficiaryAccount: [
'',
[
Validators.required,
Validators.pattern(/^[A-Za-z0-9]{6,20}$/)
]
],
ifscCode: [
'',
[
Validators.required,
Validators.pattern(/^[A-Z]{4}0[A-Z0-9]{6}$/)
]
],
purposeOfRemittance: [
'',
[]
],
remarks: ['']
});
// Re-evaluate purpose requirement whenever amount or type changes.
this.entryForm.get('amount')?.valueChanges.subscribe(() => this.refreshPurposeValidator());
this.entryForm.get('transactionType')?.valueChanges.subscribe((t) => {
this.refreshPurposeValidator();
this.refreshAmountValidator(t);
});
// Any change to A/C or IFSC invalidates the prior BANL match — operator must re-verify.
this.entryForm.get('beneficiaryAccount')?.valueChanges.subscribe(() => this.invalidateBanl());
this.entryForm.get('ifscCode')?.valueChanges.subscribe(() => this.invalidateBanl());
this.refreshAmountValidator(this.entryForm.get('transactionType')?.value);
}
/** Drop the prior BANL match + clear the auto-filled beneficiary name. */
private invalidateBanl(): void {
if (!this.banlMatch) return;
this.banlMatch = null;
this.banlError = null;
this.entryForm.get('beneficiaryName')?.setValue('', { emitEvent: false });
}
// ───────── Validators ─────────
private refreshAmountValidator(type: TxnType): void {
const amt = this.entryForm.get('amount');
if (!amt) return;
const validators = [
Validators.required,
Validators.min(1)
];
if (type === 'RTGS') {
validators.push(Validators.min(this.RTGS_MIN));
}
amt.setValidators(validators);
amt.updateValueAndValidity({ emitEvent: false });
}
private refreshPurposeValidator(): void {
const purpose = this.entryForm.get('purposeOfRemittance');
if (!purpose) return;
if (this.purposeMandatory) {
purpose.setValidators([
Validators.required
]);
} else {
purpose.clearValidators();
}
purpose.updateValueAndValidity({ emitEvent: false });
}
// ───────── Derived state ─────────
get isRtgs(): boolean {
return this.entryForm?.get('transactionType')?.value === 'RTGS';
}
get amount(): number {
return Number(this.entryForm?.get('amount')?.value || 0);
}
/** Purpose is mandatory for RTGS or any high-value (≥ ₹2 L) txn per spec. */
get purposeMandatory(): boolean {
return this.isRtgs || this.amount >= this.HIGH_VALUE_THRESHOLD;
}
get rtgsUnderMin(): boolean {
return this.isRtgs && this.amount > 0 && this.amount < this.RTGS_MIN;
}
/** True once a BANL lookup has resolved a valid beneficiary record. */
get banlVerified(): boolean {
return this.banlMatch !== null;
}
/** Master gate — Initiate is only enabled when both form is valid AND BANL is verified. */
get canSubmit(): boolean {
return this.entryForm?.valid === true && this.banlVerified;
}
/** Pre-requisites for kicking off BANL (need A/C number that's structurally valid). */
get canPerformBanl(): boolean {
const acct = this.entryForm?.get('beneficiaryAccount');
return !!acct && acct.value && !acct.hasError('pattern');
}
get clearingWindow(): string {
if (this.isRtgs) {
return 'RTGS — settled in real time during RBI hours (Mon–Sat, 24×7 supported since Dec 2020).';
}
return 'NEFT — half-hourly batches, 24×7 settlement.';
}
// ───────── BANL lookup ─────────
/**
* Beneficiary Account Number Look-up — stand-in for the IMPS Switch API
* (account-no + IFSC → beneficiary name). Mandatory before Initiate.
*/
performBanl(): void {
const acct = (this.entryForm.get('beneficiaryAccount')?.value || '').trim();
const ifsc = (this.entryForm.get('ifscCode')?.value || '').trim().toUpperCase();
this.banlMatch = null;
this.banlError = null;
if (!acct) {
this.banlError = 'Enter the Beneficiary Account Number to look up.';
return;
}
if (this.entryForm.get('beneficiaryAccount')?.hasError('pattern')) {
this.banlError = 'Beneficiary Account format is invalid — fix it before look-up.';
return;
}
if (ifsc && this.entryForm.get('ifscCode')?.hasError('pattern')) {
this.banlError = 'IFSC format is invalid — fix it before look-up.';
return;
}
this.banlLooking = true;
// Simulated lookup; swap for the IMPS Switch API call later.
setTimeout(() => {
this.banlLooking = false;
const match = this.banlDirectory.find((b) => b.beneficiaryAccount === acct && (!ifsc || b.ifscCode === ifsc));
if (!match) {
// Demo fallback: synthesise a name for any account that passes structural checks.
// Real implementation will route through the IMPS Switch.
const demo = this.synthesiseBanlForDemo(acct, ifsc);
this.banlMatch = demo;
this.applyBanlMatch(demo, true);
return;
}
this.applyBanlMatch(match, false);
}, 600);
}
private applyBanlMatch(match: BanlMatch, isDemo: boolean): void {
this.banlMatch = match;
// The input is `readonly` in the template, so only BANL writes this value.
this.entryForm.get('beneficiaryName')?.setValue(match.beneficiaryName, { emitEvent: false });
this.entryForm.patchValue({ ifscCode: match.ifscCode }, { emitEvent: false });
const tag = isDemo ? ' (demo)' : '';
this.snack.open(`BANL verified${tag} — ${match.beneficiaryName}`, 'OK', { duration: 2500 });
}
/**
* Demo-only stand-in for the IMPS Switch response.
* Produces a plausible-looking beneficiary record from the entered A/C + IFSC.
*/
private synthesiseBanlForDemo(acct: string, ifsc: string): BanlMatch {
const bankFromIfsc = (code: string): string => {
const map: Record<string, string> = {
HDFC: 'HDFC Bank',
ICIC: 'ICICI Bank',
SBIN: 'State Bank of India',
KKBK: 'Kotak Mahindra Bank',
AXIS: 'Axis Bank',
UTIB: 'Axis Bank',
PUNB: 'Punjab National Bank',
BARB: 'Bank of Baroda',
CNRB: 'Canara Bank',
YESB: 'YES Bank',
IDFB: 'IDFC FIRST Bank'
};
return map[(code || '').slice(0, 4).toUpperCase()] || 'Partner Bank';
};
const seed = acct.slice(-2);
const sampleNames = [
'Rohan S. Mehta',
'Anita Verma',
'Bluestone Trading Co.',
'Madhavi Singh',
'Aarav Enterprises',
'Neha Kapoor',
'Spectra Components',
'Indrani Sen',
'Vijaya Traders',
'Pinnacle Realty Pvt Ltd'
];
const name = sampleNames[parseInt(seed, 10) % sampleNames.length] || 'Verified Beneficiary';
return {
beneficiaryName: name,
beneficiaryAccount: acct,
ifscCode: ifsc || 'HDFC0001234',
bankName: bankFromIfsc(ifsc),
branchName: 'Verified via IMPS Switch',
status: 'ACTIVE'
};
}
clearBanl(): void {
this.banlMatch = null;
this.banlError = null;
this.entryForm.get('beneficiaryName')?.setValue('', { emitEvent: false });
}
// ───────── Actions ─────────
submit(): void {
if (!this.banlVerified) {
this.banlError = 'BANL verification is mandatory before initiating a transfer.';
this.snack.open('Run BANL Look-up to verify the beneficiary before submitting.', 'OK', {
duration: 3500
});
return;
}
if (this.entryForm.invalid) {
this.entryForm.markAllAsTouched();
this.snack.open('Please fix the highlighted fields.', 'OK', { duration: 2800 });
return;
}
const v = this.entryForm.getRawValue(); // include disabled (BANL-filled) beneficiaryName
const txnId = 'TX' + Date.now().toString().slice(-10);
this.snack.open(
`${v.transactionType} initiated · ₹${Number(v.amount).toLocaleString('en-IN')} · Ref ${txnId}`,
'OK',
{ duration: 4500 }
);
if (this.isDialog) {
this.dialogRef!.close({ submitted: true, payload: { ...v, txnId } });
return;
}
this.resetForm();
}
resetForm(): void {
this.entryForm.reset({
transactionType: 'NEFT',
valueDate: new Date(),
amount: null,
senderAccount: '',
beneficiaryName: '',
beneficiaryAccount: '',
ifscCode: '',
purposeOfRemittance: '',
remarks: ''
});
this.clearBanl();
this.refreshAmountValidator('NEFT');
this.refreshPurposeValidator();
}
/** Sample / demo entry to make the form discoverable. Auto-runs BANL after prefill. */
loadSample(): void {
this.entryForm.patchValue(
{
transactionType: 'RTGS',
valueDate: new Date(),
amount: 250000,
senderAccount: 'SB10029384',
beneficiaryAccount: '50100123456789',
ifscCode: 'HDFC0001234',
purposeOfRemittance: 'TRADE_SETTLEMENT',
remarks: 'Q1 vendor settlement'
},
{ emitEvent: false }
);
this.refreshAmountValidator('RTGS');
this.refreshPurposeValidator();
this.banlMatch = null;
this.banlError = null;
// Kick BANL so sample is immediately submittable.
this.performBanl();
}
back(): void {
if (this.isDialog) {
this.dialogRef!.close({ submitted: false });
return;
}
this.router.navigate([
'/home'
]);
}
}
@@ -0,0 +1,433 @@
<!--
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="nrr-root">
<!-- HEADER -->
<div class="nrr-head">
<div class="nrr-head-left">
<fa-icon icon="chart-line" class="nrr-head-ic"></fa-icon>
<div>
<h2 class="nrr-head-t">NEFT / RTGS — Report &amp; Reconciliation</h2>
<div class="nrr-head-s">
Digital · Outward Remittance Analytics · Showing
<strong>{{ totalCount | number }}</strong> transactions worth
<strong>₹{{ totalAmount | number: '1.0-0' }}</strong>
</div>
</div>
</div>
<div class="nrr-head-actions">
<button mat-stroked-button (click)="exportCsv()" matTooltip="Download filtered transactions as CSV">
<fa-icon icon="download"></fa-icon> Export CSV
</button>
</div>
</div>
<!-- KPI STRIP -->
<div class="nrr-kpis">
<div class="nrr-kpi nrr-kpi--blue">
<div class="nrr-kpi-ic"><fa-icon icon="exchange"></fa-icon></div>
<div>
<div class="nrr-kpi-n">{{ totalCount | number }}</div>
<div class="nrr-kpi-l">Total Transactions</div>
<div class="nrr-kpi-sub">{{ neftCount }} NEFT &middot; {{ rtgsCount }} RTGS</div>
</div>
</div>
<div class="nrr-kpi nrr-kpi--teal">
<div class="nrr-kpi-ic"><fa-icon icon="coins"></fa-icon></div>
<div>
<div class="nrr-kpi-n">₹{{ totalAmount | number: '1.0-0' }}</div>
<div class="nrr-kpi-l">Total Value</div>
<div class="nrr-kpi-sub">avg ₹{{ avgTicket | number: '1.0-0' }} / txn</div>
</div>
</div>
<div class="nrr-kpi nrr-kpi--green">
<div class="nrr-kpi-ic"><fa-icon icon="check-circle"></fa-icon></div>
<div>
<div class="nrr-kpi-n">{{ settledCount | number }}</div>
<div class="nrr-kpi-l">Settled</div>
<div class="nrr-kpi-sub">success {{ successRate | number: '1.1-1' }}%</div>
</div>
</div>
<div class="nrr-kpi nrr-kpi--amber">
<div class="nrr-kpi-ic"><fa-icon icon="hourglass-half"></fa-icon></div>
<div>
<div class="nrr-kpi-n">{{ pendingCount | number }}</div>
<div class="nrr-kpi-l">In-Flight</div>
<div class="nrr-kpi-sub">uploaded / validated / sent</div>
</div>
</div>
<div class="nrr-kpi nrr-kpi--red">
<div class="nrr-kpi-ic"><fa-icon icon="times-circle"></fa-icon></div>
<div>
<div class="nrr-kpi-n">{{ returnedCount | number }}</div>
<div class="nrr-kpi-l">Returned</div>
<div class="nrr-kpi-sub">verify return codes</div>
</div>
</div>
</div>
<!-- TABS -->
<div class="nrr-tabs">
@for (t of tabs; track t.id) {
<button class="nrr-tab" [class.active]="activeTab === t.id" (click)="showTab(t.id)">
<fa-icon [icon]="t.icon"></fa-icon> {{ t.label }}
</button>
}
</div>
<!-- TRENDS -->
@if (activeTab === 'trends') {
<div class="nrr-card">
<div class="nrr-card-h">
<span><fa-icon icon="chart-line" class="nrr-card-h-ic"></fa-icon> Daily Throughput (Last 7 Value Dates)</span>
<span class="nrr-pill nrr-pill--blue">NEFT</span>
<span class="nrr-pill nrr-pill--purple">RTGS</span>
</div>
<div class="nrr-trend">
@for (b of trendBuckets; track b.date) {
<div class="nrr-trend-col">
<div class="nrr-trend-bar-wrap">
<div class="nrr-trend-bar" [style.height.%]="trendBarHeight(b.amount)">
<span class="nrr-trend-amt">₹{{ b.amount | number: '1.0-0' }}</span>
</div>
</div>
<div class="nrr-trend-meta">
<div class="nrr-trend-date">{{ b.date | date: 'd MMM' }}</div>
<div class="nrr-trend-split">
<span class="nrr-pill nrr-pill--blue mini">N {{ b.neft }}</span>
<span class="nrr-pill nrr-pill--purple mini">R {{ b.rtgs }}</span>
</div>
</div>
</div>
}
</div>
</div>
}
<!-- BANK-WISE -->
@if (activeTab === 'bank') {
<div class="nrr-card">
<div class="nrr-card-h">
<span><fa-icon icon="university" class="nrr-card-h-ic"></fa-icon> Beneficiary Bank Distribution</span>
</div>
<div class="nrr-table-wrap">
<table class="nrr-table">
<thead>
<tr>
<th>Bank</th>
<th class="num">Count</th>
<th class="num">Amount</th>
<th class="num">Settled</th>
<th class="num">Pending</th>
<th class="num">Returned</th>
</tr>
</thead>
<tbody>
@for (r of bankRows; track r.bank) {
<tr>
<td>
<strong>{{ r.bank }}</strong>
</td>
<td class="num">{{ r.count | number }}</td>
<td class="num">
<strong>₹{{ r.amount | number: '1.0-0' }}</strong>
</td>
<td class="num">
<span class="nrr-pill nrr-pill--green mini">{{ r.settled }}</span>
</td>
<td class="num">
<span class="nrr-pill nrr-pill--amber mini">{{ r.pending }}</span>
</td>
<td class="num">
<span class="nrr-pill nrr-pill--red mini">{{ r.returned }}</span>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
<!-- DATE-WISE -->
@if (activeTab === 'date') {
<div class="nrr-card">
<div class="nrr-card-h">
<span><fa-icon icon="calendar-alt" class="nrr-card-h-ic"></fa-icon> Value-Date Aggregation</span>
</div>
<div class="nrr-table-wrap">
<table class="nrr-table">
<thead>
<tr>
<th>Value Date</th>
<th class="num">Count</th>
<th class="num">NEFT</th>
<th class="num">RTGS</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
@for (r of dateRows; track r.date) {
<tr>
<td>{{ r.date | date: 'EEE, d MMM y' }}</td>
<td class="num">{{ r.count | number }}</td>
<td class="num">
<span class="nrr-pill nrr-pill--blue mini">{{ r.neft }}</span>
</td>
<td class="num">
<span class="nrr-pill nrr-pill--purple mini">{{ r.rtgs }}</span>
</td>
<td class="num">
<strong>₹{{ r.amount | number: '1.0-0' }}</strong>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
<!-- PENDING / PASS / RETURN -->
@if (activeTab === 'pending') {
<div class="nrr-grid-2">
<div class="nrr-card">
<div class="nrr-card-h">
<span><fa-icon icon="tasks" class="nrr-card-h-ic"></fa-icon> Processing-State Funnel</span>
</div>
<div class="nrr-funnel">
<div class="nrr-funnel-row">
<span class="nrr-funnel-l"> <span class="nrr-dot nrr-dot--amber"></span> Uploaded / Validated / Sent </span>
<span class="nrr-funnel-v">{{ pendingCount | number }}</span>
</div>
<div class="nrr-funnel-row">
<span class="nrr-funnel-l"> <span class="nrr-dot nrr-dot--green"></span> Settled (Pass) </span>
<span class="nrr-funnel-v">{{ settledCount | number }}</span>
</div>
<div class="nrr-funnel-row">
<span class="nrr-funnel-l"> <span class="nrr-dot nrr-dot--red"></span> Returned </span>
<span class="nrr-funnel-v">{{ returnedCount | number }}</span>
</div>
<div class="nrr-funnel-meter">
<span class="seg green" [style.flex]="settledCount || 0"></span>
<span class="seg amber" [style.flex]="pendingCount || 0"></span>
<span class="seg red" [style.flex]="returnedCount || 0"></span>
</div>
</div>
</div>
<div class="nrr-card">
<div class="nrr-card-h">
<span><fa-icon icon="exclamation-triangle" class="nrr-card-h-ic"></fa-icon> Return-Code Breakdown</span>
</div>
@if (returnCodeRows.length === 0) {
<div class="nrr-empty">No returned transactions in current window.</div>
} @else {
<table class="nrr-table">
<thead>
<tr>
<th>Code</th>
<th>Reason</th>
<th class="num">Count</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
@for (r of returnCodeRows; track r.code) {
<tr>
<td>
<span class="nrr-pill nrr-pill--red mini">{{ r.code }}</span>
</td>
<td>{{ r.reason }}</td>
<td class="num">{{ r.count }}</td>
<td class="num">₹{{ r.amount | number: '1.0-0' }}</td>
</tr>
}
</tbody>
</table>
}
</div>
</div>
}
<!-- CHANNEL (BRANCH / MOBILE) -->
@if (activeTab === 'channel') {
<div class="nrr-card">
<div class="nrr-card-h">
<span><fa-icon icon="mobile-alt" class="nrr-card-h-ic"></fa-icon> Channel Mix — Branch vs Mobile</span>
</div>
<div class="nrr-channel-grid">
@for (r of channelRows; track r.channel) {
<div class="nrr-channel-card" [class.is-mobile]="r.channel === 'MOBILE'">
<div class="nrr-channel-ic">
<fa-icon [icon]="r.channel === 'MOBILE' ? 'mobile-alt' : 'building'"></fa-icon>
</div>
<div class="nrr-channel-body">
<div class="nrr-channel-t">
{{ r.channel === 'MOBILE' ? 'Mobile / Self-Service' : 'Branch / Counter' }}
</div>
<div class="nrr-channel-n">{{ r.count | number }} <span>txns</span></div>
<div class="nrr-channel-amt">₹{{ r.amount | number: '1.0-0' }}</div>
<div class="nrr-channel-bar">
<span [style.width.%]="r.share"></span>
</div>
<div class="nrr-channel-share">{{ r.share | number: '1.1-1' }}% of value</div>
</div>
</div>
}
</div>
</div>
}
<!-- USER-WISE -->
@if (activeTab === 'user') {
<div class="nrr-card">
<div class="nrr-card-h">
<span><fa-icon icon="user-tie" class="nrr-card-h-ic"></fa-icon> Maker / Operator Leaderboard</span>
</div>
<div class="nrr-table-wrap">
<table class="nrr-table">
<thead>
<tr>
<th>User</th>
<th>Branch</th>
<th class="num">Txns</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
@for (r of userRows; track r.user + r.branch) {
<tr>
<td>
<strong>{{ r.user }}</strong>
</td>
<td>{{ r.branch }}</td>
<td class="num">{{ r.count | number }}</td>
<td class="num">
<strong>₹{{ r.amount | number: '1.0-0' }}</strong>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
<!-- TRANSACTION DETAIL TABLE -->
<div class="nrr-card">
<div class="nrr-card-h">
<span><fa-icon icon="list-alt" class="nrr-card-h-ic"></fa-icon> Transactions</span>
<div class="nrr-filters">
<div class="nrr-search">
<fa-icon icon="search" class="nrr-search-ic"></fa-icon>
<input type="text" [(ngModel)]="search" placeholder="Search UTR, Batch, Instruction, A/C, IFSC, Bene Name…" />
</div>
<select [(ngModel)]="typeFilter" class="nrr-select" matTooltip="Filter by payment type">
<option value="">All Types</option>
<option value="NEFT">NEFT</option>
<option value="RTGS">RTGS</option>
</select>
<select [(ngModel)]="stateFilter" class="nrr-select" matTooltip="Filter by processing state">
<option value="">All States</option>
<option value="UPLOADED">Uploaded</option>
<option value="VALIDATED">Validated</option>
<option value="SENT">Sent</option>
<option value="SETTLED">Settled</option>
<option value="PENDING">Pending</option>
<option value="RETURNED">Returned</option>
</select>
@if (search || typeFilter || stateFilter) {
<button type="button" class="nrr-clear-btn" (click)="clearFilters()">
<fa-icon icon="times"></fa-icon> Clear
</button>
}
</div>
</div>
<div class="nrr-table-wrap">
<table class="nrr-table nrr-table-detail">
<thead>
<tr>
<th>Batch / Instruction</th>
<th>UTR</th>
<th>Remitter A/C</th>
<th>Beneficiary</th>
<th>IFSC</th>
<th>Type</th>
<th class="num">Amount</th>
<th>Value Date</th>
<th>Purpose</th>
<th>State</th>
<th>Sent</th>
</tr>
</thead>
<tbody>
@for (t of filteredTxns; track t.utrNumber) {
<tr>
<td>
<div class="mono small">{{ t.batchId }}</div>
<div class="mono small muted">{{ t.instructionId }}</div>
</td>
<td class="mono small">{{ t.utrNumber }}</td>
<td class="mono small">{{ t.remitterAcc }}</td>
<td>
<div>{{ t.beneName }}</div>
<div class="mono small muted">{{ t.beneAcc }} · {{ t.beneBank }}</div>
</td>
<td class="mono small">{{ t.beneIfsc }}</td>
<td>
<span
class="nrr-pill mini"
[class.nrr-pill--purple]="t.paymentType === 'RTGS'"
[class.nrr-pill--blue]="t.paymentType === 'NEFT'"
>
{{ t.paymentType }}
</span>
</td>
<td class="num">
<strong>₹{{ t.amount | number: '1.2-2' }}</strong>
</td>
<td>{{ t.valueDate | date: 'd MMM y' }}</td>
<td>
<span class="mono small">{{ t.purposeCode }}</span>
@if (t.leiCode) {
<span class="nrr-pill nrr-pill--teal mini" matTooltip="LEI: {{ t.leiCode }}">LEI</span>
}
</td>
<td>
<span class="nrr-pill mini" [ngClass]="'nrr-pill--' + stateColor(t.processingState)">
{{ t.processingState }}
</span>
@if (t.returnCode) {
<div class="small muted">{{ t.returnCode }} · {{ t.returnReason }}</div>
}
</td>
<td class="mono small">{{ t.sentTimestamp }}</td>
</tr>
}
@if (filteredTxns.length === 0) {
<tr>
<td colspan="11" class="nrr-empty">No transactions match the current filters.</td>
</tr>
}
</tbody>
</table>
</div>
</div>
<!-- FLOATING ACTION BUTTON -->
<button
type="button"
class="nrr-fab"
(click)="openInitiateDialog()"
matTooltip="Initiate a new NEFT / RTGS transfer"
matTooltipPosition="left"
>
<fa-icon icon="paper-plane"></fa-icon>
<span class="nrr-fab-label">Initiate NEFT / RTGS</span>
</button>
</div>
@@ -0,0 +1,795 @@
/**
* 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 {
--r-navy: #0b1e3d;
--r-blue: #1d4ed8;
--r-blue-l: #eff6ff;
--r-teal: #0e7e6b;
--r-teal-l: #e0f4f0;
--r-green: #15803d;
--r-green-l: #dcfce7;
--r-amber: #d97706;
--r-amber-l: #fef3c7;
--r-red: #c0392b;
--r-red-l: #fdecea;
--r-purple: #6d28d9;
--r-purple-l: #ede9fe;
--r-pink: #db2777;
--r-pink-l: #fce7f3;
--r-g50: #f8fafc;
--r-g100: #f1f5f9;
--r-g200: #e2e8f0;
--r-g300: #cbd5e1;
--r-g500: #64748b;
--r-g600: #475569;
--r-g700: #334155;
--r-g900: #0f172a;
display: block;
font-family:
'DM Sans',
system-ui,
-apple-system,
sans-serif;
background: var(--r-g50);
color: var(--r-g900);
font-size: 13.5px;
line-height: 1.5;
}
.nrr-root {
padding: 18px 22px 90px;
max-width: 1500px;
margin: 0 auto;
position: relative;
}
/* ───────── HEADER ───────── */
.nrr-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.nrr-head-left {
display: flex;
align-items: center;
gap: 14px;
}
.nrr-head-ic {
font-size: 26px;
color: var(--r-blue);
}
.nrr-head-t {
margin: 0;
font-size: 18px;
font-weight: 700;
color: var(--r-g900);
}
.nrr-head-s {
font-size: 12px;
color: var(--r-g500);
strong {
color: var(--r-g700);
}
}
.nrr-head-actions {
display: flex;
gap: 8px;
fa-icon {
margin-right: 6px;
}
}
/* ───────── KPIs ───────── */
.nrr-kpis {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 10px;
margin-bottom: 14px;
}
@media (width <= 1100px) {
.nrr-kpis {
grid-template-columns: repeat(3, 1fr);
}
}
@media (width <= 720px) {
.nrr-kpis {
grid-template-columns: repeat(2, 1fr);
}
}
.nrr-kpi {
background: #fff;
border: 1px solid var(--r-g200);
border-radius: 10px;
padding: 12px 14px;
display: flex;
align-items: center;
gap: 12px;
box-shadow: 0 1px 2px rgb(15 23 42 / 4%);
border-left-width: 4px;
}
.nrr-kpi-ic {
width: 36px;
height: 36px;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 16px;
flex-shrink: 0;
}
.nrr-kpi-n {
font-size: 18px;
font-weight: 700;
color: var(--r-g900);
line-height: 1.2;
}
.nrr-kpi-l {
font-size: 11px;
font-weight: 600;
color: var(--r-g700);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.nrr-kpi-sub {
font-size: 11px;
color: var(--r-g500);
}
.nrr-kpi--blue {
border-left-color: var(--r-blue);
.nrr-kpi-ic {
background: var(--r-blue-l);
color: var(--r-blue);
}
}
.nrr-kpi--teal {
border-left-color: var(--r-teal);
.nrr-kpi-ic {
background: var(--r-teal-l);
color: var(--r-teal);
}
}
.nrr-kpi--green {
border-left-color: var(--r-green);
.nrr-kpi-ic {
background: var(--r-green-l);
color: var(--r-green);
}
}
.nrr-kpi--amber {
border-left-color: var(--r-amber);
.nrr-kpi-ic {
background: var(--r-amber-l);
color: var(--r-amber);
}
}
.nrr-kpi--red {
border-left-color: var(--r-red);
.nrr-kpi-ic {
background: var(--r-red-l);
color: var(--r-red);
}
}
/* ───────── TABS ───────── */
.nrr-tabs {
display: flex;
gap: 4px;
background: #fff;
border: 1px solid var(--r-g200);
border-radius: 10px;
padding: 4px;
margin-bottom: 14px;
overflow-x: auto;
}
.nrr-tab {
display: inline-flex;
align-items: center;
gap: 6px;
background: transparent;
border: 0;
border-radius: 7px;
padding: 8px 14px;
font-size: 12.5px;
font-weight: 600;
color: var(--r-g600);
cursor: pointer;
white-space: nowrap;
transition: all 0.15s ease;
fa-icon {
font-size: 12px;
}
&:hover {
background: var(--r-g100);
color: var(--r-g900);
}
&.active {
background: var(--r-blue);
color: #fff;
}
}
/* ───────── CARDS ───────── */
.nrr-card {
background: #fff;
border: 1px solid var(--r-g200);
border-radius: 12px;
padding: 14px 16px;
margin-bottom: 14px;
box-shadow: 0 1px 2px rgb(15 23 42 / 4%);
}
.nrr-card-h {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
font-size: 13px;
font-weight: 700;
color: var(--r-g700);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 12px;
padding-bottom: 10px;
border-bottom: 1px dashed var(--r-g200);
}
.nrr-card-h-ic {
color: var(--r-blue);
font-size: 13px;
margin-right: 4px;
}
.nrr-grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
@media (width <= 900px) {
grid-template-columns: 1fr;
}
}
/* ───────── PILLS ───────── */
.nrr-pill {
display: inline-flex;
align-items: center;
font-size: 11px;
font-weight: 700;
padding: 3px 9px;
border-radius: 999px;
text-transform: uppercase;
letter-spacing: 0.04em;
&.mini {
font-size: 10px;
padding: 2px 7px;
}
&.nrr-pill--blue {
background: var(--r-blue-l);
color: var(--r-blue);
}
&.nrr-pill--teal {
background: var(--r-teal-l);
color: var(--r-teal);
}
&.nrr-pill--green {
background: var(--r-green-l);
color: var(--r-green);
}
&.nrr-pill--amber {
background: var(--r-amber-l);
color: var(--r-amber);
}
&.nrr-pill--red {
background: var(--r-red-l);
color: var(--r-red);
}
&.nrr-pill--purple {
background: var(--r-purple-l);
color: var(--r-purple);
}
}
/* ───────── TREND CHART ───────── */
.nrr-trend {
display: flex;
align-items: flex-end;
gap: 12px;
min-height: 220px;
padding: 10px 4px;
}
.nrr-trend-col {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
min-width: 60px;
}
.nrr-trend-bar-wrap {
width: 100%;
height: 160px;
display: flex;
align-items: flex-end;
justify-content: center;
}
.nrr-trend-bar {
width: 75%;
min-height: 6px;
background: linear-gradient(180deg, var(--r-blue) 0%, var(--r-purple) 100%);
border-radius: 6px 6px 0 0;
position: relative;
transition: height 0.3s ease;
}
.nrr-trend-amt {
position: absolute;
top: -22px;
left: 50%;
transform: translateX(-50%);
font-size: 11px;
font-weight: 700;
color: var(--r-g700);
white-space: nowrap;
}
.nrr-trend-meta {
text-align: center;
display: flex;
flex-direction: column;
gap: 4px;
}
.nrr-trend-date {
font-size: 11px;
font-weight: 600;
color: var(--r-g700);
}
.nrr-trend-split {
display: flex;
gap: 4px;
justify-content: center;
}
/* ───────── TABLE ───────── */
.nrr-table-wrap {
overflow-x: auto;
}
.nrr-table {
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
thead th {
background: var(--r-g100);
color: var(--r-g700);
text-align: left;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 8px 10px;
border-bottom: 1px solid var(--r-g200);
white-space: nowrap;
&.num {
text-align: right;
}
}
tbody td {
padding: 8px 10px;
border-bottom: 1px solid var(--r-g100);
vertical-align: top;
&.num {
text-align: right;
}
}
tbody tr:hover {
background: var(--r-blue-l);
}
}
.nrr-table-detail {
font-size: 12px;
tbody td {
padding: 7px 10px;
}
}
.mono {
font-family: 'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace;
font-size: 12px;
}
.small {
font-size: 11.5px;
}
.muted {
color: var(--r-g500);
}
/* ───────── FUNNEL ───────── */
.nrr-funnel {
padding: 8px 4px;
}
.nrr-funnel-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
border-bottom: 1px dashed var(--r-g200);
&:last-of-type {
border-bottom: 0;
}
}
.nrr-funnel-l {
display: inline-flex;
align-items: center;
gap: 8px;
font-weight: 600;
color: var(--r-g700);
}
.nrr-funnel-v {
font-size: 16px;
font-weight: 700;
color: var(--r-g900);
}
.nrr-dot {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
&.nrr-dot--green {
background: var(--r-green);
}
&.nrr-dot--amber {
background: var(--r-amber);
}
&.nrr-dot--red {
background: var(--r-red);
}
}
.nrr-funnel-meter {
display: flex;
height: 10px;
border-radius: 999px;
overflow: hidden;
margin-top: 12px;
background: var(--r-g100);
.seg {
display: block;
&.green {
background: var(--r-green);
}
&.amber {
background: var(--r-amber);
}
&.red {
background: var(--r-red);
}
}
}
/* ───────── CHANNEL CARDS ───────── */
.nrr-channel-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
@media (width <= 720px) {
grid-template-columns: 1fr;
}
}
.nrr-channel-card {
background: linear-gradient(135deg, var(--r-blue-l) 0%, #fff 100%);
border: 1px solid #dbeafe;
border-radius: 12px;
padding: 16px 18px;
display: flex;
gap: 14px;
align-items: flex-start;
&.is-mobile {
background: linear-gradient(135deg, var(--r-purple-l) 0%, #fff 100%);
border-color: #ddd6fe;
.nrr-channel-ic {
background: var(--r-purple);
}
}
}
.nrr-channel-ic {
width: 44px;
height: 44px;
border-radius: 10px;
background: var(--r-blue);
color: #fff;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 18px;
}
.nrr-channel-body {
flex: 1;
}
.nrr-channel-t {
font-size: 12px;
font-weight: 700;
color: var(--r-g700);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 4px;
}
.nrr-channel-n {
font-size: 22px;
font-weight: 700;
color: var(--r-g900);
line-height: 1.1;
span {
font-size: 12px;
font-weight: 600;
color: var(--r-g500);
}
}
.nrr-channel-amt {
font-size: 13px;
font-weight: 600;
color: var(--r-g700);
margin: 6px 0;
}
.nrr-channel-bar {
background: rgb(0 0 0 / 6%);
height: 6px;
border-radius: 999px;
overflow: hidden;
span {
display: block;
height: 100%;
background: var(--r-blue);
border-radius: 999px;
transition: width 0.3s ease;
}
}
.is-mobile .nrr-channel-bar span {
background: var(--r-purple);
}
.nrr-channel-share {
font-size: 11px;
color: var(--r-g500);
margin-top: 4px;
}
/* ───────── FILTER ROW ───────── */
.nrr-filters {
margin-left: auto;
display: flex;
gap: 6px;
align-items: center;
flex-wrap: wrap;
text-transform: none;
letter-spacing: 0;
}
.nrr-search {
position: relative;
display: inline-flex;
align-items: center;
input {
border: 1px solid var(--r-g200);
border-radius: 8px;
padding: 6px 10px 6px 28px;
font-size: 12px;
min-width: 240px;
background: #fff;
color: var(--r-g900);
&:focus {
outline: none;
border-color: var(--r-blue);
box-shadow: 0 0 0 2px rgb(29 78 216 / 15%);
}
}
}
.nrr-search-ic {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
color: var(--r-g500);
font-size: 11px;
pointer-events: none;
}
.nrr-select {
border: 1px solid var(--r-g200);
border-radius: 8px;
padding: 6px 10px;
font-size: 12px;
background: #fff;
color: var(--r-g900);
cursor: pointer;
&:focus {
outline: none;
border-color: var(--r-blue);
}
}
.nrr-clear-btn {
background: var(--r-red-l);
color: var(--r-red);
border: 1px solid #fecaca;
border-radius: 999px;
padding: 5px 12px;
font-size: 11px;
font-weight: 600;
cursor: pointer;
text-transform: uppercase;
letter-spacing: 0.04em;
fa-icon {
margin-right: 4px;
}
&:hover {
background: var(--r-red);
color: #fff;
}
}
.nrr-empty {
padding: 22px 0;
text-align: center;
color: var(--r-g500);
font-size: 13px;
}
/* ───────── ENTRY DIALOG PANEL OVERRIDE ───────── */
:host ::ng-deep .nr-entry-dialog {
.mat-mdc-dialog-surface {
padding: 0 !important;
border-radius: 14px !important;
overflow: hidden !important;
box-shadow:
0 24px 48px rgb(15 23 42 / 28%),
0 8px 16px rgb(15 23 42 / 18%) !important;
}
.mat-mdc-dialog-container .mdc-dialog__container,
.mat-mdc-dialog-container .mdc-dialog__surface {
padding: 0 !important;
}
.mat-mdc-dialog-content {
padding: 0 !important;
margin: 0 !important;
max-height: none !important;
}
}
:host ::ng-deep .nr-entry-dialog-backdrop {
background: rgb(11 30 61 / 55%);
}
/* ───────── FAB ───────── */
.nrr-fab {
position: fixed;
right: 28px;
bottom: 28px;
background: linear-gradient(135deg, var(--r-blue) 0%, var(--r-purple) 100%);
color: #fff;
border: 0;
border-radius: 999px;
padding: 14px 22px;
font-size: 13.5px;
font-weight: 700;
display: inline-flex;
align-items: center;
gap: 10px;
cursor: pointer;
box-shadow:
0 10px 24px rgb(29 78 216 / 35%),
0 4px 8px rgb(15 23 42 / 18%);
transition: all 0.18s ease;
z-index: 50;
fa-icon {
font-size: 16px;
}
.nrr-fab-label {
text-transform: uppercase;
letter-spacing: 0.05em;
}
&:hover {
transform: translateY(-2px);
box-shadow:
0 14px 30px rgb(29 78 216 / 45%),
0 6px 12px rgb(15 23 42 / 22%);
}
&:active {
transform: translateY(0);
}
}
@@ -0,0 +1,747 @@
/**
* 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, inject, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatDialog } from '@angular/material/dialog';
import { MatTooltip } from '@angular/material/tooltip';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { NeftRtgsEntryComponent } from '../neft-rtgs-entry/neft-rtgs-entry.component';
export type PaymentType = 'NEFT' | 'RTGS';
export type ProcessingState = 'UPLOADED' | 'VALIDATED' | 'SENT' | 'SETTLED' | 'PENDING' | 'RETURNED';
export interface NeftRtgsTxn {
batchId: string;
instructionId: string;
utrNumber: string;
remitterAcc: string;
remitterBranch: string;
remitterUser: string;
remitterChannel: 'BRANCH' | 'MOBILE';
beneAcc: string;
beneIfsc: string;
beneName: string;
beneBank: string;
paymentType: PaymentType;
amount: number;
valueDate: string; // YYYY-MM-DD
purposeCode: string;
leiCode?: string;
processingState: ProcessingState;
returnCode?: string;
returnReason?: string;
sentTimestamp: string; // YYYY-MM-DD HH:mm:ss
}
type ReportTab = 'trends' | 'bank' | 'date' | 'pending' | 'channel' | 'user';
interface BankRow {
bank: string;
count: number;
amount: number;
settled: number;
pending: number;
returned: number;
}
interface DateRow {
date: string;
count: number;
amount: number;
neft: number;
rtgs: number;
}
interface UserRow {
user: string;
count: number;
amount: number;
branch: string;
}
interface ChannelRow {
channel: 'BRANCH' | 'MOBILE';
count: number;
amount: number;
share: number;
}
@Component({
selector: 'mifosx-neft-rtgs-report',
templateUrl: './neft-rtgs-report.component.html',
styleUrls: ['./neft-rtgs-report.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule,
FaIconComponent,
MatTooltip
]
})
export class NeftRtgsReportComponent implements OnInit {
private dialog = inject(MatDialog);
/** Active tab */
activeTab: ReportTab = 'trends';
/** Search / filter */
search = '';
typeFilter: '' | PaymentType = '';
stateFilter: '' | ProcessingState = '';
/** All transactions (mock data — replace with API). */
txns: NeftRtgsTxn[] = [];
/** Tab labels */
readonly tabs: { id: ReportTab; label: string; icon: string }[] = [
{ id: 'trends', label: 'Trends', icon: 'chart-line' },
{ id: 'bank', label: 'Bank-wise', icon: 'university' },
{ id: 'date', label: 'Date-wise', icon: 'calendar-alt' },
{ id: 'pending', label: 'Pending / Pass / Return', icon: 'tasks' },
{ id: 'channel', label: 'Branch / Mobile', icon: 'mobile-alt' },
{ id: 'user', label: 'User-wise', icon: 'user-tie' }
];
ngOnInit(): void {
this.txns = this.seedTransactions();
}
// ───────────────────── KPIs ─────────────────────
get totalCount(): number {
return this.txns.length;
}
get totalAmount(): number {
return this.txns.reduce((s, t) => s + t.amount, 0);
}
get settledCount(): number {
return this.txns.filter((t) => t.processingState === 'SETTLED').length;
}
get pendingCount(): number {
return this.txns.filter((t) => [
'UPLOADED',
'VALIDATED',
'SENT',
'PENDING'
].includes(t.processingState)).length;
}
get returnedCount(): number {
return this.txns.filter((t) => t.processingState === 'RETURNED').length;
}
get neftCount(): number {
return this.txns.filter((t) => t.paymentType === 'NEFT').length;
}
get rtgsCount(): number {
return this.txns.filter((t) => t.paymentType === 'RTGS').length;
}
get neftAmount(): number {
return this.txns.filter((t) => t.paymentType === 'NEFT').reduce((s, t) => s + t.amount, 0);
}
get rtgsAmount(): number {
return this.txns.filter((t) => t.paymentType === 'RTGS').reduce((s, t) => s + t.amount, 0);
}
get successRate(): number {
if (this.totalCount === 0) return 0;
return (this.settledCount / this.totalCount) * 100;
}
get avgTicket(): number {
if (this.totalCount === 0) return 0;
return this.totalAmount / this.totalCount;
}
// ───────────────────── Filtered list ─────────────────────
get filteredTxns(): NeftRtgsTxn[] {
const q = this.search.trim().toLowerCase();
return this.txns.filter((t) => {
if (this.typeFilter && t.paymentType !== this.typeFilter) return false;
if (this.stateFilter && t.processingState !== this.stateFilter) return false;
if (!q) return true;
return (
t.utrNumber.toLowerCase().includes(q) ||
t.batchId.toLowerCase().includes(q) ||
t.instructionId.toLowerCase().includes(q) ||
t.beneAcc.toLowerCase().includes(q) ||
t.beneIfsc.toLowerCase().includes(q) ||
t.beneName.toLowerCase().includes(q) ||
t.remitterAcc.toLowerCase().includes(q)
);
});
}
// ───────────────────── Tab-specific aggregations ─────────────────────
get bankRows(): BankRow[] {
const map = new Map<string, BankRow>();
for (const t of this.txns) {
const key = t.beneBank;
if (!map.has(key)) {
map.set(key, { bank: key, count: 0, amount: 0, settled: 0, pending: 0, returned: 0 });
}
const r = map.get(key)!;
r.count++;
r.amount += t.amount;
if (t.processingState === 'SETTLED') r.settled++;
else if (t.processingState === 'RETURNED') r.returned++;
else r.pending++;
}
return Array.from(map.values()).sort((a, b) => b.amount - a.amount);
}
get dateRows(): DateRow[] {
const map = new Map<string, DateRow>();
for (const t of this.txns) {
if (!map.has(t.valueDate)) {
map.set(t.valueDate, { date: t.valueDate, count: 0, amount: 0, neft: 0, rtgs: 0 });
}
const r = map.get(t.valueDate)!;
r.count++;
r.amount += t.amount;
if (t.paymentType === 'NEFT') r.neft++;
else r.rtgs++;
}
return Array.from(map.values()).sort((a, b) => (a.date < b.date ? 1 : -1));
}
get userRows(): UserRow[] {
const map = new Map<string, UserRow>();
for (const t of this.txns) {
const key = `${t.remitterUser}|${t.remitterBranch}`;
if (!map.has(key)) {
map.set(key, { user: t.remitterUser, branch: t.remitterBranch, count: 0, amount: 0 });
}
const r = map.get(key)!;
r.count++;
r.amount += t.amount;
}
return Array.from(map.values()).sort((a, b) => b.amount - a.amount);
}
get channelRows(): ChannelRow[] {
const total = this.totalAmount || 1;
const map = new Map<'BRANCH' | 'MOBILE', ChannelRow>();
for (const t of this.txns) {
if (!map.has(t.remitterChannel)) {
map.set(t.remitterChannel, {
channel: t.remitterChannel,
count: 0,
amount: 0,
share: 0
});
}
const r = map.get(t.remitterChannel)!;
r.count++;
r.amount += t.amount;
}
map.forEach((r) => (r.share = (r.amount / total) * 100));
return Array.from(map.values());
}
/** Trend buckets — last 7 value-dates */
get trendBuckets(): DateRow[] {
return this.dateRows.slice(0, 7).reverse();
}
/** Max amount across trend buckets (for bar chart scaling). */
get trendMaxAmount(): number {
const buckets = this.trendBuckets;
if (!buckets.length) return 1;
return Math.max(...buckets.map((b) => b.amount));
}
/** Return-code breakdown for Pending/Pass/Return tab */
get returnCodeRows(): { code: string; reason: string; count: number; amount: number }[] {
const map = new Map<string, { code: string; reason: string; count: number; amount: number }>();
for (const t of this.txns) {
if (t.processingState !== 'RETURNED' || !t.returnCode) continue;
if (!map.has(t.returnCode)) {
map.set(t.returnCode, {
code: t.returnCode,
reason: t.returnReason || '',
count: 0,
amount: 0
});
}
const r = map.get(t.returnCode)!;
r.count++;
r.amount += t.amount;
}
return Array.from(map.values()).sort((a, b) => b.count - a.count);
}
// ───────────────────── Actions ─────────────────────
showTab(tab: ReportTab): void {
this.activeTab = tab;
}
/** Floating action — open NEFT/RTGS entry in a non-dismissible dialog. */
openInitiateDialog(): void {
const ref = this.dialog.open(NeftRtgsEntryComponent, {
width: '880px',
maxWidth: '94vw',
maxHeight: '88vh',
disableClose: true,
autoFocus: false,
restoreFocus: true,
panelClass: 'nr-entry-dialog',
backdropClass: 'nr-entry-backdrop'
});
ref.afterClosed().subscribe((result) => {
if (result && result.submitted && result.payload) {
this.txns = [
this.toTxn(result.payload),
...this.txns
];
}
});
}
exportCsv(): void {
const headers = [
'Batch ID',
'Instruction ID',
'UTR Number',
'Remitter Acc',
'Bene Acc No',
'Bene IFSC',
'Bene Name',
'Payment Type',
'Amount',
'Value Date',
'Purpose Code',
'LEI Code',
'Processing State',
'Return Code',
'Sent Timestamp'
];
const rows: string[][] = [headers];
for (const t of this.filteredTxns) {
rows.push([
t.batchId,
t.instructionId,
t.utrNumber,
t.remitterAcc,
t.beneAcc,
t.beneIfsc,
t.beneName,
t.paymentType,
t.amount.toFixed(2),
t.valueDate,
t.purposeCode,
t.leiCode || '',
t.processingState,
t.returnCode || '',
t.sentTimestamp
]);
}
const escape = (v: string): string => {
const s = (v || '').replace(/"/g, '""');
return /[,"\n]/.test(s) ? `"${s}"` : s;
};
const csv = rows.map((r) => r.map(escape).join(',')).join('\n') + '\n';
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `NEFT_RTGS_Report_${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(url);
}
clearFilters(): void {
this.search = '';
this.typeFilter = '';
this.stateFilter = '';
}
// ───────────────────── Helpers ─────────────────────
stateColor(state: ProcessingState): string {
switch (state) {
case 'SETTLED':
return 'green';
case 'RETURNED':
return 'red';
case 'SENT':
return 'blue';
case 'VALIDATED':
return 'teal';
case 'UPLOADED':
return 'amber';
case 'PENDING':
return 'amber';
}
}
trendBarHeight(amount: number): number {
return Math.max(6, Math.round((amount / this.trendMaxAmount) * 100));
}
/** Convert a submitted entry-form payload into a synthetic transaction row. */
private toTxn(p: any): NeftRtgsTxn {
const valueDate = p.valueDate
? new Date(p.valueDate).toISOString().slice(0, 10)
: new Date().toISOString().slice(0, 10);
const ts = new Date();
const pad = (n: number) => String(n).padStart(2, '0');
return {
batchId: `H2H-${valueDate.replace(/-/g, '')}-${pad(Math.floor(Math.random() * 99) + 1)}`,
instructionId: 'ERP' + Date.now().toString().slice(-8),
utrNumber: 'KKBK' + Date.now().toString().slice(-11),
remitterAcc: p.senderAccount || '',
remitterBranch: 'Main Branch',
remitterUser: 'Current User',
remitterChannel: 'BRANCH',
beneAcc: p.beneficiaryAccount || '',
beneIfsc: (p.ifscCode || '').toUpperCase(),
beneName: p.beneficiaryName || '',
beneBank: this.bankFromIfsc(p.ifscCode || ''),
paymentType: p.transactionType,
amount: Number(p.amount) || 0,
valueDate,
purposeCode: this.purposeCodeFor(p.purposeOfRemittance),
leiCode: Number(p.amount) >= 500000000 ? '549300ABCDEF1234' : undefined,
processingState: 'UPLOADED',
sentTimestamp:
ts.getFullYear() +
'-' +
pad(ts.getMonth() + 1) +
'-' +
pad(ts.getDate()) +
' ' +
pad(ts.getHours()) +
':' +
pad(ts.getMinutes()) +
':' +
pad(ts.getSeconds())
};
}
private bankFromIfsc(ifsc: string): string {
const code = ifsc.slice(0, 4).toUpperCase();
const map: Record<string, string> = {
HDFC: 'HDFC Bank',
ICIC: 'ICICI Bank',
SBIN: 'State Bank of India',
KKBK: 'Kotak Mahindra Bank',
AXIS: 'Axis Bank',
UTIB: 'Axis Bank',
PUNB: 'Punjab National Bank',
BARB: 'Bank of Baroda',
CNRB: 'Canara Bank',
YESB: 'YES Bank',
IDFB: 'IDFC FIRST Bank'
};
return map[code] || code + ' Bank';
}
private purposeCodeFor(p: string): string {
const map: Record<string, string> = {
CAPITAL_TRANSFER: 'P0102',
FAMILY_MAINTENANCE: 'P1301',
TRADE_SETTLEMENT: 'P0801',
SALARY_PAYMENT: 'P1006',
LOAN_REPAYMENT: 'P0103',
TAX_PAYMENT: 'P1099',
VENDOR_PAYMENT: 'P0802',
INVESTMENT: 'P0202',
GIFT: 'P1302',
OTHER: 'P0099'
};
return map[p] || 'P0099';
}
// ───────────────────── Mock seed data ─────────────────────
private seedTransactions(): NeftRtgsTxn[] {
const today = new Date();
const dt = (offset: number, h = 10, m = 0): string => {
const d = new Date(today);
d.setDate(d.getDate() - offset);
const pad = (n: number) => String(n).padStart(2, '0');
return (
d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + pad(h) + ':' + pad(m) + ':00'
);
};
const vd = (offset: number): string => dt(offset).slice(0, 10);
return [
{
batchId: 'H2H-20260326-01',
instructionId: 'ERP77889922',
utrNumber: 'KKBKR5202608511',
remitterAcc: '50200012345678',
remitterBranch: 'Mumbai — BKC',
remitterUser: 'Anita Desai',
remitterChannel: 'BRANCH',
beneAcc: '91000222333444',
beneIfsc: 'HDFC0001234',
beneName: 'Global Logistics Ltd',
beneBank: 'HDFC Bank',
paymentType: 'RTGS',
amount: 550000,
valueDate: vd(0),
purposeCode: 'P0102',
leiCode: '549300ABCDEF1234',
processingState: 'SETTLED',
sentTimestamp: dt(0, 14, 5)
},
{
batchId: 'H2H-20260326-01',
instructionId: 'ERP77889923',
utrNumber: 'HDFCR5202608712',
remitterAcc: '50200012345678',
remitterBranch: 'Mumbai — BKC',
remitterUser: 'Anita Desai',
remitterChannel: 'BRANCH',
beneAcc: '345678901234',
beneIfsc: 'ICIC0000567',
beneName: 'Bluestone Trading Co.',
beneBank: 'ICICI Bank',
paymentType: 'NEFT',
amount: 78500,
valueDate: vd(0),
purposeCode: 'P0802',
processingState: 'SENT',
sentTimestamp: dt(0, 14, 6)
},
{
batchId: 'H2H-20260325-04',
instructionId: 'ERP77889811',
utrNumber: 'SBINR5202608902',
remitterAcc: '50200099887766',
remitterBranch: 'Bengaluru — MG Road',
remitterUser: 'Karthik Rao',
remitterChannel: 'MOBILE',
beneAcc: '912345678901',
beneIfsc: 'SBIN0011223',
beneName: 'Anita Verma',
beneBank: 'State Bank of India',
paymentType: 'NEFT',
amount: 15000,
valueDate: vd(1),
purposeCode: 'P1301',
processingState: 'SETTLED',
sentTimestamp: dt(1, 11, 22)
},
{
batchId: 'H2H-20260325-04',
instructionId: 'ERP77889812',
utrNumber: 'AXISR5202608811',
remitterAcc: '50200099887766',
remitterBranch: 'Bengaluru — MG Road',
remitterUser: 'Karthik Rao',
remitterChannel: 'MOBILE',
beneAcc: '6789012345',
beneIfsc: 'UTIB0000456',
beneName: 'Rakesh Industries',
beneBank: 'Axis Bank',
paymentType: 'RTGS',
amount: 1250000,
valueDate: vd(1),
purposeCode: 'P0801',
leiCode: '549300XYZAB7890',
processingState: 'RETURNED',
returnCode: 'R01',
returnReason: 'Insufficient Funds',
sentTimestamp: dt(1, 11, 25)
},
{
batchId: 'H2H-20260324-02',
instructionId: 'ERP77889701',
utrNumber: 'PUNBR5202608619',
remitterAcc: '50100221122334',
remitterBranch: 'Delhi — Connaught',
remitterUser: 'Sushma Iyer',
remitterChannel: 'BRANCH',
beneAcc: '50100998877665',
beneIfsc: 'PUNB0123400',
beneName: 'Madhavi Singh',
beneBank: 'Punjab National Bank',
paymentType: 'NEFT',
amount: 42000,
valueDate: vd(2),
purposeCode: 'P1006',
processingState: 'SETTLED',
sentTimestamp: dt(2, 9, 35)
},
{
batchId: 'H2H-20260324-02',
instructionId: 'ERP77889702',
utrNumber: 'YESBR5202608520',
remitterAcc: '50100221122334',
remitterBranch: 'Delhi — Connaught',
remitterUser: 'Sushma Iyer',
remitterChannel: 'BRANCH',
beneAcc: '11223344556677',
beneIfsc: 'YESB0000123',
beneName: 'Sigma Constructions',
beneBank: 'YES Bank',
paymentType: 'RTGS',
amount: 825000,
valueDate: vd(2),
purposeCode: 'P0102',
processingState: 'PENDING',
sentTimestamp: dt(2, 9, 38)
},
{
batchId: 'H2H-20260323-07',
instructionId: 'ERP77889601',
utrNumber: 'CNRBR5202608417',
remitterAcc: '50100887766554',
remitterBranch: 'Chennai — T. Nagar',
remitterUser: 'Ramesh Krishnan',
remitterChannel: 'MOBILE',
beneAcc: '99887766554433',
beneIfsc: 'CNRB0001890',
beneName: 'Padma Rao',
beneBank: 'Canara Bank',
paymentType: 'NEFT',
amount: 9800,
valueDate: vd(3),
purposeCode: 'P1302',
processingState: 'SETTLED',
sentTimestamp: dt(3, 16, 15)
},
{
batchId: 'H2H-20260323-07',
instructionId: 'ERP77889602',
utrNumber: 'BARBR5202608318',
remitterAcc: '50100887766554',
remitterBranch: 'Chennai — T. Nagar',
remitterUser: 'Ramesh Krishnan',
remitterChannel: 'BRANCH',
beneAcc: '50100123456987',
beneIfsc: 'BARB0VJVATA',
beneName: 'Vijaya Traders',
beneBank: 'Bank of Baroda',
paymentType: 'NEFT',
amount: 125000,
valueDate: vd(3),
purposeCode: 'P0802',
processingState: 'VALIDATED',
sentTimestamp: dt(3, 16, 20)
},
{
batchId: 'H2H-20260322-03',
instructionId: 'ERP77889501',
utrNumber: 'IDFBR5202608215',
remitterAcc: '50200445566778',
remitterBranch: 'Pune — Koregaon Park',
remitterUser: 'Vikram Joshi',
remitterChannel: 'BRANCH',
beneAcc: '70010023456789',
beneIfsc: 'IDFB0040101',
beneName: 'Pinnacle Realty Pvt Ltd',
beneBank: 'IDFC FIRST Bank',
paymentType: 'RTGS',
amount: 3450000,
valueDate: vd(4),
purposeCode: 'P0202',
leiCode: '549300QRSTUV5678',
processingState: 'SETTLED',
sentTimestamp: dt(4, 12, 45)
},
{
batchId: 'H2H-20260322-03',
instructionId: 'ERP77889502',
utrNumber: 'HDFCR5202608116',
remitterAcc: '50200445566778',
remitterBranch: 'Pune — Koregaon Park',
remitterUser: 'Vikram Joshi',
remitterChannel: 'MOBILE',
beneAcc: '50100654321789',
beneIfsc: 'HDFC0009876',
beneName: 'Neha Kapoor',
beneBank: 'HDFC Bank',
paymentType: 'NEFT',
amount: 25000,
valueDate: vd(4),
purposeCode: 'P1301',
processingState: 'SETTLED',
sentTimestamp: dt(4, 12, 50)
},
{
batchId: 'H2H-20260321-05',
instructionId: 'ERP77889411',
utrNumber: 'KKBKR5202608013',
remitterAcc: '50200778899001',
remitterBranch: 'Hyderabad — Banjara',
remitterUser: 'Lakshmi Naidu',
remitterChannel: 'BRANCH',
beneAcc: '12345678900011',
beneIfsc: 'KKBK0001899',
beneName: 'Aarav Enterprises',
beneBank: 'Kotak Mahindra Bank',
paymentType: 'NEFT',
amount: 67000,
valueDate: vd(5),
purposeCode: 'P0802',
processingState: 'RETURNED',
returnCode: 'R03',
returnReason: 'Account Closed',
sentTimestamp: dt(5, 10, 5)
},
{
batchId: 'H2H-20260321-05',
instructionId: 'ERP77889412',
utrNumber: 'SBINR5202607914',
remitterAcc: '50200778899001',
remitterBranch: 'Hyderabad — Banjara',
remitterUser: 'Lakshmi Naidu',
remitterChannel: 'BRANCH',
beneAcc: '50100445566778',
beneIfsc: 'SBIN0008765',
beneName: 'Spectra Components',
beneBank: 'State Bank of India',
paymentType: 'RTGS',
amount: 980000,
valueDate: vd(5),
purposeCode: 'P0801',
leiCode: '549300LMNOPQ2468',
processingState: 'SETTLED',
sentTimestamp: dt(5, 10, 10)
},
{
batchId: 'H2H-20260320-06',
instructionId: 'ERP77889301',
utrNumber: 'ICICR5202607812',
remitterAcc: '50100334455667',
remitterBranch: 'Kolkata — Park Street',
remitterUser: 'Arjun Banerjee',
remitterChannel: 'MOBILE',
beneAcc: '88990011223344',
beneIfsc: 'ICIC0001234',
beneName: 'Eastern Logistics',
beneBank: 'ICICI Bank',
paymentType: 'NEFT',
amount: 188000,
valueDate: vd(6),
purposeCode: 'P0802',
processingState: 'SETTLED',
sentTimestamp: dt(6, 14, 30)
},
{
batchId: 'H2H-20260320-06',
instructionId: 'ERP77889302',
utrNumber: 'AXISR5202607711',
remitterAcc: '50100334455667',
remitterBranch: 'Kolkata — Park Street',
remitterUser: 'Arjun Banerjee',
remitterChannel: 'BRANCH',
beneAcc: '50200998877665',
beneIfsc: 'UTIB0001234',
beneName: 'Indrani Sen',
beneBank: 'Axis Bank',
paymentType: 'NEFT',
amount: 5500,
valueDate: vd(6),
purposeCode: 'P1302',
processingState: 'SETTLED',
sentTimestamp: dt(6, 14, 32)
}
];
}
}