feat: letter-mgmt module + digital UPI/IMPS/ATM-POS reports + header redesign
letter-mgmt (new module): - Notice page: overdue loan list with filters, KPI strip, bulk actions, call disposition logging (Promise-to-Pay, Not Reachable, etc.), Speed Post dispatch dialog with consignment + post-office tracking, PDF preview + download, call-contact dialog with primary / alt / family / nominee numbers and per-contact disposition history. - Certificates: Loan Interest, Share, Balance, Form 60 (no-PAN flow), Form 15G/H (auto age-based), Hypothecation, NOC - each with shared preview dialog (six tone variants) and jsPDF generator using a common letterhead / summary-grid / signature-seal helper. digital: - IMPS module: report (P2A/P2P/Aadhaar tabs) + entry form with MMID/mobile lookup, NPCI switch resolve-to-A/C+IFSC BANL flow, sender autocomplete, hide sender field for P2P/Aadhaar, charges + 18% GST + waive checkbox, per-sender today-only history dialog. - UPI report with app-mix tab and full transaction filtering. - ATM/POS/ECOM hub with three ticket cards routing to today/status/cycle. - NEFT/RTGS entry: sender-account autocomplete, charges + GST + waive, field reorder (sender before amount), NEFT cap / RTGS floor validation, History button opening per-sender today's transactions. - NEFT/RTGS report: horizontal-bar trend leaderboard replacing tall bars. shell + theme: - Header redesigned with IBM Plex Sans 12/500 typography, 11-column grid per row, top-accent hover indicator, bordered cells via grid-gap. - Live KPI ticker below menu (CASH, TXN TODAY, CLEARING IN/OUT, NPA, CASA, CD RATIO, RBI CIRC, REPO) with marquee scroll, pause-on-hover, brand-blue gradient, amber bottom strip, pulsing red LIVE chip. - Sidebar 'KYC Management' -> 'KYC & ReKYC Management'. - Letter Mgmt / certificate menu items wired with green icon hints for items that have a working page. access-control: - MFA card layout compacted; method header inlines back button + title. icons: - Register faTruck/TruckFast, faEllipsisV/H, faCommentDots. - Add IBM Plex Sans Google Fonts import to index.html. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
4322f37c6d
commit
325228c514
@@ -0,0 +1,60 @@
|
||||
<!--
|
||||
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="ape-root">
|
||||
<!-- HEADER -->
|
||||
<div class="ape-head">
|
||||
<div class="ape-head-l">
|
||||
<fa-icon icon="credit-card" class="ape-head-ic"></fa-icon>
|
||||
<div>
|
||||
<h2 class="ape-head-t">ATM / POS / E-Commerce — Reports</h2>
|
||||
<div class="ape-head-s">
|
||||
Pick a report style. Tickets open the matching analytics view for reconciliation and ops review.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TICKETS -->
|
||||
<div class="ape-tickets">
|
||||
@for (t of tickets; track t.kind) {
|
||||
<button
|
||||
type="button"
|
||||
class="ape-ticket"
|
||||
[class.accent-blue]="t.accent === 'blue'"
|
||||
[class.accent-green]="t.accent === 'green'"
|
||||
[class.accent-purple]="t.accent === 'purple'"
|
||||
(click)="openReport(t)"
|
||||
[attr.aria-label]="t.title"
|
||||
[matTooltip]="t.cta"
|
||||
matTooltipPosition="below"
|
||||
>
|
||||
<!-- Punched-edge ticket "tear" — decorative -->
|
||||
<span class="ape-ticket-notch ape-ticket-notch--l"></span>
|
||||
<span class="ape-ticket-notch ape-ticket-notch--r"></span>
|
||||
|
||||
<div class="ape-ticket-top">
|
||||
<div class="ape-ticket-icon">
|
||||
<fa-icon [icon]="t.icon"></fa-icon>
|
||||
</div>
|
||||
<span class="ape-ticket-tag">{{ t.tagline }}</span>
|
||||
</div>
|
||||
|
||||
<div class="ape-ticket-body">
|
||||
<h3 class="ape-ticket-title">{{ t.title }}</h3>
|
||||
<p class="ape-ticket-desc">{{ t.description }}</p>
|
||||
</div>
|
||||
|
||||
<div class="ape-ticket-cta">
|
||||
<span>{{ t.cta }}</span>
|
||||
<fa-icon icon="arrow-right" class="ape-ticket-cta-ic"></fa-icon>
|
||||
</div>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,336 @@
|
||||
/**
|
||||
* 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 {
|
||||
--ape-blue: #1d4ed8;
|
||||
--ape-blue-d: #1e40af;
|
||||
--ape-blue-l: #eff6ff;
|
||||
--ape-blue-tint: #dbeafe;
|
||||
--ape-green: #15803d;
|
||||
--ape-green-d: #166534;
|
||||
--ape-green-l: #dcfce7;
|
||||
--ape-green-tint: #bbf7d0;
|
||||
--ape-purple: #6d28d9;
|
||||
--ape-purple-d: #5b21b6;
|
||||
--ape-purple-l: #ede9fe;
|
||||
--ape-purple-tint: #ddd6fe;
|
||||
--ape-g50: #f8fafc;
|
||||
--ape-g100: #f1f5f9;
|
||||
--ape-g200: #e2e8f0;
|
||||
--ape-g300: #cbd5e1;
|
||||
--ape-g500: #64748b;
|
||||
--ape-g700: #334155;
|
||||
--ape-g900: #0f172a;
|
||||
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ape-root {
|
||||
padding: 22px 26px 32px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ───────── Page header ───────── */
|
||||
.ape-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 18px 22px;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(135deg, var(--ape-blue-l) 0%, #fff 65%);
|
||||
border: 1px solid var(--ape-blue-tint);
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.ape-head-l {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.ape-head-ic {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 14px;
|
||||
background: var(--ape-blue-d);
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.ape-head-t {
|
||||
margin: 0 0 4px;
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
color: var(--ape-g900);
|
||||
}
|
||||
|
||||
.ape-head-s {
|
||||
font-size: 13px;
|
||||
color: var(--ape-g500);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ───────── Tickets grid ───────── */
|
||||
.ape-tickets {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 18px;
|
||||
|
||||
@media (width <= 1024px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
@media (width <= 640px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ───────── Ticket card ───────── */
|
||||
.ape-ticket {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 22px 22px 18px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--ape-g200);
|
||||
border-radius: 16px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
overflow: visible;
|
||||
transition:
|
||||
transform 0.18s ease,
|
||||
box-shadow 0.18s ease,
|
||||
border-color 0.18s ease;
|
||||
|
||||
/* Subtle gradient strip on the left edge to give the ticket a "stub" look. */
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 14px auto 14px 0;
|
||||
width: 4px;
|
||||
border-radius: 0 4px 4px 0;
|
||||
background: var(--ape-g200);
|
||||
transition: background 0.18s ease;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow:
|
||||
0 18px 38px -22px rgb(15 23 42 / 22%),
|
||||
0 6px 14px -8px rgb(15 23 42 / 12%);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--ape-blue);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
/* Accent variants */
|
||||
&.accent-blue {
|
||||
&::before {
|
||||
background: var(--ape-blue);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--ape-blue-tint);
|
||||
}
|
||||
|
||||
.ape-ticket-icon {
|
||||
background: var(--ape-blue-l);
|
||||
color: var(--ape-blue-d);
|
||||
}
|
||||
|
||||
.ape-ticket-tag {
|
||||
background: var(--ape-blue-l);
|
||||
color: var(--ape-blue-d);
|
||||
border-color: var(--ape-blue-tint);
|
||||
}
|
||||
|
||||
.ape-ticket-cta {
|
||||
color: var(--ape-blue-d);
|
||||
border-color: var(--ape-blue-tint);
|
||||
}
|
||||
|
||||
&:hover .ape-ticket-cta {
|
||||
background: var(--ape-blue-d);
|
||||
color: #fff;
|
||||
border-color: var(--ape-blue-d);
|
||||
}
|
||||
}
|
||||
|
||||
&.accent-green {
|
||||
&::before {
|
||||
background: var(--ape-green);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--ape-green-tint);
|
||||
}
|
||||
|
||||
.ape-ticket-icon {
|
||||
background: var(--ape-green-l);
|
||||
color: var(--ape-green-d);
|
||||
}
|
||||
|
||||
.ape-ticket-tag {
|
||||
background: var(--ape-green-l);
|
||||
color: var(--ape-green-d);
|
||||
border-color: var(--ape-green-tint);
|
||||
}
|
||||
|
||||
.ape-ticket-cta {
|
||||
color: var(--ape-green-d);
|
||||
border-color: var(--ape-green-tint);
|
||||
}
|
||||
|
||||
&:hover .ape-ticket-cta {
|
||||
background: var(--ape-green-d);
|
||||
color: #fff;
|
||||
border-color: var(--ape-green-d);
|
||||
}
|
||||
}
|
||||
|
||||
&.accent-purple {
|
||||
&::before {
|
||||
background: var(--ape-purple);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--ape-purple-tint);
|
||||
}
|
||||
|
||||
.ape-ticket-icon {
|
||||
background: var(--ape-purple-l);
|
||||
color: var(--ape-purple-d);
|
||||
}
|
||||
|
||||
.ape-ticket-tag {
|
||||
background: var(--ape-purple-l);
|
||||
color: var(--ape-purple-d);
|
||||
border-color: var(--ape-purple-tint);
|
||||
}
|
||||
|
||||
.ape-ticket-cta {
|
||||
color: var(--ape-purple-d);
|
||||
border-color: var(--ape-purple-tint);
|
||||
}
|
||||
|
||||
&:hover .ape-ticket-cta {
|
||||
background: var(--ape-purple-d);
|
||||
color: #fff;
|
||||
border-color: var(--ape-purple-d);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ───────── Ticket "tear" notches ───────── */
|
||||
.ape-ticket-notch {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--ape-g50);
|
||||
border: 1px solid var(--ape-g200);
|
||||
transform: translateY(-50%);
|
||||
|
||||
&--l {
|
||||
left: -8px;
|
||||
}
|
||||
|
||||
&--r {
|
||||
right: -8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ───────── Ticket header (icon + tagline pill) ───────── */
|
||||
.ape-ticket-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.ape-ticket-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 12px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.ape-ticket-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 999px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ───────── Ticket body ───────── */
|
||||
.ape-ticket-body {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ape-ticket-title {
|
||||
margin: 0 0 6px;
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
color: var(--ape-g900);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.ape-ticket-desc {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
color: var(--ape-g500);
|
||||
}
|
||||
|
||||
/* ───────── CTA chip at the foot ───────── */
|
||||
.ape-ticket-cta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 9px 14px;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
border: 1px solid;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
align-self: stretch;
|
||||
transition:
|
||||
background 0.18s ease,
|
||||
color 0.18s ease,
|
||||
border-color 0.18s ease;
|
||||
}
|
||||
|
||||
.ape-ticket-cta-ic {
|
||||
font-size: 11px;
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
|
||||
.ape-ticket:hover .ape-ticket-cta-ic {
|
||||
transform: translateX(3px);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* 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 } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { Router } from '@angular/router';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
export type AtmPosEcomReportKind = 'today' | 'status' | 'cycle';
|
||||
|
||||
interface ReportTicket {
|
||||
kind: AtmPosEcomReportKind;
|
||||
/** Route segment under /digital/atm-pos-ecom/ — wired once the report component exists. */
|
||||
route: string;
|
||||
icon: string;
|
||||
accent: 'blue' | 'green' | 'purple';
|
||||
title: string;
|
||||
tagline: string;
|
||||
description: string;
|
||||
cta: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-atm-pos-ecom',
|
||||
templateUrl: './atm-pos-ecom.component.html',
|
||||
styleUrls: ['./atm-pos-ecom.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
CommonModule,
|
||||
FaIconComponent,
|
||||
MatTooltip
|
||||
]
|
||||
})
|
||||
export class AtmPosEcomComponent {
|
||||
private snack = inject(MatSnackBar);
|
||||
private router = inject(Router);
|
||||
|
||||
/** The three reports surfaced as tickets. Wire `route` to a real component as it lands. */
|
||||
readonly tickets: ReportTicket[] = [
|
||||
{
|
||||
kind: 'today',
|
||||
route: 'today',
|
||||
icon: 'calendar-check',
|
||||
accent: 'blue',
|
||||
title: "Today's Report",
|
||||
tagline: 'Same-day snapshot',
|
||||
description:
|
||||
"Real-time view of today's ATM, POS and E-commerce transactions across every channel — counts, value, success rate.",
|
||||
cta: 'Open Today’s Report'
|
||||
},
|
||||
{
|
||||
kind: 'status',
|
||||
route: 'status',
|
||||
icon: 'tasks',
|
||||
accent: 'green',
|
||||
title: 'Status-wise Report',
|
||||
tagline: 'Grouped by processing state',
|
||||
description:
|
||||
'Breakdown by outcome — Approved · Declined · Reversed · Pending · Timed-out. Useful for ops triage and decline-rate analysis.',
|
||||
cta: 'Open Status Report'
|
||||
},
|
||||
{
|
||||
kind: 'cycle',
|
||||
route: 'cycle',
|
||||
icon: 'sync-alt',
|
||||
accent: 'purple',
|
||||
title: 'Cycle-wise Report',
|
||||
tagline: 'Per settlement cycle',
|
||||
description:
|
||||
'Compare across settlement cycles — daily, weekly, monthly — for reconciliation with the network and acquirer files.',
|
||||
cta: 'Open Cycle Report'
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* Until the underlying report components are wired, clicks surface a friendly
|
||||
* "coming soon" toast. Once the report format is shared, replace this with
|
||||
* `this.router.navigate(['/digital/atm-pos-ecom', t.route])`.
|
||||
*/
|
||||
openReport(t: ReportTicket): void {
|
||||
this.snack.open(`${t.title} is coming next — drop the format and I'll wire it up.`, 'OK', { duration: 3500 });
|
||||
// this.router.navigate(['/digital/atm-pos-ecom', t.route]);
|
||||
}
|
||||
}
|
||||
@@ -10,20 +10,32 @@ 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';
|
||||
import { ImpsReportComponent } from './imps-report/imps-report.component';
|
||||
import { UpiReportComponent } from './upi-report/upi-report.component';
|
||||
import { AtmPosEcomComponent } from './atm-pos-ecom/atm-pos-ecom.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' }
|
||||
data: { title: 'NEFT / RTGS', breadcrumb: 'NEFT / RTGS' }
|
||||
},
|
||||
{
|
||||
path: 'digital/imps-report',
|
||||
component: ImpsReportComponent,
|
||||
data: { title: 'IMPS', breadcrumb: 'IMPS' }
|
||||
},
|
||||
{
|
||||
path: 'digital/upi-report',
|
||||
component: UpiReportComponent,
|
||||
data: { title: 'UPI Reports', breadcrumb: 'UPI' }
|
||||
},
|
||||
{
|
||||
path: 'digital/atm-pos-ecom',
|
||||
component: AtmPosEcomComponent,
|
||||
data: { title: 'ATM / POS / E-Commerce Reports', breadcrumb: 'ATM / POS / ECOM' }
|
||||
}
|
||||
])
|
||||
];
|
||||
|
||||
@@ -12,13 +12,25 @@ 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';
|
||||
import { ImpsEntryComponent } from './imps-entry/imps-entry.component';
|
||||
import { ImpsReportComponent } from './imps-report/imps-report.component';
|
||||
import { SenderHistoryDialogComponent } from './sender-history-dialog/sender-history-dialog.component';
|
||||
import { ImpsSenderHistoryDialogComponent } from './imps-sender-history-dialog/imps-sender-history-dialog.component';
|
||||
import { UpiReportComponent } from './upi-report/upi-report.component';
|
||||
import { AtmPosEcomComponent } from './atm-pos-ecom/atm-pos-ecom.component';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
SharedModule,
|
||||
DigitalRoutingModule,
|
||||
NeftRtgsEntryComponent,
|
||||
NeftRtgsReportComponent
|
||||
NeftRtgsReportComponent,
|
||||
ImpsEntryComponent,
|
||||
ImpsReportComponent,
|
||||
UpiReportComponent,
|
||||
SenderHistoryDialogComponent,
|
||||
ImpsSenderHistoryDialogComponent,
|
||||
AtmPosEcomComponent
|
||||
]
|
||||
})
|
||||
export class DigitalModule {}
|
||||
|
||||
@@ -0,0 +1,504 @@
|
||||
<!--
|
||||
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="bolt" class="nr-head-ic"></fa-icon>
|
||||
<div>
|
||||
<h2 class="nr-head-t">IMPS — Immediate Payment Service</h2>
|
||||
<div class="nr-head-s">
|
||||
<span class="is-rtgs">{{ txnType }}</span>
|
||||
·
|
||||
<fa-icon icon="bolt" class="nr-head-s-ic"></fa-icon>
|
||||
Real-time NPCI Switch · 24×7 · ₹5L per-transaction limit
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="nr-head-meta">
|
||||
<div class="nr-head-pill is-rtgs">
|
||||
<span class="nr-head-pill-l">IMPS Limit</span>
|
||||
<span class="nr-head-pill-v mono">₹{{ IMPS_MAX | number: '1.0-0' }}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="nr-history-btn"
|
||||
(click)="viewSenderHistory()"
|
||||
[disabled]="!currentSender"
|
||||
[matTooltip]="historyBtnTooltip"
|
||||
matTooltipPosition="below"
|
||||
aria-label="View sender IMPS history"
|
||||
>
|
||||
<fa-icon icon="chart-line"></fa-icon>
|
||||
<span class="nr-history-btn-label">History</span>
|
||||
</button>
|
||||
@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="txnType" required>
|
||||
<mat-option value="P2A">P2A — Account + IFSC</mat-option>
|
||||
<mat-option value="P2P">P2P — MMID + Mobile</mat-option>
|
||||
<mat-option value="P2AADHAAR">P2A · Aadhaar (APBS)</mat-option>
|
||||
</mat-select>
|
||||
<fa-icon
|
||||
matSuffix
|
||||
icon="info-circle"
|
||||
class="nr-hint-ic"
|
||||
matTooltip="P2P uses MMID + Mobile (no IFSC needed). P2A uses A/C + IFSC. APBS resolves via Aadhaar."
|
||||
matTooltipPosition="left"
|
||||
></fa-icon>
|
||||
@if (entryForm.get('txnType')?.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>
|
||||
|
||||
@if (isP2A) {
|
||||
<mat-form-field appearance="outline" class="nr-field">
|
||||
<mat-label>Sender Account No.</mat-label>
|
||||
<input
|
||||
matInput
|
||||
required
|
||||
formControlName="senderAccount"
|
||||
[matAutocomplete]="senderAuto"
|
||||
placeholder="Type to search your accounts…"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<fa-icon matSuffix icon="search" class="nr-hint-ic"></fa-icon>
|
||||
<mat-autocomplete #senderAuto="matAutocomplete" class="nr-sender-autocomplete">
|
||||
@for (a of filteredSenderAccounts | async; track a.accountNo) {
|
||||
<mat-option [value]="a.accountNo">
|
||||
<div class="nr-sender-opt">
|
||||
<span class="nr-sender-opt-acc mono">{{ a.accountNo }}</span>
|
||||
<span class="nr-sender-opt-meta">
|
||||
{{ a.holderName }} · {{ a.productName }}
|
||||
<span class="nr-sender-opt-bal">Avail ₹{{ a.availableBalance | number: '1.0-0' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</mat-option>
|
||||
}
|
||||
@if (!(filteredSenderAccounts | async)?.length) {
|
||||
<mat-option disabled>
|
||||
<em>No matching account — check the number you've typed.</em>
|
||||
</mat-option>
|
||||
}
|
||||
</mat-autocomplete>
|
||||
@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>
|
||||
}
|
||||
|
||||
<!-- Remitter / Sender info chip — visible across all transaction types. -->
|
||||
@if (currentSender) {
|
||||
<div class="nr-sender-chip nr-span-2" [class.is-low]="insufficientBalance">
|
||||
<div class="nr-sender-chip-l">
|
||||
<fa-icon icon="user" class="nr-sender-chip-ic"></fa-icon>
|
||||
<span class="nr-sender-chip-name">{{ currentSender.holderName }}</span>
|
||||
<span class="nr-sender-chip-sub">{{ currentSender.productName }} · {{ currentSender.accountNo }}</span>
|
||||
</div>
|
||||
<div class="nr-sender-chip-r">
|
||||
<span class="nr-sender-chip-bal-l">Available Balance</span>
|
||||
<span class="nr-sender-chip-bal-v">₹{{ currentSender.availableBalance | number: '1.2-2' }}</span>
|
||||
@if (insufficientBalance) {
|
||||
<span class="nr-sender-chip-warn">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
Below total debit ₹{{ totalDebit | number: '1.2-2' }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<mat-form-field appearance="outline" class="nr-field">
|
||||
<mat-label>Amount (₹)</mat-label>
|
||||
<span matPrefix class="nr-prefix">₹ </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>Amount must be > 0</mat-error>
|
||||
}
|
||||
@if (entryForm.get('amount')?.hasError('max') && !entryForm.get('amount')?.hasError('required')) {
|
||||
<mat-error>IMPS limit ₹{{ IMPS_MAX | number: '1.0-0' }} per txn</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 (beneVerified) {
|
||||
<span class="nr-banl-status-pill verified" matTooltip="Beneficiary name verified via BANL (A/C + IFSC)">
|
||||
<fa-icon icon="check-circle"></fa-icon> BANL Verified
|
||||
</span>
|
||||
} @else {
|
||||
<span class="nr-banl-status-pill pending" matTooltip="BANL name 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]="!beneVerified && canPerformLookup"
|
||||
(click)="performLookup()"
|
||||
[disabled]="lookupBusy || !canPerformLookup"
|
||||
[matTooltip]="
|
||||
isP2A
|
||||
? 'Run BANL on the entered A/C + IFSC'
|
||||
: isP2P
|
||||
? 'Resolve MMID + Mobile to A/C + IFSC, then run BANL'
|
||||
: 'Resolve Aadhaar to A/C + IFSC (APBS), then run BANL'
|
||||
"
|
||||
>
|
||||
@if (lookupBusy) {
|
||||
<fa-icon icon="spinner" class="fa-spin"></fa-icon> Looking up…
|
||||
} @else if (beneVerified) {
|
||||
<fa-icon icon="sync-alt"></fa-icon> Re-verify
|
||||
} @else {
|
||||
<fa-icon icon="search"></fa-icon>
|
||||
@if (isP2A) {
|
||||
BANL Look-up
|
||||
} @else {
|
||||
Resolve & BANL Look-up
|
||||
}
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="nr-grid">
|
||||
@if (isP2A) {
|
||||
<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>
|
||||
}
|
||||
|
||||
@if (isP2P) {
|
||||
<mat-form-field appearance="outline" class="nr-field">
|
||||
<mat-label>MMID (7 digits)</mat-label>
|
||||
<input
|
||||
matInput
|
||||
maxlength="7"
|
||||
inputmode="numeric"
|
||||
formControlName="beneMmid"
|
||||
placeholder="e.g. 9234567"
|
||||
autocomplete="off"
|
||||
(keypress)="$event.charCode >= 48 && $event.charCode <= 57 ? true : $event.preventDefault()"
|
||||
/>
|
||||
<fa-icon matSuffix icon="hashtag" class="nr-hint-ic"></fa-icon>
|
||||
<mat-hint>Or use Mobile</mat-hint>
|
||||
@if (entryForm.get('beneMmid')?.hasError('pattern')) {
|
||||
<mat-error>MMID must be exactly 7 digits</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="nr-field">
|
||||
<mat-label>Beneficiary Mobile</mat-label>
|
||||
<span matPrefix class="nr-prefix">+91 </span>
|
||||
<input
|
||||
matInput
|
||||
maxlength="10"
|
||||
inputmode="numeric"
|
||||
formControlName="beneMobile"
|
||||
placeholder="10-digit Indian mobile"
|
||||
autocomplete="off"
|
||||
(keypress)="$event.charCode >= 48 && $event.charCode <= 57 ? true : $event.preventDefault()"
|
||||
/>
|
||||
<fa-icon matSuffix icon="mobile-alt" class="nr-hint-ic"></fa-icon>
|
||||
<mat-hint>Or use MMID</mat-hint>
|
||||
@if (entryForm.get('beneMobile')?.hasError('pattern')) {
|
||||
<mat-error>Enter a valid 10-digit Indian mobile (6/7/8/9-starting)</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
@if (isAadhaar) {
|
||||
<mat-form-field appearance="outline" class="nr-field nr-span-2">
|
||||
<mat-label>Beneficiary Aadhaar (12 digits)</mat-label>
|
||||
<input
|
||||
matInput
|
||||
required
|
||||
maxlength="12"
|
||||
inputmode="numeric"
|
||||
formControlName="beneAadhaar"
|
||||
placeholder="12-digit Aadhaar number"
|
||||
autocomplete="off"
|
||||
(keypress)="$event.charCode >= 48 && $event.charCode <= 57 ? true : $event.preventDefault()"
|
||||
/>
|
||||
<fa-icon matSuffix icon="id-card" class="nr-hint-ic"></fa-icon>
|
||||
@if (entryForm.get('beneAadhaar')?.hasError('required')) {
|
||||
<mat-error>Aadhaar is <strong>required</strong></mat-error>
|
||||
}
|
||||
@if (
|
||||
entryForm.get('beneAadhaar')?.hasError('pattern') && !entryForm.get('beneAadhaar')?.hasError('required')
|
||||
) {
|
||||
<mat-error>Aadhaar must be exactly 12 digits</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
<!--
|
||||
BANL is a name-lookup over Account + IFSC only — there is no MMID/Aadhaar
|
||||
name-lookup service. So for P2P / P2A·Aadhaar we first resolve the underlying
|
||||
A/C + IFSC (from the IMPS / APBS switch), then run BANL on those.
|
||||
These fields stay read-only and auto-fill from the resolution step.
|
||||
-->
|
||||
@if (!isP2A && beneResolved) {
|
||||
<mat-form-field appearance="outline" class="nr-field">
|
||||
<mat-label>Resolved Account No.</mat-label>
|
||||
<input matInput readonly formControlName="beneficiaryAccount" autocomplete="off" />
|
||||
<fa-icon matSuffix icon="lock" class="nr-name-lock"></fa-icon>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="nr-field">
|
||||
<mat-label>Resolved IFSC</mat-label>
|
||||
<input matInput readonly formControlName="ifscCode" autocomplete="off" />
|
||||
<fa-icon matSuffix icon="lock" class="nr-name-lock"></fa-icon>
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
<mat-form-field appearance="outline" class="nr-field nr-span-2 nr-name-field">
|
||||
<mat-label>Beneficiary Name (auto-filled by IMPS Switch)</mat-label>
|
||||
<input
|
||||
matInput
|
||||
required
|
||||
readonly
|
||||
formControlName="beneficiaryName"
|
||||
placeholder="Run look-up to fetch the registered name"
|
||||
autocomplete="off"
|
||||
/>
|
||||
@if (beneVerified) {
|
||||
<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-form-field>
|
||||
</div>
|
||||
|
||||
@if (beneMatch) {
|
||||
<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>{{ beneMatch.beneficiaryName }}</strong>
|
||||
@if (beneMatch.beneficiaryAccount) {
|
||||
· <span class="mono">{{ beneMatch.beneficiaryAccount }}</span>
|
||||
}
|
||||
@if (beneMatch.ifscCode) {
|
||||
· <span class="mono">{{ beneMatch.ifscCode }}</span>
|
||||
}
|
||||
@if (beneMatch.mmid) {
|
||||
· MMID <span class="mono">{{ beneMatch.mmid }}</span>
|
||||
}
|
||||
@if (beneMatch.mobile) {
|
||||
· Mobile <span class="mono">+91 {{ beneMatch.mobile }}</span>
|
||||
}
|
||||
· {{ beneMatch.bankName }}, {{ beneMatch.branchName }}
|
||||
<span class="nr-banl-status" [class.dormant]="beneMatch.status === 'DORMANT'"
|
||||
>· {{ beneMatch.status }}</span
|
||||
>
|
||||
</div>
|
||||
<button type="button" class="nr-banl-card-x" (click)="clearLookup()" matTooltip="Dismiss">
|
||||
<fa-icon icon="times"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@if (lookupError) {
|
||||
<div class="nr-banl-card error">
|
||||
<fa-icon icon="exclamation-triangle" class="nr-banl-card-ic"></fa-icon>
|
||||
<div class="nr-banl-card-body">{{ lookupError }}</div>
|
||||
<button type="button" class="nr-banl-card-x" (click)="clearLookup()" 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 & Notes</span>
|
||||
</div>
|
||||
|
||||
<div class="nr-grid">
|
||||
<mat-form-field appearance="outline" class="nr-field nr-span-2">
|
||||
<mat-label>Purpose of Remittance <span class="nr-req-star">*</span></mat-label>
|
||||
<mat-select formControlName="purposeOfRemittance" required>
|
||||
@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 is required</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>
|
||||
|
||||
<!-- SECTION 4: Charges -->
|
||||
<section class="nr-section">
|
||||
<div class="nr-section-h">
|
||||
<fa-icon icon="receipt" class="nr-section-ic"></fa-icon>
|
||||
<span>Charges & GST</span>
|
||||
<span class="nr-section-h-sub">IMPS schedule · 18% GST on charge</span>
|
||||
<label class="nr-waive">
|
||||
<input type="checkbox" formControlName="waiveCharges" />
|
||||
<span>Waive charges</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="nr-fees" [class.is-waived]="chargesWaived">
|
||||
<div class="nr-fee-row">
|
||||
<span class="nr-fee-l">Transfer Amount</span>
|
||||
<span class="nr-fee-v">₹{{ amount || 0 | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="nr-fee-row">
|
||||
<span class="nr-fee-l">
|
||||
Service Charge
|
||||
<small>(IMPS)</small>
|
||||
</span>
|
||||
<span class="nr-fee-v">
|
||||
@if (chargesWaived) {
|
||||
<s>₹{{ scheduledCharge | number: '1.2-2' }}</s>
|
||||
<span class="nr-fee-waived-pill">Waived</span>
|
||||
} @else {
|
||||
₹{{ serviceCharge | number: '1.2-2' }}
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div class="nr-fee-row">
|
||||
<span class="nr-fee-l">GST <small>(18% on charge)</small></span>
|
||||
<span class="nr-fee-v">₹{{ gstOnCharge | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="nr-fee-row nr-fee-row--total">
|
||||
<span class="nr-fee-l">Total Debit</span>
|
||||
<span class="nr-fee-v">
|
||||
<strong>₹{{ totalDebit | number: '1.2-2' }}</strong>
|
||||
</span>
|
||||
</div>
|
||||
</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 (!beneVerified) {
|
||||
<span class="nr-submit-hint">
|
||||
<fa-icon icon="exclamation-circle"></fa-icon>
|
||||
IMPS Switch 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]="!beneVerified ? 'Run IMPS Switch look-up first to verify the beneficiary' : ''"
|
||||
matTooltipPosition="above"
|
||||
>
|
||||
<fa-icon icon="paper-plane"></fa-icon>
|
||||
Initiate IMPS
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,691 @@
|
||||
/**
|
||||
* 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 { AsyncPipe } from '@angular/common';
|
||||
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 { MatDialog, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatAutocomplete, MatAutocompleteTrigger } from '@angular/material/autocomplete';
|
||||
import { Router } from '@angular/router';
|
||||
import { Observable, of, startWith, map } from 'rxjs';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
import { SenderAccount } from '../neft-rtgs-entry/neft-rtgs-entry.component';
|
||||
import { seedImpsTransactions } from '../imps-report/imps-report.component';
|
||||
import {
|
||||
ImpsSenderHistoryDialogComponent,
|
||||
ImpsSenderHistoryDialogData
|
||||
} from '../imps-sender-history-dialog/imps-sender-history-dialog.component';
|
||||
|
||||
export type ImpsTxnType = 'P2A' | 'P2P' | 'P2AADHAAR';
|
||||
|
||||
interface PurposeOption {
|
||||
code: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface ImpsBeneficiaryMatch {
|
||||
beneficiaryName: string;
|
||||
beneficiaryAccount?: string;
|
||||
ifscCode?: string;
|
||||
mmid?: string;
|
||||
mobile?: string;
|
||||
aadhaar?: string;
|
||||
bankName: string;
|
||||
branchName: string;
|
||||
status: 'ACTIVE' | 'DORMANT';
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-imps-entry',
|
||||
templateUrl: './imps-entry.component.html',
|
||||
styleUrls: ['../neft-rtgs-entry/neft-rtgs-entry.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
FaIconComponent,
|
||||
MatTooltip,
|
||||
MatAutocomplete,
|
||||
MatAutocompleteTrigger,
|
||||
AsyncPipe
|
||||
]
|
||||
})
|
||||
export class ImpsEntryComponent implements OnInit {
|
||||
private fb = inject(FormBuilder);
|
||||
private snack = inject(MatSnackBar);
|
||||
private router = inject(Router);
|
||||
private dialog = inject(MatDialog);
|
||||
private dialogRef = inject(MatDialogRef<ImpsEntryComponent>, { optional: true });
|
||||
|
||||
get isDialog(): boolean {
|
||||
return this.dialogRef !== null;
|
||||
}
|
||||
|
||||
/** IMPS per-transaction ceiling (₹5L) per current NPCI norms. */
|
||||
readonly IMPS_MAX = 500000;
|
||||
/** GST rate applied on the service charge (18% per current GST schedule). */
|
||||
readonly GST_RATE = 0.18;
|
||||
|
||||
minDate = new Date(2000, 0, 1);
|
||||
maxDate = new Date(2100, 11, 31);
|
||||
|
||||
entryForm: FormGroup;
|
||||
|
||||
/** IMPS Switch lookup state. */
|
||||
lookupBusy = false;
|
||||
beneMatch: ImpsBeneficiaryMatch | null = null;
|
||||
lookupError: string | null = null;
|
||||
|
||||
/** Mock NPCI IMPS Switch directory — keyed by both A/C+IFSC and MMID+Mobile. */
|
||||
private impsDirectory: ImpsBeneficiaryMatch[] = [
|
||||
{
|
||||
beneficiaryName: 'Rohan S. Mehta',
|
||||
beneficiaryAccount: '50100123456789',
|
||||
ifscCode: 'HDFC0001234',
|
||||
mmid: '9234567',
|
||||
mobile: '9821234567',
|
||||
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',
|
||||
mmid: '9128831',
|
||||
mobile: '9845112233',
|
||||
bankName: 'State Bank of India',
|
||||
branchName: 'New Delhi — Connaught Place',
|
||||
status: 'DORMANT'
|
||||
},
|
||||
{
|
||||
beneficiaryName: 'Neha Kapoor',
|
||||
beneficiaryAccount: '50100654321789',
|
||||
ifscCode: 'HDFC0009876',
|
||||
mmid: '9650432',
|
||||
mobile: '9890065432',
|
||||
bankName: 'HDFC Bank',
|
||||
branchName: 'Pune — Koregaon Park',
|
||||
status: 'ACTIVE'
|
||||
}
|
||||
];
|
||||
|
||||
/** Same demo list as NEFT/RTGS — the operator's own debit-side accounts. */
|
||||
senderAccounts: SenderAccount[] = [
|
||||
{
|
||||
accountNo: 'SB10029384',
|
||||
holderName: 'Anita Desai',
|
||||
productName: 'Savings — Premium',
|
||||
availableBalance: 482500
|
||||
},
|
||||
{
|
||||
accountNo: 'CA20034112',
|
||||
holderName: 'Anita Desai',
|
||||
productName: 'Current — Business',
|
||||
availableBalance: 1245300
|
||||
},
|
||||
{
|
||||
accountNo: 'SB50200012345',
|
||||
holderName: 'Anita Desai',
|
||||
productName: 'Savings — Regular',
|
||||
availableBalance: 92300
|
||||
},
|
||||
{
|
||||
accountNo: 'OD30049827',
|
||||
holderName: 'Anita Desai',
|
||||
productName: 'Overdraft — Secured',
|
||||
availableBalance: 750000
|
||||
},
|
||||
{
|
||||
accountNo: 'CA60055123',
|
||||
holderName: 'Anita Desai HUF',
|
||||
productName: 'Current — HUF',
|
||||
availableBalance: 318900
|
||||
}
|
||||
];
|
||||
|
||||
filteredSenderAccounts!: Observable<SenderAccount[]>;
|
||||
|
||||
purposeOptions: PurposeOption[] = [
|
||||
{ code: 'FAMILY_MAINTENANCE', label: 'Family Maintenance' },
|
||||
{ code: 'VENDOR_PAYMENT', label: 'Vendor / Supplier Payment' },
|
||||
{ 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: 'GIFT', label: 'Gift / Personal Remittance' },
|
||||
{ code: 'INVESTMENT', label: 'Investment / Securities' },
|
||||
{ code: 'OTHER', label: 'Other' }
|
||||
];
|
||||
|
||||
ngOnInit(): void {
|
||||
this.entryForm = this.fb.group({
|
||||
txnType: [
|
||||
'P2A' as ImpsTxnType,
|
||||
Validators.required
|
||||
],
|
||||
valueDate: [
|
||||
new Date(),
|
||||
Validators.required
|
||||
],
|
||||
senderAccount: [
|
||||
'',
|
||||
[
|
||||
Validators.required,
|
||||
Validators.pattern(/^[A-Za-z0-9]{6,20}$/)
|
||||
]
|
||||
],
|
||||
amount: [
|
||||
null,
|
||||
[
|
||||
Validators.required,
|
||||
Validators.min(1),
|
||||
Validators.max(this.IMPS_MAX)
|
||||
]
|
||||
],
|
||||
// P2A
|
||||
beneficiaryAccount: [''],
|
||||
ifscCode: [''],
|
||||
// P2P
|
||||
beneMmid: [''],
|
||||
beneMobile: [''],
|
||||
// P2AADHAAR
|
||||
beneAadhaar: [''],
|
||||
// Resolved
|
||||
beneficiaryName: [
|
||||
'',
|
||||
[
|
||||
Validators.required,
|
||||
Validators.maxLength(120)
|
||||
]
|
||||
],
|
||||
purposeOfRemittance: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
remarks: [''],
|
||||
waiveCharges: [false]
|
||||
});
|
||||
|
||||
this.entryForm.get('txnType')?.valueChanges.subscribe((t: ImpsTxnType) => this.refreshTypeValidators(t));
|
||||
|
||||
// Any change to beneficiary identifiers invalidates the prior match.
|
||||
[
|
||||
'beneficiaryAccount',
|
||||
'ifscCode',
|
||||
'beneMmid',
|
||||
'beneMobile',
|
||||
'beneAadhaar'
|
||||
].forEach((c) => this.entryForm.get(c)?.valueChanges.subscribe(() => this.invalidateLookup()));
|
||||
|
||||
this.filteredSenderAccounts =
|
||||
this.entryForm.get('senderAccount')?.valueChanges.pipe(
|
||||
startWith(this.entryForm.get('senderAccount')?.value || ''),
|
||||
map((value) => this.filterSenderAccounts(value || ''))
|
||||
) ?? of(this.senderAccounts.slice());
|
||||
|
||||
this.refreshTypeValidators(this.entryForm.get('txnType')?.value);
|
||||
}
|
||||
|
||||
private filterSenderAccounts(query: string): SenderAccount[] {
|
||||
const q = (query || '').toString().trim().toLowerCase();
|
||||
if (!q) return this.senderAccounts.slice();
|
||||
return this.senderAccounts.filter(
|
||||
(a) =>
|
||||
a.accountNo.toLowerCase().includes(q) ||
|
||||
a.holderName.toLowerCase().includes(q) ||
|
||||
a.productName.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
|
||||
private invalidateLookup(): void {
|
||||
if (!this.beneMatch) return;
|
||||
this.beneMatch = null;
|
||||
this.lookupError = null;
|
||||
this.entryForm.get('beneficiaryName')?.setValue('', { emitEvent: false });
|
||||
// For P2P / Aadhaar the A/C + IFSC were auto-filled by the resolution step;
|
||||
// clear them too so a stale resolution can't be submitted after the operator
|
||||
// changes the underlying MMID / Mobile / Aadhaar.
|
||||
if (!this.isP2A) {
|
||||
this.entryForm.patchValue({ beneficiaryAccount: '', ifscCode: '' }, { emitEvent: false });
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply the right validator set for the current IMPS sub-type. */
|
||||
private refreshTypeValidators(type: ImpsTxnType): void {
|
||||
const sender = this.entryForm.get('senderAccount');
|
||||
const acct = this.entryForm.get('beneficiaryAccount');
|
||||
const ifsc = this.entryForm.get('ifscCode');
|
||||
const mmid = this.entryForm.get('beneMmid');
|
||||
const mob = this.entryForm.get('beneMobile');
|
||||
const aad = this.entryForm.get('beneAadhaar');
|
||||
|
||||
sender?.clearValidators();
|
||||
acct?.clearValidators();
|
||||
ifsc?.clearValidators();
|
||||
mmid?.clearValidators();
|
||||
mob?.clearValidators();
|
||||
aad?.clearValidators();
|
||||
|
||||
if (type === 'P2A') {
|
||||
sender?.setValidators([
|
||||
Validators.required,
|
||||
Validators.pattern(/^[A-Za-z0-9]{6,20}$/)
|
||||
]);
|
||||
acct?.setValidators([
|
||||
Validators.required,
|
||||
Validators.pattern(/^[A-Za-z0-9]{6,20}$/)
|
||||
]);
|
||||
ifsc?.setValidators([
|
||||
Validators.required,
|
||||
Validators.pattern(/^[A-Z]{4}0[A-Z0-9]{6}$/)
|
||||
]);
|
||||
} else if (type === 'P2P') {
|
||||
// Either MMID OR Mobile is sufficient — neither is individually required.
|
||||
// Format is still validated when something is entered.
|
||||
mmid?.setValidators([
|
||||
Validators.pattern(/^[0-9]{7}$/)
|
||||
]);
|
||||
mob?.setValidators([
|
||||
Validators.pattern(/^[6-9][0-9]{9}$/)
|
||||
]);
|
||||
} else {
|
||||
aad?.setValidators([
|
||||
Validators.required,
|
||||
Validators.pattern(/^[0-9]{12}$/)
|
||||
]);
|
||||
}
|
||||
|
||||
[
|
||||
sender,
|
||||
acct,
|
||||
ifsc,
|
||||
mmid,
|
||||
mob,
|
||||
aad
|
||||
].forEach((c) => c?.updateValueAndValidity({ emitEvent: false }));
|
||||
|
||||
// When the sender picker is hidden (P2P / Aadhaar), default the sender to the
|
||||
// operator's first account so the remitter chip still shows name + balance.
|
||||
if (type !== 'P2A' && !sender?.value && this.senderAccounts.length) {
|
||||
sender?.setValue(this.senderAccounts[0].accountNo, { emitEvent: false });
|
||||
}
|
||||
}
|
||||
|
||||
// ───────── Derived ─────────
|
||||
get txnType(): ImpsTxnType {
|
||||
return this.entryForm?.get('txnType')?.value || 'P2A';
|
||||
}
|
||||
get isP2A(): boolean {
|
||||
return this.txnType === 'P2A';
|
||||
}
|
||||
get isP2P(): boolean {
|
||||
return this.txnType === 'P2P';
|
||||
}
|
||||
get isAadhaar(): boolean {
|
||||
return this.txnType === 'P2AADHAAR';
|
||||
}
|
||||
get amount(): number {
|
||||
return Number(this.entryForm?.get('amount')?.value || 0);
|
||||
}
|
||||
get overLimit(): boolean {
|
||||
return this.amount > this.IMPS_MAX;
|
||||
}
|
||||
|
||||
/** Operator can waive the service charge (e.g., staff / internal / relationship). */
|
||||
get chargesWaived(): boolean {
|
||||
return !!this.entryForm?.get('waiveCharges')?.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* IMPS charge slab (bank-set, illustrative):
|
||||
* ≤ ₹1,000 → ₹2.50
|
||||
* ≤ ₹25,000 → ₹5
|
||||
* ≤ ₹1,00,000 → ₹15
|
||||
* > ₹1,00,000 → ₹25
|
||||
*/
|
||||
get serviceCharge(): number {
|
||||
if (this.chargesWaived) return 0;
|
||||
const a = this.amount;
|
||||
if (!a || a <= 0) return 0;
|
||||
if (a <= 1000) return 2.5;
|
||||
if (a <= 25000) return 5;
|
||||
if (a <= 100000) return 15;
|
||||
return 25;
|
||||
}
|
||||
|
||||
get gstOnCharge(): number {
|
||||
return +(this.serviceCharge * this.GST_RATE).toFixed(2);
|
||||
}
|
||||
|
||||
get totalFee(): number {
|
||||
return +(this.serviceCharge + this.gstOnCharge).toFixed(2);
|
||||
}
|
||||
|
||||
get totalDebit(): number {
|
||||
return +(this.amount + this.totalFee).toFixed(2);
|
||||
}
|
||||
|
||||
/** Same slab logic but ignoring the waive flag — used to show the struck-through "original" charge. */
|
||||
get scheduledCharge(): number {
|
||||
const a = this.amount;
|
||||
if (!a || a <= 0) return 0;
|
||||
if (a <= 1000) return 2.5;
|
||||
if (a <= 25000) return 5;
|
||||
if (a <= 100000) return 15;
|
||||
return 25;
|
||||
}
|
||||
|
||||
/** Sender account record matching the currently entered/picked account number. */
|
||||
get currentSender(): SenderAccount | null {
|
||||
const v = (this.entryForm?.get('senderAccount')?.value || '').toString().trim().toLowerCase();
|
||||
if (!v) return null;
|
||||
return this.senderAccounts.find((a) => a.accountNo.toLowerCase() === v) || null;
|
||||
}
|
||||
|
||||
/** True if amount + fee would exceed the picked sender's available balance. */
|
||||
get insufficientBalance(): boolean {
|
||||
const s = this.currentSender;
|
||||
if (!s || this.amount <= 0) return false;
|
||||
return this.totalDebit > s.availableBalance;
|
||||
}
|
||||
|
||||
/** Tooltip for the header history button — kept as a getter so the template avoids string escaping. */
|
||||
get historyBtnTooltip(): string {
|
||||
const s = this.currentSender;
|
||||
return s ? `View today's IMPS transactions for ${s.holderName}` : 'Pick a sender account first';
|
||||
}
|
||||
get beneVerified(): boolean {
|
||||
return this.beneMatch !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* True once the underlying A/C + IFSC have been resolved (either entered for P2A,
|
||||
* or fetched from the IMPS / APBS switch for P2P / P2A·Aadhaar). BANL only operates
|
||||
* on A/C + IFSC, so we always need both before BANL can run.
|
||||
*/
|
||||
get beneResolved(): boolean {
|
||||
return !!(this.entryForm?.get('beneficiaryAccount')?.value && this.entryForm?.get('ifscCode')?.value);
|
||||
}
|
||||
get canPerformLookup(): boolean {
|
||||
if (this.isP2A) {
|
||||
const acct = this.entryForm?.get('beneficiaryAccount');
|
||||
return !!acct && acct.value && !acct.hasError('pattern');
|
||||
}
|
||||
if (this.isP2P) {
|
||||
const mmid = this.entryForm?.get('beneMmid');
|
||||
const mob = this.entryForm?.get('beneMobile');
|
||||
const mmidOk = !!mmid?.value && !mmid.hasError('pattern');
|
||||
const mobOk = !!mob?.value && !mob.hasError('pattern');
|
||||
// Either MMID or Mobile is enough — both is also fine.
|
||||
return mmidOk || mobOk;
|
||||
}
|
||||
const aad = this.entryForm?.get('beneAadhaar');
|
||||
return !!aad && aad.value && !aad.hasError('pattern');
|
||||
}
|
||||
get canSubmit(): boolean {
|
||||
return this.entryForm?.valid === true && this.beneVerified;
|
||||
}
|
||||
|
||||
// ───────── Lookup ─────────
|
||||
performLookup(): void {
|
||||
const t = this.txnType;
|
||||
this.beneMatch = null;
|
||||
this.lookupError = null;
|
||||
|
||||
if (!this.canPerformLookup) {
|
||||
this.lookupError =
|
||||
t === 'P2A'
|
||||
? 'Enter a valid Beneficiary Account before look-up.'
|
||||
: t === 'P2P'
|
||||
? 'Enter a valid 7-digit MMID or 10-digit Mobile before look-up.'
|
||||
: 'Enter a valid 12-digit Aadhaar before look-up.';
|
||||
return;
|
||||
}
|
||||
|
||||
this.lookupBusy = true;
|
||||
setTimeout(() => {
|
||||
this.lookupBusy = false;
|
||||
let match: ImpsBeneficiaryMatch | undefined;
|
||||
if (t === 'P2A') {
|
||||
const acct = this.entryForm.get('beneficiaryAccount')?.value;
|
||||
const ifsc = (this.entryForm.get('ifscCode')?.value || '').toUpperCase();
|
||||
match = this.impsDirectory.find((b) => b.beneficiaryAccount === acct && (!ifsc || b.ifscCode === ifsc));
|
||||
} else if (t === 'P2P') {
|
||||
const mmid = this.entryForm.get('beneMmid')?.value;
|
||||
const mob = this.entryForm.get('beneMobile')?.value;
|
||||
// Match on whichever identifier the operator provided — either is sufficient.
|
||||
match = this.impsDirectory.find((b) => (mmid && b.mmid === mmid) || (mob && b.mobile === mob));
|
||||
} else {
|
||||
const aad = this.entryForm.get('beneAadhaar')?.value;
|
||||
match = this.impsDirectory.find((b) => b.aadhaar === aad);
|
||||
}
|
||||
|
||||
if (!match) {
|
||||
match = this.synthesise();
|
||||
}
|
||||
this.applyMatch(match);
|
||||
}, 600);
|
||||
}
|
||||
|
||||
private applyMatch(match: ImpsBeneficiaryMatch): void {
|
||||
this.beneMatch = match;
|
||||
// BANL only takes A/C + IFSC, so for MMID / Aadhaar transfers we resolve to those
|
||||
// first (here via the mock directory / synth) and write them back into the form as
|
||||
// readonly fields. The name is then the BANL response on the resolved A/C + IFSC.
|
||||
const patch: Record<string, unknown> = {
|
||||
beneficiaryName: match.beneficiaryName
|
||||
};
|
||||
if (match.beneficiaryAccount) {
|
||||
patch['beneficiaryAccount'] = match.beneficiaryAccount;
|
||||
}
|
||||
if (match.ifscCode) {
|
||||
patch['ifscCode'] = match.ifscCode;
|
||||
}
|
||||
// If the operator entered only one of MMID/Mobile, auto-fill the complementary one
|
||||
// from the directory match — don't overwrite anything they typed.
|
||||
if (this.isP2P) {
|
||||
if (match.mmid && !this.entryForm.get('beneMmid')?.value) {
|
||||
patch['beneMmid'] = match.mmid;
|
||||
}
|
||||
if (match.mobile && !this.entryForm.get('beneMobile')?.value) {
|
||||
patch['beneMobile'] = match.mobile;
|
||||
}
|
||||
}
|
||||
this.entryForm.patchValue(patch, { emitEvent: false });
|
||||
this.snack.open(`BANL verified — ${match.beneficiaryName}`, 'OK', { duration: 2500 });
|
||||
}
|
||||
|
||||
private synthesise(): ImpsBeneficiaryMatch {
|
||||
const t = this.txnType;
|
||||
const acct = this.entryForm.get('beneficiaryAccount')?.value || '';
|
||||
const ifsc = (this.entryForm.get('ifscCode')?.value || '').toUpperCase();
|
||||
const mmid = this.entryForm.get('beneMmid')?.value || '';
|
||||
const mobile = this.entryForm.get('beneMobile')?.value || '';
|
||||
const aadhaar = this.entryForm.get('beneAadhaar')?.value || '';
|
||||
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 seedStr = t === 'P2A' ? acct : t === 'P2P' ? mmid : aadhaar;
|
||||
const seed = parseInt((seedStr || '0').toString().slice(-2), 10) || 0;
|
||||
const name = sampleNames[seed % sampleNames.length];
|
||||
|
||||
// BANL only operates on A/C + IFSC, so for MMID / Aadhaar we synth the underlying
|
||||
// account too — mocks the IMPS / APBS switch resolution that a real implementation
|
||||
// would call before BANL.
|
||||
const sampleIfscs = [
|
||||
'HDFC0001234',
|
||||
'ICIC0000567',
|
||||
'SBIN0011223',
|
||||
'KKBK0001899',
|
||||
'UTIB0000456',
|
||||
'PUNB0123400',
|
||||
'BARB0VJVATA',
|
||||
'YESB0000123',
|
||||
'IDFB0040101',
|
||||
'CNRB0001890'
|
||||
];
|
||||
const synthIfsc =
|
||||
t === 'P2A' ? ifsc || sampleIfscs[seed % sampleIfscs.length] : sampleIfscs[seed % sampleIfscs.length];
|
||||
const synthAcct = t === 'P2A' ? acct : '5010' + (seedStr || '0').toString().padStart(10, '0').slice(-10);
|
||||
|
||||
return {
|
||||
beneficiaryName: name,
|
||||
beneficiaryAccount: synthAcct,
|
||||
ifscCode: synthIfsc,
|
||||
mmid: t === 'P2P' ? mmid : undefined,
|
||||
mobile: t === 'P2P' ? mobile : undefined,
|
||||
aadhaar: t === 'P2AADHAAR' ? aadhaar : undefined,
|
||||
bankName: this.bankFromIfsc(synthIfsc) || 'Partner Bank',
|
||||
branchName: t === 'P2A' ? 'Verified by BANL' : 'Resolved via IMPS / APBS · Verified by BANL',
|
||||
status: 'ACTIVE'
|
||||
};
|
||||
}
|
||||
|
||||
private bankFromIfsc(ifsc: 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[(ifsc || '').slice(0, 4).toUpperCase()] || 'Partner Bank';
|
||||
}
|
||||
|
||||
clearLookup(): void {
|
||||
this.beneMatch = null;
|
||||
this.lookupError = null;
|
||||
this.entryForm.get('beneficiaryName')?.setValue('', { emitEvent: false });
|
||||
}
|
||||
|
||||
// ───────── Actions ─────────
|
||||
submit(): void {
|
||||
if (!this.beneVerified) {
|
||||
this.lookupError = 'IMPS Switch verification is mandatory before initiating.';
|
||||
this.snack.open('Run IMPS Switch look-up to verify the beneficiary.', '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();
|
||||
const txnId = 'IMPS' + Date.now().toString().slice(-10);
|
||||
this.snack.open(`IMPS 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({
|
||||
txnType: 'P2A',
|
||||
valueDate: new Date(),
|
||||
senderAccount: '',
|
||||
amount: null,
|
||||
beneficiaryAccount: '',
|
||||
ifscCode: '',
|
||||
beneMmid: '',
|
||||
beneMobile: '',
|
||||
beneAadhaar: '',
|
||||
beneficiaryName: '',
|
||||
purposeOfRemittance: '',
|
||||
remarks: '',
|
||||
waiveCharges: false
|
||||
});
|
||||
this.clearLookup();
|
||||
this.refreshTypeValidators('P2A');
|
||||
}
|
||||
|
||||
loadSample(): void {
|
||||
this.entryForm.patchValue(
|
||||
{
|
||||
txnType: 'P2P',
|
||||
valueDate: new Date(),
|
||||
senderAccount: 'SB10029384',
|
||||
amount: 5000,
|
||||
beneMmid: '9234567',
|
||||
beneMobile: '9821234567',
|
||||
purposeOfRemittance: 'FAMILY_MAINTENANCE',
|
||||
remarks: 'Monthly support'
|
||||
},
|
||||
{ emitEvent: false }
|
||||
);
|
||||
this.refreshTypeValidators('P2P');
|
||||
this.beneMatch = null;
|
||||
this.lookupError = null;
|
||||
this.performLookup();
|
||||
}
|
||||
|
||||
back(): void {
|
||||
if (this.isDialog) {
|
||||
this.dialogRef!.close({ submitted: false });
|
||||
return;
|
||||
}
|
||||
this.router.navigate([
|
||||
'/home'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the IMPS sender-history dialog showing today's outward IMPS transactions
|
||||
* for the currently picked sender. No-op if no sender is selected.
|
||||
*/
|
||||
viewSenderHistory(): void {
|
||||
const sender = this.currentSender;
|
||||
if (!sender) {
|
||||
this.snack.open('Pick a sender account first to view its history.', 'OK', { duration: 2500 });
|
||||
return;
|
||||
}
|
||||
const data: ImpsSenderHistoryDialogData = {
|
||||
senderName: sender.holderName,
|
||||
senderAccountNo: sender.accountNo,
|
||||
txns: seedImpsTransactions()
|
||||
};
|
||||
this.dialog.open(ImpsSenderHistoryDialogComponent, {
|
||||
width: '960px',
|
||||
maxWidth: '94vw',
|
||||
maxHeight: '88vh',
|
||||
autoFocus: false,
|
||||
restoreFocus: true,
|
||||
panelClass: 'shd-dialog',
|
||||
data
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
<!--
|
||||
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="bolt" class="nrr-head-ic"></fa-icon>
|
||||
<div>
|
||||
<h2 class="nrr-head-t">IMPS — Report & Reconciliation</h2>
|
||||
<div class="nrr-head-s">
|
||||
Digital · Immediate Payment Service · 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="bolt"></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">{{ p2aCount }} P2A · {{ p2pCount }} P2P</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">{{ successCount | number }}</div>
|
||||
<div class="nrr-kpi-l">Successful</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">initiated / sent / pending</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">{{ failedCount | number }}</div>
|
||||
<div class="nrr-kpi-l">Failed / Reversed</div>
|
||||
<div class="nrr-kpi-sub">check failure 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">P2A</span>
|
||||
<span class="nrr-pill nrr-pill--purple">P2P</span>
|
||||
</div>
|
||||
<div class="nrr-trend">
|
||||
@for (b of trendBuckets; track b.date) {
|
||||
<div class="nrr-trend-row">
|
||||
<div class="nrr-trend-row-date">
|
||||
<span class="nrr-trend-row-day">{{ b.date | date: 'EEE' }}</span>
|
||||
<span class="nrr-trend-row-md">{{ b.date | date: 'd MMM' }}</span>
|
||||
</div>
|
||||
<div
|
||||
class="nrr-trend-row-bar-wrap"
|
||||
[matTooltip]="'P2A ' + b.p2a + ' · P2P ' + b.p2p + ' · ' + (b.count | number) + ' txns'"
|
||||
>
|
||||
<div class="nrr-trend-row-bar" [style.width.%]="trendBarHeight(b.amount)"></div>
|
||||
</div>
|
||||
<div class="nrr-trend-row-pills">
|
||||
<span class="nrr-pill nrr-pill--blue mini">P2A {{ b.p2a }}</span>
|
||||
<span class="nrr-pill nrr-pill--purple mini">P2P {{ b.p2p }}</span>
|
||||
</div>
|
||||
<div class="nrr-trend-row-amt">₹{{ b.amount | number: '1.0-0' }}</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">Success</th>
|
||||
<th class="num">Pending</th>
|
||||
<th class="num">Failed</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.success }}</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.failed }}</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">P2A</th>
|
||||
<th class="num">P2P</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.p2a }}</span>
|
||||
</td>
|
||||
<td class="num">
|
||||
<span class="nrr-pill nrr-pill--purple mini">{{ r.p2p }}</span>
|
||||
</td>
|
||||
<td class="num">
|
||||
<strong>₹{{ r.amount | number: '1.0-0' }}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- SUCCESS / PENDING / FAILED -->
|
||||
@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> Initiated / Sent / Pending </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> Successful </span>
|
||||
<span class="nrr-funnel-v">{{ successCount | number }}</span>
|
||||
</div>
|
||||
<div class="nrr-funnel-row">
|
||||
<span class="nrr-funnel-l"> <span class="nrr-dot nrr-dot--red"></span> Failed / Reversed </span>
|
||||
<span class="nrr-funnel-v">{{ failedCount | number }}</span>
|
||||
</div>
|
||||
<div class="nrr-funnel-meter">
|
||||
<span class="seg green" [style.flex]="successCount || 0"></span>
|
||||
<span class="seg amber" [style.flex]="pendingCount || 0"></span>
|
||||
<span class="seg red" [style.flex]="failedCount || 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> Failure-Code Breakdown</span>
|
||||
</div>
|
||||
@if (failureCodeRows.length === 0) {
|
||||
<div class="nrr-empty">No failed 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 failureCodeRows; 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 RRN, UTR, A/C, IFSC, MMID, Mobile, Bene Name…" />
|
||||
</div>
|
||||
<select [(ngModel)]="typeFilter" class="nrr-select" matTooltip="Filter by IMPS sub-type">
|
||||
<option value="">All Types</option>
|
||||
<option value="P2A">P2A · Account + IFSC</option>
|
||||
<option value="P2P">P2P · MMID + Mobile</option>
|
||||
<option value="P2AADHAAR">P2A · Aadhaar</option>
|
||||
</select>
|
||||
<select [(ngModel)]="stateFilter" class="nrr-select" matTooltip="Filter by processing state">
|
||||
<option value="">All States</option>
|
||||
<option value="INITIATED">Initiated</option>
|
||||
<option value="SENT">Sent</option>
|
||||
<option value="PENDING">Pending</option>
|
||||
<option value="SUCCESS">Success</option>
|
||||
<option value="FAILED">Failed</option>
|
||||
<option value="REVERSED">Reversed</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>RRN / UTR</th>
|
||||
<th>Remitter A/C</th>
|
||||
<th>Beneficiary</th>
|
||||
<th>MMID / IFSC</th>
|
||||
<th>Mobile</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.rrn) {
|
||||
<tr>
|
||||
<td>
|
||||
<div class="mono small">{{ t.rrn }}</div>
|
||||
<div class="mono small muted">{{ t.utr }}</div>
|
||||
</td>
|
||||
<td class="mono small">{{ t.remitterAcc }}</td>
|
||||
<td>
|
||||
<div>{{ t.beneName }}</div>
|
||||
<div class="mono small muted">
|
||||
@if (t.txnType === 'P2A') {
|
||||
{{ t.beneAcc }} · {{ t.beneBank }}
|
||||
} @else if (t.txnType === 'P2P') {
|
||||
MMID {{ t.beneMmid }} · {{ t.beneBank }}
|
||||
} @else {
|
||||
Aadhaar {{ t.beneAadhaar }} · {{ t.beneBank }}
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
<td class="mono small">
|
||||
@if (t.txnType === 'P2A') {
|
||||
{{ t.beneIfsc }}
|
||||
} @else if (t.txnType === 'P2P') {
|
||||
{{ t.beneMmid }}
|
||||
} @else {
|
||||
—
|
||||
}
|
||||
</td>
|
||||
<td class="mono small">{{ t.beneMobile || '—' }}</td>
|
||||
<td>
|
||||
<span
|
||||
class="nrr-pill mini"
|
||||
[class.nrr-pill--purple]="t.txnType === 'P2P'"
|
||||
[class.nrr-pill--blue]="t.txnType === 'P2A'"
|
||||
[class.nrr-pill--teal]="t.txnType === 'P2AADHAAR'"
|
||||
>
|
||||
{{ t.txnType }}
|
||||
</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>
|
||||
</td>
|
||||
<td>
|
||||
<span class="nrr-pill mini" [ngClass]="'nrr-pill--' + stateColor(t.state)">
|
||||
{{ t.state }}
|
||||
</span>
|
||||
@if (t.failureCode) {
|
||||
<div class="small muted">{{ t.failureCode }} · {{ t.failureReason }}</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 IMPS transfer"
|
||||
matTooltipPosition="left"
|
||||
>
|
||||
<fa-icon icon="paper-plane"></fa-icon>
|
||||
<span class="nrr-fab-label">Initiate IMPS</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -0,0 +1,717 @@
|
||||
/**
|
||||
* 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 { ImpsEntryComponent } from '../imps-entry/imps-entry.component';
|
||||
|
||||
/** Person-to-Account, Person-to-Person (MMID+Mobile), Person-to-Aadhaar. */
|
||||
export type ImpsTxnType = 'P2A' | 'P2P' | 'P2AADHAAR';
|
||||
export type ImpsState = 'INITIATED' | 'SENT' | 'SUCCESS' | 'PENDING' | 'FAILED' | 'REVERSED';
|
||||
|
||||
export interface ImpsTxn {
|
||||
rrn: string;
|
||||
utr: string;
|
||||
remitterAcc: string;
|
||||
remitterBranch: string;
|
||||
remitterUser: string;
|
||||
remitterChannel: 'BRANCH' | 'MOBILE';
|
||||
/** P2A populates these. */
|
||||
beneAcc?: string;
|
||||
beneIfsc?: string;
|
||||
/** P2P populates these. */
|
||||
beneMmid?: string;
|
||||
beneMobile?: string;
|
||||
/** P2AADHAAR populates this. */
|
||||
beneAadhaar?: string;
|
||||
beneName: string;
|
||||
beneBank: string;
|
||||
txnType: ImpsTxnType;
|
||||
amount: number;
|
||||
valueDate: string;
|
||||
purposeCode: string;
|
||||
state: ImpsState;
|
||||
failureCode?: string;
|
||||
failureReason?: string;
|
||||
sentTimestamp: string;
|
||||
}
|
||||
|
||||
type ReportTab = 'trends' | 'bank' | 'date' | 'pending' | 'channel' | 'user';
|
||||
|
||||
interface BankRow {
|
||||
bank: string;
|
||||
count: number;
|
||||
amount: number;
|
||||
success: number;
|
||||
pending: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
interface DateRow {
|
||||
date: string;
|
||||
count: number;
|
||||
amount: number;
|
||||
p2a: number;
|
||||
p2p: 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-imps-report',
|
||||
templateUrl: './imps-report.component.html',
|
||||
styleUrls: ['../neft-rtgs-report/neft-rtgs-report.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
FormsModule,
|
||||
FaIconComponent,
|
||||
MatTooltip
|
||||
]
|
||||
})
|
||||
export class ImpsReportComponent implements OnInit {
|
||||
private dialog = inject(MatDialog);
|
||||
|
||||
activeTab: ReportTab = 'trends';
|
||||
|
||||
search = '';
|
||||
typeFilter: '' | ImpsTxnType = '';
|
||||
stateFilter: '' | ImpsState = '';
|
||||
|
||||
txns: ImpsTxn[] = [];
|
||||
|
||||
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: 'Success / Pending / Failed', icon: 'tasks' },
|
||||
{ id: 'channel', label: 'Branch / Mobile', icon: 'mobile-alt' },
|
||||
{ id: 'user', label: 'User-wise', icon: 'user-tie' }
|
||||
];
|
||||
|
||||
ngOnInit(): void {
|
||||
this.txns = seedImpsTransactions();
|
||||
}
|
||||
|
||||
// ───────────────────── KPIs ─────────────────────
|
||||
get totalCount(): number {
|
||||
return this.txns.length;
|
||||
}
|
||||
get totalAmount(): number {
|
||||
return this.txns.reduce((s, t) => s + t.amount, 0);
|
||||
}
|
||||
get successCount(): number {
|
||||
return this.txns.filter((t) => t.state === 'SUCCESS').length;
|
||||
}
|
||||
get pendingCount(): number {
|
||||
return this.txns.filter((t) => [
|
||||
'INITIATED',
|
||||
'SENT',
|
||||
'PENDING'
|
||||
].includes(t.state)).length;
|
||||
}
|
||||
get failedCount(): number {
|
||||
return this.txns.filter((t) => t.state === 'FAILED' || t.state === 'REVERSED').length;
|
||||
}
|
||||
get p2aCount(): number {
|
||||
return this.txns.filter((t) => t.txnType === 'P2A').length;
|
||||
}
|
||||
get p2pCount(): number {
|
||||
return this.txns.filter((t) => t.txnType === 'P2P').length;
|
||||
}
|
||||
get successRate(): number {
|
||||
if (this.totalCount === 0) return 0;
|
||||
return (this.successCount / this.totalCount) * 100;
|
||||
}
|
||||
get avgTicket(): number {
|
||||
if (this.totalCount === 0) return 0;
|
||||
return this.totalAmount / this.totalCount;
|
||||
}
|
||||
|
||||
// ───────────────────── Filtered list ─────────────────────
|
||||
get filteredTxns(): ImpsTxn[] {
|
||||
const q = this.search.trim().toLowerCase();
|
||||
return this.txns.filter((t) => {
|
||||
if (this.typeFilter && t.txnType !== this.typeFilter) return false;
|
||||
if (this.stateFilter && t.state !== this.stateFilter) return false;
|
||||
if (!q) return true;
|
||||
return (
|
||||
t.rrn.toLowerCase().includes(q) ||
|
||||
t.utr.toLowerCase().includes(q) ||
|
||||
(t.beneAcc || '').toLowerCase().includes(q) ||
|
||||
(t.beneIfsc || '').toLowerCase().includes(q) ||
|
||||
(t.beneMmid || '').toLowerCase().includes(q) ||
|
||||
(t.beneMobile || '').toLowerCase().includes(q) ||
|
||||
t.beneName.toLowerCase().includes(q) ||
|
||||
t.remitterAcc.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ───────────────────── 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, success: 0, pending: 0, failed: 0 });
|
||||
}
|
||||
const r = map.get(key)!;
|
||||
r.count++;
|
||||
r.amount += t.amount;
|
||||
if (t.state === 'SUCCESS') r.success++;
|
||||
else if (t.state === 'FAILED' || t.state === 'REVERSED') r.failed++;
|
||||
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, p2a: 0, p2p: 0 });
|
||||
}
|
||||
const r = map.get(t.valueDate)!;
|
||||
r.count++;
|
||||
r.amount += t.amount;
|
||||
if (t.txnType === 'P2A') r.p2a++;
|
||||
else if (t.txnType === 'P2P') r.p2p++;
|
||||
}
|
||||
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());
|
||||
}
|
||||
|
||||
get trendBuckets(): DateRow[] {
|
||||
return this.dateRows.slice(0, 7).reverse();
|
||||
}
|
||||
|
||||
get trendMaxAmount(): number {
|
||||
const buckets = this.trendBuckets;
|
||||
if (!buckets.length) return 1;
|
||||
return Math.max(...buckets.map((b) => b.amount));
|
||||
}
|
||||
|
||||
get failureCodeRows(): { 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.state === 'FAILED' || t.state === 'REVERSED') || !t.failureCode) continue;
|
||||
if (!map.has(t.failureCode)) {
|
||||
map.set(t.failureCode, {
|
||||
code: t.failureCode,
|
||||
reason: t.failureReason || '',
|
||||
count: 0,
|
||||
amount: 0
|
||||
});
|
||||
}
|
||||
const r = map.get(t.failureCode)!;
|
||||
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;
|
||||
}
|
||||
|
||||
openInitiateDialog(): void {
|
||||
const ref = this.dialog.open(ImpsEntryComponent, {
|
||||
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 = [
|
||||
'RRN',
|
||||
'UTR',
|
||||
'Remitter Acc',
|
||||
'Bene Acc / MMID / Aadhaar',
|
||||
'Bene IFSC / Mobile',
|
||||
'Bene Name',
|
||||
'Txn Type',
|
||||
'Amount',
|
||||
'Value Date',
|
||||
'Purpose Code',
|
||||
'State',
|
||||
'Failure Code',
|
||||
'Sent Timestamp'
|
||||
];
|
||||
const rows: string[][] = [headers];
|
||||
for (const t of this.filteredTxns) {
|
||||
rows.push([
|
||||
t.rrn,
|
||||
t.utr,
|
||||
t.remitterAcc,
|
||||
t.beneAcc || t.beneMmid || t.beneAadhaar || '',
|
||||
t.beneIfsc || t.beneMobile || '',
|
||||
t.beneName,
|
||||
t.txnType,
|
||||
t.amount.toFixed(2),
|
||||
t.valueDate,
|
||||
t.purposeCode,
|
||||
t.state,
|
||||
t.failureCode || '',
|
||||
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 = `IMPS_Report_${new Date().toISOString().slice(0, 10)}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
clearFilters(): void {
|
||||
this.search = '';
|
||||
this.typeFilter = '';
|
||||
this.stateFilter = '';
|
||||
}
|
||||
|
||||
// ───────────────────── Helpers ─────────────────────
|
||||
stateColor(state: ImpsState): string {
|
||||
switch (state) {
|
||||
case 'SUCCESS':
|
||||
return 'green';
|
||||
case 'FAILED':
|
||||
case 'REVERSED':
|
||||
return 'red';
|
||||
case 'SENT':
|
||||
return 'blue';
|
||||
case 'INITIATED':
|
||||
return 'teal';
|
||||
case 'PENDING':
|
||||
return 'amber';
|
||||
}
|
||||
}
|
||||
|
||||
trendBarHeight(amount: number): number {
|
||||
return Math.max(6, Math.round((amount / this.trendMaxAmount) * 100));
|
||||
}
|
||||
|
||||
private toTxn(p: any): ImpsTxn {
|
||||
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 {
|
||||
rrn: 'IMPS' + Date.now().toString().slice(-10),
|
||||
utr: 'UTR' + Date.now().toString().slice(-11),
|
||||
remitterAcc: p.senderAccount || '',
|
||||
remitterBranch: 'Main Branch',
|
||||
remitterUser: 'Current User',
|
||||
remitterChannel: 'BRANCH',
|
||||
beneAcc: p.beneficiaryAccount,
|
||||
beneIfsc: (p.ifscCode || '').toUpperCase() || undefined,
|
||||
beneMmid: p.beneMmid,
|
||||
beneMobile: p.beneMobile,
|
||||
beneAadhaar: p.beneAadhaar,
|
||||
beneName: p.beneficiaryName || '',
|
||||
beneBank: this.bankFromIfsc(p.ifscCode || ''),
|
||||
txnType: p.txnType || 'P2A',
|
||||
amount: Number(p.amount) || 0,
|
||||
valueDate,
|
||||
purposeCode: this.purposeCodeFor(p.purposeOfRemittance),
|
||||
state: 'INITIATED',
|
||||
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 ─────────────────────
|
||||
/** @deprecated Use the exported `seedImpsTransactions()` instead. */
|
||||
private seedTransactions(): ImpsTxn[] {
|
||||
return seedImpsTransactions();
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────── Shared mock seed (top-level export) ─────────────────────
|
||||
/**
|
||||
* Mock IMPS history. Exported so the entry form (and any per-sender history dialogs)
|
||||
* can render from the same dataset. Replace with an API call when the back-end is wired.
|
||||
*/
|
||||
export function seedImpsTransactions(): ImpsTxn[] {
|
||||
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 [
|
||||
{
|
||||
rrn: 'IMPS2608517721',
|
||||
utr: 'UTRKKBK52026081',
|
||||
remitterAcc: '50200012345678',
|
||||
remitterBranch: 'Mumbai — BKC',
|
||||
remitterUser: 'Anita Desai',
|
||||
remitterChannel: 'MOBILE',
|
||||
beneMmid: '9234567',
|
||||
beneMobile: '9821234567',
|
||||
beneName: 'Rohan S. Mehta',
|
||||
beneBank: 'HDFC Bank',
|
||||
txnType: 'P2P',
|
||||
amount: 12500,
|
||||
valueDate: vd(0),
|
||||
purposeCode: 'P1301',
|
||||
state: 'SUCCESS',
|
||||
sentTimestamp: dt(0, 14, 5)
|
||||
},
|
||||
{
|
||||
rrn: 'IMPS2608517722',
|
||||
utr: 'UTRHDFC2608712',
|
||||
remitterAcc: '50200012345678',
|
||||
remitterBranch: 'Mumbai — BKC',
|
||||
remitterUser: 'Anita Desai',
|
||||
remitterChannel: 'BRANCH',
|
||||
beneAcc: '345678901234',
|
||||
beneIfsc: 'ICIC0000567',
|
||||
beneName: 'Bluestone Trading Co.',
|
||||
beneBank: 'ICICI Bank',
|
||||
txnType: 'P2A',
|
||||
amount: 78500,
|
||||
valueDate: vd(0),
|
||||
purposeCode: 'P0802',
|
||||
state: 'SENT',
|
||||
sentTimestamp: dt(0, 14, 6)
|
||||
},
|
||||
{
|
||||
rrn: 'IMPS2608517723',
|
||||
utr: 'UTRSBIN2608902',
|
||||
remitterAcc: '50200099887766',
|
||||
remitterBranch: 'Bengaluru — MG Road',
|
||||
remitterUser: 'Karthik Rao',
|
||||
remitterChannel: 'MOBILE',
|
||||
beneMmid: '9128831',
|
||||
beneMobile: '9845112233',
|
||||
beneName: 'Anita Verma',
|
||||
beneBank: 'State Bank of India',
|
||||
txnType: 'P2P',
|
||||
amount: 4500,
|
||||
valueDate: vd(1),
|
||||
purposeCode: 'P1301',
|
||||
state: 'SUCCESS',
|
||||
sentTimestamp: dt(1, 11, 22)
|
||||
},
|
||||
{
|
||||
rrn: 'IMPS2608517724',
|
||||
utr: 'UTRAXIS2608811',
|
||||
remitterAcc: '50200099887766',
|
||||
remitterBranch: 'Bengaluru — MG Road',
|
||||
remitterUser: 'Karthik Rao',
|
||||
remitterChannel: 'MOBILE',
|
||||
beneAcc: '6789012345',
|
||||
beneIfsc: 'UTIB0000456',
|
||||
beneName: 'Rakesh Industries',
|
||||
beneBank: 'Axis Bank',
|
||||
txnType: 'P2A',
|
||||
amount: 195000,
|
||||
valueDate: vd(1),
|
||||
purposeCode: 'P0801',
|
||||
state: 'FAILED',
|
||||
failureCode: 'E03',
|
||||
failureReason: 'Beneficiary Bank Not Available',
|
||||
sentTimestamp: dt(1, 11, 25)
|
||||
},
|
||||
{
|
||||
rrn: 'IMPS2608517725',
|
||||
utr: 'UTRPUNB2608619',
|
||||
remitterAcc: '50100221122334',
|
||||
remitterBranch: 'Delhi — Connaught',
|
||||
remitterUser: 'Sushma Iyer',
|
||||
remitterChannel: 'BRANCH',
|
||||
beneAcc: '50100998877665',
|
||||
beneIfsc: 'PUNB0123400',
|
||||
beneName: 'Madhavi Singh',
|
||||
beneBank: 'Punjab National Bank',
|
||||
txnType: 'P2A',
|
||||
amount: 42000,
|
||||
valueDate: vd(2),
|
||||
purposeCode: 'P1006',
|
||||
state: 'SUCCESS',
|
||||
sentTimestamp: dt(2, 9, 35)
|
||||
},
|
||||
{
|
||||
rrn: 'IMPS2608517726',
|
||||
utr: 'UTRYESB2608520',
|
||||
remitterAcc: '50100221122334',
|
||||
remitterBranch: 'Delhi — Connaught',
|
||||
remitterUser: 'Sushma Iyer',
|
||||
remitterChannel: 'BRANCH',
|
||||
beneMmid: '9001122',
|
||||
beneMobile: '9818877665',
|
||||
beneName: 'Sigma Constructions',
|
||||
beneBank: 'YES Bank',
|
||||
txnType: 'P2P',
|
||||
amount: 18500,
|
||||
valueDate: vd(2),
|
||||
purposeCode: 'P0102',
|
||||
state: 'PENDING',
|
||||
sentTimestamp: dt(2, 9, 38)
|
||||
},
|
||||
{
|
||||
rrn: 'IMPS2608517727',
|
||||
utr: 'UTRCNRB2608417',
|
||||
remitterAcc: '50100887766554',
|
||||
remitterBranch: 'Chennai — T. Nagar',
|
||||
remitterUser: 'Ramesh Krishnan',
|
||||
remitterChannel: 'MOBILE',
|
||||
beneMmid: '9889911',
|
||||
beneMobile: '9840012345',
|
||||
beneName: 'Padma Rao',
|
||||
beneBank: 'Canara Bank',
|
||||
txnType: 'P2P',
|
||||
amount: 2800,
|
||||
valueDate: vd(3),
|
||||
purposeCode: 'P1302',
|
||||
state: 'SUCCESS',
|
||||
sentTimestamp: dt(3, 16, 15)
|
||||
},
|
||||
{
|
||||
rrn: 'IMPS2608517728',
|
||||
utr: 'UTRBARB2608318',
|
||||
remitterAcc: '50100887766554',
|
||||
remitterBranch: 'Chennai — T. Nagar',
|
||||
remitterUser: 'Ramesh Krishnan',
|
||||
remitterChannel: 'BRANCH',
|
||||
beneAcc: '50100123456987',
|
||||
beneIfsc: 'BARB0VJVATA',
|
||||
beneName: 'Vijaya Traders',
|
||||
beneBank: 'Bank of Baroda',
|
||||
txnType: 'P2A',
|
||||
amount: 125000,
|
||||
valueDate: vd(3),
|
||||
purposeCode: 'P0802',
|
||||
state: 'INITIATED',
|
||||
sentTimestamp: dt(3, 16, 20)
|
||||
},
|
||||
{
|
||||
rrn: 'IMPS2608517729',
|
||||
utr: 'UTRIDFB2608215',
|
||||
remitterAcc: '50200445566778',
|
||||
remitterBranch: 'Pune — Koregaon Park',
|
||||
remitterUser: 'Vikram Joshi',
|
||||
remitterChannel: 'BRANCH',
|
||||
beneAadhaar: 'XXXX-XXXX-7891',
|
||||
beneName: 'Pinnacle Realty Pvt Ltd',
|
||||
beneBank: 'IDFC FIRST Bank',
|
||||
txnType: 'P2AADHAAR',
|
||||
amount: 145000,
|
||||
valueDate: vd(4),
|
||||
purposeCode: 'P0202',
|
||||
state: 'SUCCESS',
|
||||
sentTimestamp: dt(4, 12, 45)
|
||||
},
|
||||
{
|
||||
rrn: 'IMPS2608517730',
|
||||
utr: 'UTRHDFC2608116',
|
||||
remitterAcc: '50200445566778',
|
||||
remitterBranch: 'Pune — Koregaon Park',
|
||||
remitterUser: 'Vikram Joshi',
|
||||
remitterChannel: 'MOBILE',
|
||||
beneMmid: '9650432',
|
||||
beneMobile: '9890065432',
|
||||
beneName: 'Neha Kapoor',
|
||||
beneBank: 'HDFC Bank',
|
||||
txnType: 'P2P',
|
||||
amount: 6500,
|
||||
valueDate: vd(4),
|
||||
purposeCode: 'P1301',
|
||||
state: 'SUCCESS',
|
||||
sentTimestamp: dt(4, 12, 50)
|
||||
},
|
||||
{
|
||||
rrn: 'IMPS2608517731',
|
||||
utr: 'UTRKKBK2608013',
|
||||
remitterAcc: '50200778899001',
|
||||
remitterBranch: 'Hyderabad — Banjara',
|
||||
remitterUser: 'Lakshmi Naidu',
|
||||
remitterChannel: 'BRANCH',
|
||||
beneAcc: '12345678900011',
|
||||
beneIfsc: 'KKBK0001899',
|
||||
beneName: 'Aarav Enterprises',
|
||||
beneBank: 'Kotak Mahindra Bank',
|
||||
txnType: 'P2A',
|
||||
amount: 67000,
|
||||
valueDate: vd(5),
|
||||
purposeCode: 'P0802',
|
||||
state: 'REVERSED',
|
||||
failureCode: 'E11',
|
||||
failureReason: 'Beneficiary Account Closed',
|
||||
sentTimestamp: dt(5, 10, 5)
|
||||
},
|
||||
{
|
||||
rrn: 'IMPS2608517732',
|
||||
utr: 'UTRSBIN2607914',
|
||||
remitterAcc: '50200778899001',
|
||||
remitterBranch: 'Hyderabad — Banjara',
|
||||
remitterUser: 'Lakshmi Naidu',
|
||||
remitterChannel: 'BRANCH',
|
||||
beneMmid: '9445566',
|
||||
beneMobile: '9849988776',
|
||||
beneName: 'Spectra Components',
|
||||
beneBank: 'State Bank of India',
|
||||
txnType: 'P2P',
|
||||
amount: 9800,
|
||||
valueDate: vd(5),
|
||||
purposeCode: 'P0801',
|
||||
state: 'SUCCESS',
|
||||
sentTimestamp: dt(5, 10, 10)
|
||||
},
|
||||
{
|
||||
rrn: 'IMPS2608517733',
|
||||
utr: 'UTRICIC2607812',
|
||||
remitterAcc: '50100334455667',
|
||||
remitterBranch: 'Kolkata — Park Street',
|
||||
remitterUser: 'Arjun Banerjee',
|
||||
remitterChannel: 'MOBILE',
|
||||
beneAcc: '88990011223344',
|
||||
beneIfsc: 'ICIC0001234',
|
||||
beneName: 'Eastern Logistics',
|
||||
beneBank: 'ICICI Bank',
|
||||
txnType: 'P2A',
|
||||
amount: 88000,
|
||||
valueDate: vd(6),
|
||||
purposeCode: 'P0802',
|
||||
state: 'SUCCESS',
|
||||
sentTimestamp: dt(6, 14, 30)
|
||||
},
|
||||
{
|
||||
rrn: 'IMPS2608517734',
|
||||
utr: 'UTRAXIS2607711',
|
||||
remitterAcc: '50100334455667',
|
||||
remitterBranch: 'Kolkata — Park Street',
|
||||
remitterUser: 'Arjun Banerjee',
|
||||
remitterChannel: 'BRANCH',
|
||||
beneMmid: '9009988',
|
||||
beneMobile: '9831100225',
|
||||
beneName: 'Indrani Sen',
|
||||
beneBank: 'Axis Bank',
|
||||
txnType: 'P2P',
|
||||
amount: 2200,
|
||||
valueDate: vd(6),
|
||||
purposeCode: 'P1302',
|
||||
state: 'SUCCESS',
|
||||
sentTimestamp: dt(6, 14, 32)
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="shd-root">
|
||||
<!-- HEADER -->
|
||||
<div class="shd-head">
|
||||
<div class="shd-head-l">
|
||||
<fa-icon icon="bolt" class="shd-head-ic"></fa-icon>
|
||||
<div>
|
||||
<h2 class="shd-head-t">{{ data.senderName }} <small>· IMPS · Today</small></h2>
|
||||
<div class="shd-head-s">
|
||||
@if (data.senderAccountNo) {
|
||||
<span class="mono">{{ data.senderAccountNo }}</span> ·
|
||||
}
|
||||
<strong>{{ totalCount | number }}</strong> txns today ·
|
||||
<strong>₹{{ totalAmount | number: '1.0-0' }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="shd-x" (click)="close()" matTooltip="Close">
|
||||
<fa-icon icon="times"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- KPI STRIP -->
|
||||
<div class="shd-kpis">
|
||||
<div class="shd-kpi shd-kpi--blue">
|
||||
<div class="shd-kpi-n">{{ totalCount | number }}</div>
|
||||
<div class="shd-kpi-l">Total</div>
|
||||
</div>
|
||||
<div class="shd-kpi shd-kpi--teal">
|
||||
<div class="shd-kpi-n">₹{{ totalAmount | number: '1.0-0' }}</div>
|
||||
<div class="shd-kpi-l">Value</div>
|
||||
</div>
|
||||
<div class="shd-kpi shd-kpi--green">
|
||||
<div class="shd-kpi-n">{{ successCount | number }}</div>
|
||||
<div class="shd-kpi-l">Success ({{ successRate | number: '1.1-1' }}%)</div>
|
||||
</div>
|
||||
<div class="shd-kpi shd-kpi--amber">
|
||||
<div class="shd-kpi-n">{{ pendingCount | number }}</div>
|
||||
<div class="shd-kpi-l">In-Flight</div>
|
||||
</div>
|
||||
<div class="shd-kpi shd-kpi--red">
|
||||
<div class="shd-kpi-n">{{ failedCount | number }}</div>
|
||||
<div class="shd-kpi-l">Failed / Reversed</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TABLE -->
|
||||
<div class="shd-body">
|
||||
@if (filteredTxns.length === 0) {
|
||||
<div class="shd-empty">
|
||||
<fa-icon icon="info-circle"></fa-icon>
|
||||
<div>
|
||||
<strong>No IMPS transactions today for this sender.</strong>
|
||||
<p>This view shows only today's outward transfers. Older history isn't included here.</p>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="shd-table-wrap">
|
||||
<table class="shd-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>RRN / UTR</th>
|
||||
<th>Beneficiary</th>
|
||||
<th>MMID / IFSC</th>
|
||||
<th>Mobile</th>
|
||||
<th>Type</th>
|
||||
<th class="num">Amount</th>
|
||||
<th>State</th>
|
||||
<th>Sent</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (t of filteredTxns; track t.rrn) {
|
||||
<tr>
|
||||
<td>
|
||||
<div class="mono small">{{ t.rrn }}</div>
|
||||
<div class="mono small muted">{{ t.utr }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div>{{ t.beneName }}</div>
|
||||
<div class="mono small muted">
|
||||
@if (t.txnType === 'P2A') {
|
||||
{{ t.beneAcc }} · {{ t.beneBank }}
|
||||
} @else if (t.txnType === 'P2P') {
|
||||
MMID {{ t.beneMmid }} · {{ t.beneBank }}
|
||||
} @else {
|
||||
Aadhaar {{ t.beneAadhaar }} · {{ t.beneBank }}
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
<td class="mono small">
|
||||
@if (t.txnType === 'P2A') {
|
||||
{{ t.beneIfsc }}
|
||||
} @else if (t.txnType === 'P2P') {
|
||||
{{ t.beneMmid }}
|
||||
} @else {
|
||||
—
|
||||
}
|
||||
</td>
|
||||
<td class="mono small">{{ t.beneMobile || '—' }}</td>
|
||||
<td>
|
||||
<span
|
||||
class="shd-pill mini"
|
||||
[class.shd-pill--purple]="t.txnType === 'P2P'"
|
||||
[class.shd-pill--blue]="t.txnType === 'P2A'"
|
||||
[class.shd-pill--teal]="t.txnType === 'P2AADHAAR'"
|
||||
>
|
||||
{{ t.txnType }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="num">
|
||||
<strong>₹{{ t.amount | number: '1.2-2' }}</strong>
|
||||
</td>
|
||||
<td>
|
||||
<span class="shd-pill mini" [ngClass]="'shd-pill--' + stateColor(t.state)">
|
||||
{{ t.state }}
|
||||
</span>
|
||||
@if (t.failureCode) {
|
||||
<div class="small muted">{{ t.failureCode }} · {{ t.failureReason }}</div>
|
||||
}
|
||||
</td>
|
||||
<td class="mono small">{{ t.sentTimestamp }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* 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 } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
import { ImpsTxn, ImpsState } from '../imps-report/imps-report.component';
|
||||
|
||||
export interface ImpsSenderHistoryDialogData {
|
||||
senderName: string;
|
||||
senderAccountNo: string;
|
||||
txns: ImpsTxn[];
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-imps-sender-history-dialog',
|
||||
templateUrl: './imps-sender-history-dialog.component.html',
|
||||
// Reuse the styling from the NEFT/RTGS history dialog — same look & feel.
|
||||
styleUrls: ['../sender-history-dialog/sender-history-dialog.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
CommonModule,
|
||||
FaIconComponent,
|
||||
MatTooltip
|
||||
]
|
||||
})
|
||||
export class ImpsSenderHistoryDialogComponent {
|
||||
private dialogRef = inject(MatDialogRef<ImpsSenderHistoryDialogComponent>);
|
||||
data: ImpsSenderHistoryDialogData = inject(MAT_DIALOG_DATA);
|
||||
|
||||
/** Today's date as YYYY-MM-DD — matches the `valueDate` shape in the txn seed. */
|
||||
private get todayKey(): string {
|
||||
const d = new Date();
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Today's IMPS rows for this sender — matched by exact A/C number when available,
|
||||
* otherwise by case-insensitive holder name. Older value dates are excluded; this
|
||||
* view is intentionally a today-only snapshot.
|
||||
*/
|
||||
get filteredTxns(): ImpsTxn[] {
|
||||
const acc = (this.data.senderAccountNo || '').trim().toLowerCase();
|
||||
const name = (this.data.senderName || '').trim().toLowerCase();
|
||||
const today = this.todayKey;
|
||||
return (this.data.txns || []).filter((t) => {
|
||||
if (t.valueDate !== today) return false;
|
||||
if (acc && t.remitterAcc.toLowerCase() === acc) return true;
|
||||
if (name && t.remitterUser.toLowerCase() === name) return true;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
// ─── KPI getters ───
|
||||
get totalCount(): number {
|
||||
return this.filteredTxns.length;
|
||||
}
|
||||
get totalAmount(): number {
|
||||
return this.filteredTxns.reduce((s, t) => s + t.amount, 0);
|
||||
}
|
||||
get successCount(): number {
|
||||
return this.filteredTxns.filter((t) => t.state === 'SUCCESS').length;
|
||||
}
|
||||
get pendingCount(): number {
|
||||
return this.filteredTxns.filter((t) => [
|
||||
'INITIATED',
|
||||
'SENT',
|
||||
'PENDING'
|
||||
].includes(t.state)).length;
|
||||
}
|
||||
get failedCount(): number {
|
||||
return this.filteredTxns.filter((t) => t.state === 'FAILED' || t.state === 'REVERSED').length;
|
||||
}
|
||||
get successRate(): number {
|
||||
if (this.totalCount === 0) return 0;
|
||||
return (this.successCount / this.totalCount) * 100;
|
||||
}
|
||||
|
||||
stateColor(state: ImpsState): string {
|
||||
switch (state) {
|
||||
case 'SUCCESS':
|
||||
return 'green';
|
||||
case 'FAILED':
|
||||
case 'REVERSED':
|
||||
return 'red';
|
||||
case 'SENT':
|
||||
return 'blue';
|
||||
case 'INITIATED':
|
||||
return 'teal';
|
||||
case 'PENDING':
|
||||
return 'amber';
|
||||
}
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
}
|
||||
@@ -23,9 +23,26 @@
|
||||
</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>
|
||||
@if (isRtgs) {
|
||||
<span class="nr-head-pill-l">RTGS Floor</span>
|
||||
<span class="nr-head-pill-v mono">≥ ₹{{ RTGS_MIN | number: '1.0-0' }}</span>
|
||||
} @else {
|
||||
<span class="nr-head-pill-l">NEFT Cap</span>
|
||||
<span class="nr-head-pill-v mono">≤ ₹{{ NEFT_MAX | number: '1.0-0' }}</span>
|
||||
}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="nr-history-btn"
|
||||
(click)="viewSenderHistory()"
|
||||
[disabled]="!currentSender"
|
||||
[matTooltip]="historyBtnTooltip"
|
||||
matTooltipPosition="below"
|
||||
aria-label="View sender NEFT/RTGS history"
|
||||
>
|
||||
<fa-icon icon="chart-line"></fa-icon>
|
||||
<span class="nr-history-btn-label">History</span>
|
||||
</button>
|
||||
@if (isDialog) {
|
||||
<button type="button" class="nr-dialog-x" (click)="back()" matTooltip="Close">
|
||||
<fa-icon icon="times"></fa-icon>
|
||||
@@ -79,27 +96,35 @@
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="nr-field">
|
||||
<mat-label>Amount (₹)</mat-label>
|
||||
<span matPrefix class="nr-prefix">₹ </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" />
|
||||
<input
|
||||
matInput
|
||||
required
|
||||
formControlName="senderAccount"
|
||||
[matAutocomplete]="senderAuto"
|
||||
placeholder="Type to search your accounts…"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<fa-icon matSuffix icon="search" class="nr-hint-ic"></fa-icon>
|
||||
<mat-autocomplete #senderAuto="matAutocomplete" class="nr-sender-autocomplete">
|
||||
@for (a of filteredSenderAccounts | async; track a.accountNo) {
|
||||
<mat-option [value]="a.accountNo">
|
||||
<div class="nr-sender-opt">
|
||||
<span class="nr-sender-opt-acc mono">{{ a.accountNo }}</span>
|
||||
<span class="nr-sender-opt-meta">
|
||||
{{ a.holderName }} · {{ a.productName }}
|
||||
<span class="nr-sender-opt-bal">Avail ₹{{ a.availableBalance | number: '1.0-0' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</mat-option>
|
||||
}
|
||||
@if (!(filteredSenderAccounts | async)?.length) {
|
||||
<mat-option disabled>
|
||||
<em>No matching account — check the number you've typed.</em>
|
||||
</mat-option>
|
||||
}
|
||||
</mat-autocomplete>
|
||||
@if (entryForm.get('senderAccount')?.hasError('required')) {
|
||||
<mat-error>Sender Account is <strong>required</strong></mat-error>
|
||||
}
|
||||
@@ -110,6 +135,55 @@
|
||||
<mat-error>Use 6–20 alphanumeric chars</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Remitter / Sender info chip — resolved from the picked account. -->
|
||||
@if (currentSender) {
|
||||
<div class="nr-sender-chip nr-span-2" [class.is-low]="insufficientBalance">
|
||||
<div class="nr-sender-chip-l">
|
||||
<fa-icon icon="user" class="nr-sender-chip-ic"></fa-icon>
|
||||
<span class="nr-sender-chip-name">{{ currentSender.holderName }}</span>
|
||||
<span class="nr-sender-chip-sub">{{ currentSender.productName }} · {{ currentSender.accountNo }}</span>
|
||||
</div>
|
||||
<div class="nr-sender-chip-r">
|
||||
<span class="nr-sender-chip-bal-l">Available Balance</span>
|
||||
<span class="nr-sender-chip-bal-v">₹{{ currentSender.availableBalance | number: '1.2-2' }}</span>
|
||||
@if (insufficientBalance) {
|
||||
<span class="nr-sender-chip-warn">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
Below total debit ₹{{ totalDebit | number: '1.2-2' }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<mat-form-field appearance="outline" class="nr-field">
|
||||
<mat-label>Amount (₹)</mat-label>
|
||||
<span matPrefix class="nr-prefix">₹ </span>
|
||||
<input matInput type="number" min="1" step="0.01" required formControlName="amount" placeholder="0.00" />
|
||||
<mat-hint>
|
||||
@if (isRtgs) {
|
||||
RTGS ≥ ₹{{ RTGS_MIN | number: '1.0-0' }}
|
||||
} @else {
|
||||
NEFT ≤ ₹{{ NEFT_MAX | number: '1.0-0' }} — switch to RTGS for higher
|
||||
}
|
||||
</mat-hint>
|
||||
@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' }} — use NEFT for smaller amounts
|
||||
} @else {
|
||||
Amount must be > 0
|
||||
}
|
||||
</mat-error>
|
||||
}
|
||||
@if (entryForm.get('amount')?.hasError('max') && !entryForm.get('amount')?.hasError('required')) {
|
||||
<mat-error>NEFT max ₹{{ NEFT_MAX | number: '1.0-0' }} — switch to RTGS for higher amounts</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -202,7 +276,6 @@
|
||||
} @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>
|
||||
|
||||
@@ -270,6 +343,50 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- SECTION 4: Charges -->
|
||||
<section class="nr-section">
|
||||
<div class="nr-section-h">
|
||||
<fa-icon icon="receipt" class="nr-section-ic"></fa-icon>
|
||||
<span>Charges & GST</span>
|
||||
<span class="nr-section-h-sub"> {{ isRtgs ? 'RTGS' : 'NEFT' }} schedule · 18% GST on charge </span>
|
||||
<label class="nr-waive">
|
||||
<input type="checkbox" formControlName="waiveCharges" />
|
||||
<span>Waive charges</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="nr-fees" [class.is-waived]="chargesWaived">
|
||||
<div class="nr-fee-row">
|
||||
<span class="nr-fee-l">Transfer Amount</span>
|
||||
<span class="nr-fee-v">₹{{ amount || 0 | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="nr-fee-row">
|
||||
<span class="nr-fee-l">
|
||||
Service Charge
|
||||
<small>({{ isRtgs ? 'RTGS' : 'NEFT' }})</small>
|
||||
</span>
|
||||
<span class="nr-fee-v">
|
||||
@if (chargesWaived) {
|
||||
<s>₹{{ scheduledCharge | number: '1.2-2' }}</s>
|
||||
<span class="nr-fee-waived-pill">Waived</span>
|
||||
} @else {
|
||||
₹{{ serviceCharge | number: '1.2-2' }}
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div class="nr-fee-row">
|
||||
<span class="nr-fee-l">GST <small>(18% on charge)</small></span>
|
||||
<span class="nr-fee-v">₹{{ gstOnCharge | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="nr-fee-row nr-fee-row--total">
|
||||
<span class="nr-fee-l">Total Debit</span>
|
||||
<span class="nr-fee-v">
|
||||
<strong>₹{{ totalDebit | number: '1.2-2' }}</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ACTIONS -->
|
||||
<div class="nr-actions">
|
||||
<button type="button" mat-stroked-button class="nr-btn-ghost" (click)="loadSample()">
|
||||
|
||||
@@ -157,6 +157,54 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ───────── History icon (next to floor / cap pill) ───────── */
|
||||
.nr-history-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 12px 4px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--nr-blue-l);
|
||||
background: #fff;
|
||||
color: var(--nr-blue-d);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s,
|
||||
border-color 0.15s,
|
||||
color 0.15s,
|
||||
transform 0.15s;
|
||||
|
||||
fa-icon {
|
||||
font-size: 12px;
|
||||
color: var(--nr-blue);
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--nr-blue-l);
|
||||
border-color: #93c5fd;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (width <= 560px) {
|
||||
.nr-history-btn-label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ───────── SECTION ───────── */
|
||||
.nr-section {
|
||||
background: #fff;
|
||||
@@ -198,6 +246,109 @@
|
||||
color: var(--nr-amber);
|
||||
}
|
||||
|
||||
/* ───────── Section header sub-label + waive toggle ───────── */
|
||||
.nr-section-h-sub {
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: none;
|
||||
color: var(--nr-g500);
|
||||
}
|
||||
|
||||
.nr-waive {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
text-transform: none;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--nr-g700);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
// stylelint-disable-next-line selector-no-qualifying-type
|
||||
input[type='checkbox'] {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
accent-color: var(--nr-blue);
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
/* ───────── Fee / GST breakdown card ───────── */
|
||||
.nr-fees {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 4px;
|
||||
padding: 10px 14px;
|
||||
background: var(--nr-g50, #f8fafc);
|
||||
border: 1px solid var(--nr-g200, #e2e8f0);
|
||||
border-radius: 10px;
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
&.is-waived {
|
||||
background: #fffbeb;
|
||||
border-color: #fde68a;
|
||||
}
|
||||
}
|
||||
|
||||
.nr-fee-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 4px 0;
|
||||
font-size: 12.5px;
|
||||
color: var(--nr-g700);
|
||||
|
||||
small {
|
||||
color: var(--nr-g500);
|
||||
font-weight: 500;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
&.nr-fee-row--total {
|
||||
margin-top: 4px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--nr-g200, #e2e8f0);
|
||||
font-size: 13.5px;
|
||||
color: var(--nr-g900);
|
||||
|
||||
.nr-fee-v strong {
|
||||
color: var(--nr-blue);
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nr-fee-l {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nr-fee-v {
|
||||
font-variant-numeric: tabular-nums;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nr-fee-waived-pill {
|
||||
font-size: 9.5px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 2px 7px;
|
||||
border-radius: 999px;
|
||||
background: #fef3c7;
|
||||
color: #a16207;
|
||||
border: 1px solid #fde68a;
|
||||
}
|
||||
|
||||
.nr-banl-status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -358,6 +509,122 @@
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* ───────── Sender Account autocomplete rows ───────── */
|
||||
.nr-sender-opt {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.25;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.nr-sender-opt-acc {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--nr-g900);
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.nr-sender-opt-meta {
|
||||
font-size: 11.5px;
|
||||
color: var(--nr-g500);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.nr-sender-opt-bal {
|
||||
margin-left: auto;
|
||||
color: var(--nr-green);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* ───────── Remitter / Sender info chip ───────── */
|
||||
.nr-sender-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
background: var(--nr-blue-l);
|
||||
border: 1px solid #c7d2fe;
|
||||
color: var(--nr-g900);
|
||||
flex-wrap: wrap;
|
||||
|
||||
&.is-low {
|
||||
background: #fef2f2;
|
||||
border-color: #fecaca;
|
||||
}
|
||||
}
|
||||
|
||||
.nr-sender-chip-l {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.nr-sender-chip-ic {
|
||||
color: var(--nr-blue);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.nr-sender-chip-name {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--nr-g900);
|
||||
}
|
||||
|
||||
.nr-sender-chip-sub {
|
||||
font-size: 11.5px;
|
||||
font-weight: 500;
|
||||
color: var(--nr-g500);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.nr-sender-chip-r {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
line-height: 1.2;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nr-sender-chip-bal-l {
|
||||
font-size: 9.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--nr-g500);
|
||||
}
|
||||
|
||||
.nr-sender-chip-bal-v {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--nr-green);
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
.is-low & {
|
||||
color: #b91c1c;
|
||||
}
|
||||
}
|
||||
|
||||
.nr-sender-chip-warn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #b91c1c;
|
||||
|
||||
fa-icon {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ───────── BANL RESULT (compact, single-line) ───────── */
|
||||
.nr-banl-card {
|
||||
display: flex;
|
||||
|
||||
@@ -7,12 +7,21 @@
|
||||
*/
|
||||
|
||||
import { Component, inject, OnInit } from '@angular/core';
|
||||
import { AsyncPipe } from '@angular/common';
|
||||
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 { MatDialog, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatAutocomplete, MatAutocompleteTrigger } from '@angular/material/autocomplete';
|
||||
|
||||
import { seedNeftRtgsTransactions } from '../neft-rtgs-report/neft-rtgs-report.component';
|
||||
import {
|
||||
SenderHistoryDialogComponent,
|
||||
SenderHistoryDialogData
|
||||
} from '../sender-history-dialog/sender-history-dialog.component';
|
||||
import { Router } from '@angular/router';
|
||||
import { Observable, of, startWith, map } from 'rxjs';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
type TxnType = 'NEFT' | 'RTGS';
|
||||
@@ -32,6 +41,14 @@ interface BanlMatch {
|
||||
status: 'ACTIVE' | 'DORMANT';
|
||||
}
|
||||
|
||||
/** A signed-in user's own CBS account that may be used as the debit/sender side. */
|
||||
export interface SenderAccount {
|
||||
accountNo: string;
|
||||
holderName: string;
|
||||
productName: string;
|
||||
availableBalance: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-neft-rtgs-entry',
|
||||
templateUrl: './neft-rtgs-entry.component.html',
|
||||
@@ -39,13 +56,17 @@ interface BanlMatch {
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
FaIconComponent,
|
||||
MatTooltip
|
||||
MatTooltip,
|
||||
MatAutocomplete,
|
||||
MatAutocompleteTrigger,
|
||||
AsyncPipe
|
||||
]
|
||||
})
|
||||
export class NeftRtgsEntryComponent implements OnInit {
|
||||
private fb = inject(FormBuilder);
|
||||
private snack = inject(MatSnackBar);
|
||||
private router = inject(Router);
|
||||
private dialog = inject(MatDialog);
|
||||
/** Present when this component is opened inside a dialog. */
|
||||
private dialogRef = inject(MatDialogRef<NeftRtgsEntryComponent>, { optional: true });
|
||||
|
||||
@@ -55,8 +76,12 @@ export class NeftRtgsEntryComponent implements OnInit {
|
||||
|
||||
/** RTGS minimum threshold (₹2 Lakhs). */
|
||||
readonly RTGS_MIN = 200000;
|
||||
/** NEFT upper cap — amounts above this must use RTGS. */
|
||||
readonly NEFT_MAX = 200000;
|
||||
/** Threshold above which Purpose of Remittance is mandatory for NEFT (cross-border-style high value). */
|
||||
readonly HIGH_VALUE_THRESHOLD = 200000;
|
||||
/** GST rate applied on the service charge (18% per current GST schedule). */
|
||||
readonly GST_RATE = 0.18;
|
||||
|
||||
minDate = new Date(2000, 0, 1);
|
||||
maxDate = new Date(2100, 11, 31);
|
||||
@@ -68,6 +93,47 @@ export class NeftRtgsEntryComponent implements OnInit {
|
||||
banlMatch: BanlMatch | null = null;
|
||||
banlError: string | null = null;
|
||||
|
||||
/**
|
||||
* Operator's own CBS accounts available as the debit (sender) side.
|
||||
* Real implementation will fetch this from the accounts API filtered to the
|
||||
* signed-in user / branch.
|
||||
*/
|
||||
senderAccounts: SenderAccount[] = [
|
||||
{
|
||||
accountNo: 'SB10029384',
|
||||
holderName: 'Anita Desai',
|
||||
productName: 'Savings — Premium',
|
||||
availableBalance: 482500
|
||||
},
|
||||
{
|
||||
accountNo: 'CA20034112',
|
||||
holderName: 'Anita Desai',
|
||||
productName: 'Current — Business',
|
||||
availableBalance: 1245300
|
||||
},
|
||||
{
|
||||
accountNo: 'SB50200012345',
|
||||
holderName: 'Anita Desai',
|
||||
productName: 'Savings — Regular',
|
||||
availableBalance: 92300
|
||||
},
|
||||
{
|
||||
accountNo: 'OD30049827',
|
||||
holderName: 'Anita Desai',
|
||||
productName: 'Overdraft — Secured',
|
||||
availableBalance: 750000
|
||||
},
|
||||
{
|
||||
accountNo: 'CA60055123',
|
||||
holderName: 'Anita Desai HUF',
|
||||
productName: 'Current — HUF',
|
||||
availableBalance: 318900
|
||||
}
|
||||
];
|
||||
|
||||
/** Reactive stream of accounts matching the current sender-account input. */
|
||||
filteredSenderAccounts!: Observable<SenderAccount[]>;
|
||||
|
||||
/** Mock BANL directory — replace with API call later. */
|
||||
private banlDirectory: BanlMatch[] = [
|
||||
{
|
||||
@@ -159,7 +225,8 @@ export class NeftRtgsEntryComponent implements OnInit {
|
||||
'',
|
||||
[]
|
||||
],
|
||||
remarks: ['']
|
||||
remarks: [''],
|
||||
waiveCharges: [false]
|
||||
});
|
||||
|
||||
// Re-evaluate purpose requirement whenever amount or type changes.
|
||||
@@ -173,9 +240,28 @@ export class NeftRtgsEntryComponent implements OnInit {
|
||||
this.entryForm.get('beneficiaryAccount')?.valueChanges.subscribe(() => this.invalidateBanl());
|
||||
this.entryForm.get('ifscCode')?.valueChanges.subscribe(() => this.invalidateBanl());
|
||||
|
||||
// Wire sender-account autocomplete stream.
|
||||
this.filteredSenderAccounts =
|
||||
this.entryForm.get('senderAccount')?.valueChanges.pipe(
|
||||
startWith(this.entryForm.get('senderAccount')?.value || ''),
|
||||
map((value) => this.filterSenderAccounts(value || ''))
|
||||
) ?? of(this.senderAccounts.slice());
|
||||
|
||||
this.refreshAmountValidator(this.entryForm.get('transactionType')?.value);
|
||||
}
|
||||
|
||||
/** Case-insensitive contains-match on account number, holder name, or product. */
|
||||
private filterSenderAccounts(query: string): SenderAccount[] {
|
||||
const q = (query || '').toString().trim().toLowerCase();
|
||||
if (!q) return this.senderAccounts.slice();
|
||||
return this.senderAccounts.filter(
|
||||
(a) =>
|
||||
a.accountNo.toLowerCase().includes(q) ||
|
||||
a.holderName.toLowerCase().includes(q) ||
|
||||
a.productName.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
|
||||
/** Drop the prior BANL match + clear the auto-filled beneficiary name. */
|
||||
private invalidateBanl(): void {
|
||||
if (!this.banlMatch) return;
|
||||
@@ -193,7 +279,11 @@ export class NeftRtgsEntryComponent implements OnInit {
|
||||
Validators.min(1)
|
||||
];
|
||||
if (type === 'RTGS') {
|
||||
// RTGS floor: ₹2L
|
||||
validators.push(Validators.min(this.RTGS_MIN));
|
||||
} else {
|
||||
// NEFT cap: ₹2L per transaction (route higher amounts via RTGS)
|
||||
validators.push(Validators.max(this.NEFT_MAX));
|
||||
}
|
||||
amt.setValidators(validators);
|
||||
amt.updateValueAndValidity({ emitEvent: false });
|
||||
@@ -230,6 +320,87 @@ export class NeftRtgsEntryComponent implements OnInit {
|
||||
return this.isRtgs && this.amount > 0 && this.amount < this.RTGS_MIN;
|
||||
}
|
||||
|
||||
/** True when NEFT and the entered amount exceeds the NEFT cap. */
|
||||
get neftOverMax(): boolean {
|
||||
return !this.isRtgs && this.amount > this.NEFT_MAX;
|
||||
}
|
||||
|
||||
/** Sender account record matching the currently entered/picked account number. */
|
||||
get currentSender(): SenderAccount | null {
|
||||
const v = (this.entryForm?.get('senderAccount')?.value || '').toString().trim().toLowerCase();
|
||||
if (!v) return null;
|
||||
return this.senderAccounts.find((a) => a.accountNo.toLowerCase() === v) || null;
|
||||
}
|
||||
|
||||
/** True if the entered amount + fee would exceed the picked sender's available balance. */
|
||||
get insufficientBalance(): boolean {
|
||||
const s = this.currentSender;
|
||||
if (!s || this.amount <= 0) return false;
|
||||
return this.totalDebit > s.availableBalance;
|
||||
}
|
||||
|
||||
/** Tooltip for the header history button — kept as a getter so the template avoids string escaping. */
|
||||
get historyBtnTooltip(): string {
|
||||
const s = this.currentSender;
|
||||
return s ? `View past NEFT / RTGS transactions for ${s.holderName}` : 'Pick a sender account first';
|
||||
}
|
||||
|
||||
/** Operator can waive the service charge (e.g., staff / internal / relationship). */
|
||||
get chargesWaived(): boolean {
|
||||
return !!this.entryForm?.get('waiveCharges')?.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service charge schedule per RBI guidance for the NEFT / RTGS rails:
|
||||
* NEFT ≤ 10,000 → ₹2.50
|
||||
* ≤ 1,00,000 → ₹5
|
||||
* ≤ 2,00,000 → ₹15
|
||||
* > 2,00,000 → ₹25
|
||||
* RTGS 2L – 5L → ₹25
|
||||
* > 5L → ₹50
|
||||
* Returns 0 when the operator has waived charges or the amount is invalid.
|
||||
*/
|
||||
get serviceCharge(): number {
|
||||
if (this.chargesWaived) return 0;
|
||||
const a = this.amount;
|
||||
if (!a || a <= 0) return 0;
|
||||
if (this.isRtgs) {
|
||||
return a <= 500000 ? 25 : 50;
|
||||
}
|
||||
if (a <= 10000) return 2.5;
|
||||
if (a <= 100000) return 5;
|
||||
if (a <= 200000) return 15;
|
||||
return 25;
|
||||
}
|
||||
|
||||
/** 18% GST applied on the service charge. */
|
||||
get gstOnCharge(): number {
|
||||
return +(this.serviceCharge * this.GST_RATE).toFixed(2);
|
||||
}
|
||||
|
||||
/** Charge + GST — what's actually debited beyond the transfer amount. */
|
||||
get totalFee(): number {
|
||||
return +(this.serviceCharge + this.gstOnCharge).toFixed(2);
|
||||
}
|
||||
|
||||
/** Final debit hitting the sender account = amount + charge + GST (rounded paise). */
|
||||
get totalDebit(): number {
|
||||
return +(this.amount + this.totalFee).toFixed(2);
|
||||
}
|
||||
|
||||
/** Scheduled (pre-waive) charge — used to show the struck-through original when waived. */
|
||||
get scheduledCharge(): number {
|
||||
const a = this.amount;
|
||||
if (!a || a <= 0) return 0;
|
||||
if (this.isRtgs) {
|
||||
return a <= 500000 ? 25 : 50;
|
||||
}
|
||||
if (a <= 10000) return 2.5;
|
||||
if (a <= 100000) return 5;
|
||||
if (a <= 200000) return 15;
|
||||
return 25;
|
||||
}
|
||||
|
||||
/** True once a BANL lookup has resolved a valid beneficiary record. */
|
||||
get banlVerified(): boolean {
|
||||
return this.banlMatch !== null;
|
||||
@@ -392,7 +563,8 @@ export class NeftRtgsEntryComponent implements OnInit {
|
||||
beneficiaryAccount: '',
|
||||
ifscCode: '',
|
||||
purposeOfRemittance: '',
|
||||
remarks: ''
|
||||
remarks: '',
|
||||
waiveCharges: false
|
||||
});
|
||||
this.clearBanl();
|
||||
this.refreshAmountValidator('NEFT');
|
||||
@@ -431,4 +603,32 @@ export class NeftRtgsEntryComponent implements OnInit {
|
||||
'/home'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the sender-history dialog showing this customer's past NEFT / RTGS
|
||||
* outward transactions. Filters by A/C number first, then by holder name as
|
||||
* a fallback (since the demo seed data isn't keyed on the operator account
|
||||
* numbers). No-op if no sender is currently picked.
|
||||
*/
|
||||
viewSenderHistory(): void {
|
||||
const sender = this.currentSender;
|
||||
if (!sender) {
|
||||
this.snack.open('Pick a sender account first to view its history.', 'OK', { duration: 2500 });
|
||||
return;
|
||||
}
|
||||
const data: SenderHistoryDialogData = {
|
||||
senderName: sender.holderName,
|
||||
senderAccountNo: sender.accountNo,
|
||||
txns: seedNeftRtgsTransactions()
|
||||
};
|
||||
this.dialog.open(SenderHistoryDialogComponent, {
|
||||
width: '960px',
|
||||
maxWidth: '94vw',
|
||||
maxHeight: '88vh',
|
||||
autoFocus: false,
|
||||
restoreFocus: true,
|
||||
panelClass: 'shd-dialog',
|
||||
data
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,19 +90,22 @@
|
||||
</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 class="nrr-trend-row">
|
||||
<div class="nrr-trend-row-date">
|
||||
<span class="nrr-trend-row-day">{{ b.date | date: 'EEE' }}</span>
|
||||
<span class="nrr-trend-row-md">{{ b.date | date: 'd MMM' }}</span>
|
||||
</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
|
||||
class="nrr-trend-row-bar-wrap"
|
||||
[matTooltip]="'NEFT ' + b.neft + ' · RTGS ' + b.rtgs + ' · ' + (b.count | number) + ' txns'"
|
||||
>
|
||||
<div class="nrr-trend-row-bar" [style.width.%]="trendBarHeight(b.amount)"></div>
|
||||
</div>
|
||||
<div class="nrr-trend-row-pills">
|
||||
<span class="nrr-pill nrr-pill--blue mini">NEFT {{ b.neft }}</span>
|
||||
<span class="nrr-pill nrr-pill--purple mini">RTGS {{ b.rtgs }}</span>
|
||||
</div>
|
||||
<div class="nrr-trend-row-amt">₹{{ b.amount | number: '1.0-0' }}</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -335,68 +335,106 @@
|
||||
}
|
||||
|
||||
/* ───────── TREND CHART ───────── */
|
||||
|
||||
/* ───────── Daily-throughput leaderboard (horizontal-bar list) ───────── */
|
||||
.nrr-trend {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
min-height: 220px;
|
||||
padding: 10px 4px;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 6px 4px 2px;
|
||||
}
|
||||
|
||||
.nrr-trend-col {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.nrr-trend-row {
|
||||
display: grid;
|
||||
grid-template-columns: 96px 1fr auto auto;
|
||||
column-gap: 14px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 60px;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
transition: background 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--r-g50);
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
.nrr-trend-row-date {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.nrr-trend-row-day {
|
||||
font-size: 9.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--r-g500);
|
||||
}
|
||||
|
||||
.nrr-trend-row-md {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--r-g900);
|
||||
}
|
||||
|
||||
.nrr-trend-row-bar-wrap {
|
||||
position: relative;
|
||||
height: 10px;
|
||||
background: var(--r-g100);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.nrr-trend-row-bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--r-blue) 0%, var(--r-purple) 100%);
|
||||
border-radius: 999px;
|
||||
transition: width 0.3s ease;
|
||||
min-width: 6px;
|
||||
}
|
||||
|
||||
.nrr-trend-row-pills {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.nrr-trend-date {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--r-g700);
|
||||
.nrr-trend-row-amt {
|
||||
font-size: 13.5px;
|
||||
font-weight: 700;
|
||||
color: var(--r-g900);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
min-width: 92px;
|
||||
}
|
||||
|
||||
.nrr-trend-split {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
justify-content: center;
|
||||
@media (width <= 640px) {
|
||||
.nrr-trend-row {
|
||||
grid-template-columns: 70px 1fr;
|
||||
grid-template-areas:
|
||||
'date amt'
|
||||
'bar bar'
|
||||
'pills pills';
|
||||
gap: 6px 8px;
|
||||
}
|
||||
|
||||
.nrr-trend-row-date {
|
||||
grid-area: date;
|
||||
}
|
||||
|
||||
.nrr-trend-row-amt {
|
||||
grid-area: amt;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nrr-trend-row-bar-wrap {
|
||||
grid-area: bar;
|
||||
}
|
||||
|
||||
.nrr-trend-row-pills {
|
||||
grid-area: pills;
|
||||
}
|
||||
}
|
||||
|
||||
/* ───────── TABLE ───────── */
|
||||
|
||||
@@ -110,7 +110,7 @@ export class NeftRtgsReportComponent implements OnInit {
|
||||
];
|
||||
|
||||
ngOnInit(): void {
|
||||
this.txns = this.seedTransactions();
|
||||
this.txns = seedNeftRtgsTransactions();
|
||||
}
|
||||
|
||||
// ───────────────────── KPIs ─────────────────────
|
||||
@@ -455,293 +455,302 @@ export class NeftRtgsReportComponent implements OnInit {
|
||||
}
|
||||
|
||||
// ───────────────────── Mock seed data ─────────────────────
|
||||
/** @deprecated Use the exported `seedNeftRtgsTransactions()` instead. */
|
||||
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)
|
||||
}
|
||||
];
|
||||
return seedNeftRtgsTransactions();
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────── Shared mock seed (top-level export) ─────────────────────
|
||||
/**
|
||||
* Mock NEFT / RTGS history. Exported so the entry form (and any per-sender history
|
||||
* dialogs) can render from the same dataset without each component owning its own
|
||||
* copy. Replace with an API call when the back-end is wired up.
|
||||
*/
|
||||
export function seedNeftRtgsTransactions(): 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)
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
<!--
|
||||
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="shd-root">
|
||||
<!-- HEADER -->
|
||||
<div class="shd-head">
|
||||
<div class="shd-head-l">
|
||||
<fa-icon icon="chart-line" class="shd-head-ic"></fa-icon>
|
||||
<div>
|
||||
<h2 class="shd-head-t">{{ data.senderName }} <small>· NEFT / RTGS · Today</small></h2>
|
||||
<div class="shd-head-s">
|
||||
@if (data.senderAccountNo) {
|
||||
<span class="mono">{{ data.senderAccountNo }}</span> ·
|
||||
}
|
||||
<strong>{{ totalCount | number }}</strong> txns today ·
|
||||
<strong>₹{{ totalAmount | number: '1.0-0' }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="shd-x" (click)="close()" matTooltip="Close">
|
||||
<fa-icon icon="times"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- KPI STRIP -->
|
||||
<div class="shd-kpis">
|
||||
<div class="shd-kpi shd-kpi--blue">
|
||||
<div class="shd-kpi-n">{{ totalCount | number }}</div>
|
||||
<div class="shd-kpi-l">Total</div>
|
||||
</div>
|
||||
<div class="shd-kpi shd-kpi--teal">
|
||||
<div class="shd-kpi-n">₹{{ totalAmount | number: '1.0-0' }}</div>
|
||||
<div class="shd-kpi-l">Value</div>
|
||||
</div>
|
||||
<div class="shd-kpi shd-kpi--green">
|
||||
<div class="shd-kpi-n">{{ settledCount | number }}</div>
|
||||
<div class="shd-kpi-l">Settled ({{ successRate | number: '1.1-1' }}%)</div>
|
||||
</div>
|
||||
<div class="shd-kpi shd-kpi--amber">
|
||||
<div class="shd-kpi-n">{{ pendingCount | number }}</div>
|
||||
<div class="shd-kpi-l">In-Flight</div>
|
||||
</div>
|
||||
<div class="shd-kpi shd-kpi--red">
|
||||
<div class="shd-kpi-n">{{ returnedCount | number }}</div>
|
||||
<div class="shd-kpi-l">Returned</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TABLE -->
|
||||
<div class="shd-body">
|
||||
@if (filteredTxns.length === 0) {
|
||||
<div class="shd-empty">
|
||||
<fa-icon icon="info-circle"></fa-icon>
|
||||
<div>
|
||||
<strong>No NEFT / RTGS transactions today for this sender.</strong>
|
||||
<p>This view shows only today's outward transfers. Older history isn't included here.</p>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="shd-table-wrap">
|
||||
<table class="shd-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>UTR</th>
|
||||
<th>Beneficiary</th>
|
||||
<th>IFSC</th>
|
||||
<th>Type</th>
|
||||
<th class="num">Amount</th>
|
||||
<th>Value Date</th>
|
||||
<th>State</th>
|
||||
<th>Sent</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (t of filteredTxns; track t.utrNumber) {
|
||||
<tr>
|
||||
<td class="mono small">{{ t.utrNumber }}</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="shd-pill mini"
|
||||
[class.shd-pill--purple]="t.paymentType === 'RTGS'"
|
||||
[class.shd-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="shd-pill mini" [ngClass]="'shd-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>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* 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 {
|
||||
--shd-blue: #1d4ed8;
|
||||
--shd-blue-d: #1e40af;
|
||||
--shd-blue-l: #eff6ff;
|
||||
--shd-teal: #0d9488;
|
||||
--shd-teal-l: #ccfbf1;
|
||||
--shd-green: #15803d;
|
||||
--shd-green-l: #dcfce7;
|
||||
--shd-amber: #d97706;
|
||||
--shd-amber-l: #fef3c7;
|
||||
--shd-red: #b91c1c;
|
||||
--shd-red-l: #fee2e2;
|
||||
--shd-purple: #6d28d9;
|
||||
--shd-purple-l: #ede9fe;
|
||||
--shd-g50: #f8fafc;
|
||||
--shd-g100: #f1f5f9;
|
||||
--shd-g200: #e2e8f0;
|
||||
--shd-g300: #cbd5e1;
|
||||
--shd-g500: #64748b;
|
||||
--shd-g700: #334155;
|
||||
--shd-g900: #0f172a;
|
||||
|
||||
display: block;
|
||||
}
|
||||
|
||||
.shd-root {
|
||||
background: #fff;
|
||||
padding: 16px 20px 22px;
|
||||
max-width: 940px;
|
||||
width: 100%;
|
||||
max-height: 84vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ───────── Header ───────── */
|
||||
.shd-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
padding-bottom: 12px;
|
||||
margin-bottom: 14px;
|
||||
border-bottom: 1px solid var(--shd-g200);
|
||||
}
|
||||
|
||||
.shd-head-l {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.shd-head-ic {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 12px;
|
||||
background: var(--shd-blue);
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.shd-head-t {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--shd-g900);
|
||||
line-height: 1.25;
|
||||
|
||||
small {
|
||||
color: var(--shd-g500);
|
||||
font-weight: 500;
|
||||
font-size: 12.5px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.shd-head-s {
|
||||
margin-top: 3px;
|
||||
font-size: 12px;
|
||||
color: var(--shd-g500);
|
||||
|
||||
strong {
|
||||
color: var(--shd-g900);
|
||||
}
|
||||
}
|
||||
|
||||
.shd-x {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--shd-g200);
|
||||
background: #fff;
|
||||
color: var(--shd-g500);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
flex-shrink: 0;
|
||||
transition:
|
||||
background 0.12s,
|
||||
color 0.12s,
|
||||
border-color 0.12s;
|
||||
|
||||
&:hover {
|
||||
background: var(--shd-red-l);
|
||||
color: var(--shd-red);
|
||||
border-color: #fecaca;
|
||||
}
|
||||
}
|
||||
|
||||
/* ───────── KPI strip ───────── */
|
||||
.shd-kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
|
||||
@media (width <= 700px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.shd-kpi {
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
background: var(--shd-blue-l);
|
||||
border: 1px solid #c7d2fe;
|
||||
color: var(--shd-g900);
|
||||
|
||||
&--blue {
|
||||
background: var(--shd-blue-l);
|
||||
border-color: #c7d2fe;
|
||||
}
|
||||
|
||||
&--teal {
|
||||
background: var(--shd-teal-l);
|
||||
border-color: #99f6e4;
|
||||
}
|
||||
|
||||
&--green {
|
||||
background: var(--shd-green-l);
|
||||
border-color: #bbf7d0;
|
||||
}
|
||||
|
||||
&--amber {
|
||||
background: var(--shd-amber-l);
|
||||
border-color: #fde68a;
|
||||
}
|
||||
|
||||
&--red {
|
||||
background: var(--shd-red-l);
|
||||
border-color: #fecaca;
|
||||
}
|
||||
}
|
||||
|
||||
.shd-kpi-n {
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
color: var(--shd-g900);
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.shd-kpi-l {
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--shd-g500);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ───────── Body / table ───────── */
|
||||
.shd-body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.shd-empty {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 18px 16px;
|
||||
border: 1px dashed var(--shd-g300);
|
||||
border-radius: 10px;
|
||||
background: var(--shd-g50);
|
||||
color: var(--shd-g700);
|
||||
|
||||
fa-icon {
|
||||
color: var(--shd-blue);
|
||||
font-size: 18px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12.5px;
|
||||
color: var(--shd-g500);
|
||||
}
|
||||
}
|
||||
|
||||
.shd-table-wrap {
|
||||
overflow: auto;
|
||||
border: 1px solid var(--shd-g200);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.shd-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12.5px;
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
background: var(--shd-g50);
|
||||
color: var(--shd-g500);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
font-size: 10.5px;
|
||||
white-space: nowrap;
|
||||
border-bottom: 1px solid var(--shd-g200);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--shd-g100);
|
||||
vertical-align: top;
|
||||
color: var(--shd-g900);
|
||||
}
|
||||
|
||||
tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
.small {
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--shd-g500);
|
||||
}
|
||||
}
|
||||
|
||||
/* ───────── Pills ───────── */
|
||||
.shd-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
background: var(--shd-g100);
|
||||
color: var(--shd-g700);
|
||||
white-space: nowrap;
|
||||
|
||||
&.mini {
|
||||
padding: 1px 7px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
&--blue {
|
||||
background: var(--shd-blue-l);
|
||||
color: var(--shd-blue-d);
|
||||
}
|
||||
|
||||
&--purple {
|
||||
background: var(--shd-purple-l);
|
||||
color: var(--shd-purple);
|
||||
}
|
||||
|
||||
&--green {
|
||||
background: var(--shd-green-l);
|
||||
color: var(--shd-green);
|
||||
}
|
||||
|
||||
&--teal {
|
||||
background: var(--shd-teal-l);
|
||||
color: var(--shd-teal);
|
||||
}
|
||||
|
||||
&--amber {
|
||||
background: var(--shd-amber-l);
|
||||
color: var(--shd-amber);
|
||||
}
|
||||
|
||||
&--red {
|
||||
background: var(--shd-red-l);
|
||||
color: var(--shd-red);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* 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 } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
import { NeftRtgsTxn, ProcessingState } from '../neft-rtgs-report/neft-rtgs-report.component';
|
||||
|
||||
export interface SenderHistoryDialogData {
|
||||
/** Sender's name as shown on the sender chip. */
|
||||
senderName: string;
|
||||
/** Picked sender account number (for exact-match filtering). */
|
||||
senderAccountNo: string;
|
||||
/** Full transaction history to filter (already-seeded mock or live API rows). */
|
||||
txns: NeftRtgsTxn[];
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-sender-history-dialog',
|
||||
templateUrl: './sender-history-dialog.component.html',
|
||||
styleUrls: ['./sender-history-dialog.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
CommonModule,
|
||||
FaIconComponent,
|
||||
MatTooltip
|
||||
]
|
||||
})
|
||||
export class SenderHistoryDialogComponent {
|
||||
private dialogRef = inject(MatDialogRef<SenderHistoryDialogComponent>);
|
||||
data: SenderHistoryDialogData = inject(MAT_DIALOG_DATA);
|
||||
|
||||
/** Today's date as YYYY-MM-DD — matches the `valueDate` shape in the txn seed. */
|
||||
private get todayKey(): string {
|
||||
const d = new Date();
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Today's NEFT/RTGS rows for this sender — matched by exact A/C number when
|
||||
* available, otherwise by case-insensitive holder name (since the demo seed
|
||||
* and operator account list don't share account numbers). Older value dates
|
||||
* are excluded; this view is intentionally a today-only snapshot.
|
||||
*/
|
||||
get filteredTxns(): NeftRtgsTxn[] {
|
||||
const acc = (this.data.senderAccountNo || '').trim().toLowerCase();
|
||||
const name = (this.data.senderName || '').trim().toLowerCase();
|
||||
const today = this.todayKey;
|
||||
return (this.data.txns || []).filter((t) => {
|
||||
if (t.valueDate !== today) return false;
|
||||
if (acc && t.remitterAcc.toLowerCase() === acc) return true;
|
||||
if (name && t.remitterUser.toLowerCase() === name) return true;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
// ─── KPI getters ───
|
||||
get totalCount(): number {
|
||||
return this.filteredTxns.length;
|
||||
}
|
||||
get totalAmount(): number {
|
||||
return this.filteredTxns.reduce((s, t) => s + t.amount, 0);
|
||||
}
|
||||
get settledCount(): number {
|
||||
return this.filteredTxns.filter((t) => t.processingState === 'SETTLED').length;
|
||||
}
|
||||
get pendingCount(): number {
|
||||
return this.filteredTxns.filter((t) => [
|
||||
'UPLOADED',
|
||||
'VALIDATED',
|
||||
'SENT',
|
||||
'PENDING'
|
||||
].includes(t.processingState)).length;
|
||||
}
|
||||
get returnedCount(): number {
|
||||
return this.filteredTxns.filter((t) => t.processingState === 'RETURNED').length;
|
||||
}
|
||||
get successRate(): number {
|
||||
if (this.totalCount === 0) return 0;
|
||||
return (this.settledCount / this.totalCount) * 100;
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
<!--
|
||||
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="mobile-alt" class="nrr-head-ic"></fa-icon>
|
||||
<div>
|
||||
<h2 class="nrr-head-t">UPI — Report & Reconciliation</h2>
|
||||
<div class="nrr-head-s">
|
||||
Digital · Unified Payments Interface · 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="mobile-alt"></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">{{ p2pCount }} P2P · {{ p2mCount }} P2M</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">{{ successCount | number }}</div>
|
||||
<div class="nrr-kpi-l">Successful</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">initiated / pending</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">{{ failedCount | number }}</div>
|
||||
<div class="nrr-kpi-l">Failed / Expired</div>
|
||||
<div class="nrr-kpi-sub">check failure 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">P2P</span>
|
||||
<span class="nrr-pill nrr-pill--purple">P2M</span>
|
||||
</div>
|
||||
<div class="nrr-trend">
|
||||
@for (b of trendBuckets; track b.date) {
|
||||
<div class="nrr-trend-row">
|
||||
<div class="nrr-trend-row-date">
|
||||
<span class="nrr-trend-row-day">{{ b.date | date: 'EEE' }}</span>
|
||||
<span class="nrr-trend-row-md">{{ b.date | date: 'd MMM' }}</span>
|
||||
</div>
|
||||
<div
|
||||
class="nrr-trend-row-bar-wrap"
|
||||
[matTooltip]="'P2P ' + b.p2p + ' · P2M ' + b.p2m + ' · ' + (b.count | number) + ' txns'"
|
||||
>
|
||||
<div class="nrr-trend-row-bar" [style.width.%]="trendBarHeight(b.amount)"></div>
|
||||
</div>
|
||||
<div class="nrr-trend-row-pills">
|
||||
<span class="nrr-pill nrr-pill--blue mini">P2P {{ b.p2p }}</span>
|
||||
<span class="nrr-pill nrr-pill--purple mini">P2M {{ b.p2m }}</span>
|
||||
</div>
|
||||
<div class="nrr-trend-row-amt">₹{{ b.amount | number: '1.0-0' }}</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- HANDLER BANK -->
|
||||
@if (activeTab === 'bank') {
|
||||
<div class="nrr-card">
|
||||
<div class="nrr-card-h">
|
||||
<span><fa-icon icon="university" class="nrr-card-h-ic"></fa-icon> Payee / Handler 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">Success</th>
|
||||
<th class="num">Pending</th>
|
||||
<th class="num">Failed</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.success }}</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.failed }}</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">P2P</th>
|
||||
<th class="num">P2M</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.p2p }}</span>
|
||||
</td>
|
||||
<td class="num">
|
||||
<span class="nrr-pill nrr-pill--purple mini">{{ r.p2m }}</span>
|
||||
</td>
|
||||
<td class="num">
|
||||
<strong>₹{{ r.amount | number: '1.0-0' }}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- SUCCESS / PENDING / FAILED -->
|
||||
@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> Initiated / Pending </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> Successful </span>
|
||||
<span class="nrr-funnel-v">{{ successCount | number }}</span>
|
||||
</div>
|
||||
<div class="nrr-funnel-row">
|
||||
<span class="nrr-funnel-l"> <span class="nrr-dot nrr-dot--red"></span> Failed / Expired / Declined </span>
|
||||
<span class="nrr-funnel-v">{{ failedCount | number }}</span>
|
||||
</div>
|
||||
<div class="nrr-funnel-meter">
|
||||
<span class="seg green" [style.flex]="successCount || 0"></span>
|
||||
<span class="seg amber" [style.flex]="pendingCount || 0"></span>
|
||||
<span class="seg red" [style.flex]="failedCount || 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> Failure-Code Breakdown</span>
|
||||
</div>
|
||||
@if (failureCodeRows.length === 0) {
|
||||
<div class="nrr-empty">No failed 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 failureCodeRows; 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>
|
||||
}
|
||||
|
||||
<!-- APP-WISE -->
|
||||
@if (activeTab === 'app') {
|
||||
<div class="nrr-card">
|
||||
<div class="nrr-card-h">
|
||||
<span><fa-icon icon="mobile-alt" class="nrr-card-h-ic"></fa-icon> UPI App Mix</span>
|
||||
</div>
|
||||
<div class="nrr-channel-grid">
|
||||
@for (r of appRows; track r.app) {
|
||||
<div class="nrr-channel-card">
|
||||
<div class="nrr-channel-ic">
|
||||
<fa-icon icon="mobile-alt"></fa-icon>
|
||||
</div>
|
||||
<div class="nrr-channel-body">
|
||||
<div class="nrr-channel-t">{{ r.app }}</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> Top Payers</span>
|
||||
</div>
|
||||
<div class="nrr-table-wrap">
|
||||
<table class="nrr-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Payer</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 RRN, UPI Ref, VPA, Name, Bank…" />
|
||||
</div>
|
||||
<select [(ngModel)]="typeFilter" class="nrr-select" matTooltip="Filter by UPI sub-type">
|
||||
<option value="">All Types</option>
|
||||
<option value="P2P">P2P · Person-to-Person</option>
|
||||
<option value="P2M">P2M · Person-to-Merchant</option>
|
||||
<option value="COLLECT">Collect Request</option>
|
||||
</select>
|
||||
<select [(ngModel)]="stateFilter" class="nrr-select" matTooltip="Filter by processing state">
|
||||
<option value="">All States</option>
|
||||
<option value="INITIATED">Initiated</option>
|
||||
<option value="PENDING">Pending</option>
|
||||
<option value="SUCCESS">Success</option>
|
||||
<option value="FAILED">Failed</option>
|
||||
<option value="EXPIRED">Expired</option>
|
||||
<option value="DECLINED">Declined</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>RRN / UPI Ref</th>
|
||||
<th>Payer</th>
|
||||
<th>Payee</th>
|
||||
<th>App</th>
|
||||
<th>Type</th>
|
||||
<th class="num">Amount</th>
|
||||
<th>Value Date</th>
|
||||
<th>State</th>
|
||||
<th>Sent</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (t of filteredTxns; track t.rrn) {
|
||||
<tr>
|
||||
<td>
|
||||
<div class="mono small">{{ t.rrn }}</div>
|
||||
<div class="mono small muted">{{ t.upiRef }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div>{{ t.payerName }}</div>
|
||||
<div class="mono small muted">{{ t.payerVpa }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div>
|
||||
{{ t.payeeName }}
|
||||
@if (t.payeeType === 'MERCHANT') {
|
||||
<span class="nrr-pill nrr-pill--teal mini">Merchant</span>
|
||||
}
|
||||
</div>
|
||||
<div class="mono small muted">{{ t.payeeVpa }} · {{ t.payeeBank }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="nrr-pill mini">{{ t.payerApp }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
class="nrr-pill mini"
|
||||
[class.nrr-pill--purple]="t.txnType === 'P2M'"
|
||||
[class.nrr-pill--blue]="t.txnType === 'P2P'"
|
||||
[class.nrr-pill--teal]="t.txnType === 'COLLECT'"
|
||||
>
|
||||
{{ t.txnType }}
|
||||
</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="nrr-pill mini" [ngClass]="'nrr-pill--' + stateColor(t.state)">
|
||||
{{ t.state }}
|
||||
</span>
|
||||
@if (t.failureCode) {
|
||||
<div class="small muted">{{ t.failureCode }} · {{ t.failureReason }}</div>
|
||||
}
|
||||
</td>
|
||||
<td class="mono small">{{ t.sentTimestamp }}</td>
|
||||
</tr>
|
||||
}
|
||||
@if (filteredTxns.length === 0) {
|
||||
<tr>
|
||||
<td colspan="9" class="nrr-empty">No transactions match the current filters.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,618 @@
|
||||
/**
|
||||
* 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 } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
/** Person-to-Person, Person-to-Merchant, or Collect-Request (payee-initiated). */
|
||||
export type UpiTxnType = 'P2P' | 'P2M' | 'COLLECT';
|
||||
export type UpiState = 'INITIATED' | 'PENDING' | 'SUCCESS' | 'FAILED' | 'EXPIRED' | 'DECLINED';
|
||||
export type UpiApp = 'BHIM' | 'PhonePe' | 'GPay' | 'Paytm' | 'Amazon Pay' | 'WhatsApp' | 'Other';
|
||||
|
||||
export interface UpiTxn {
|
||||
rrn: string;
|
||||
upiRef: string;
|
||||
payerVpa: string;
|
||||
payerName: string;
|
||||
payerBranch: string;
|
||||
payerApp: UpiApp;
|
||||
payeeVpa: string;
|
||||
payeeName: string;
|
||||
payeeBank: string;
|
||||
payeeType: 'PERSON' | 'MERCHANT';
|
||||
txnType: UpiTxnType;
|
||||
amount: number;
|
||||
valueDate: string;
|
||||
state: UpiState;
|
||||
failureCode?: string;
|
||||
failureReason?: string;
|
||||
sentTimestamp: string;
|
||||
}
|
||||
|
||||
type ReportTab = 'trends' | 'bank' | 'date' | 'pending' | 'app' | 'user';
|
||||
|
||||
interface BankRow {
|
||||
bank: string;
|
||||
count: number;
|
||||
amount: number;
|
||||
success: number;
|
||||
pending: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
interface DateRow {
|
||||
date: string;
|
||||
count: number;
|
||||
amount: number;
|
||||
p2p: number;
|
||||
p2m: number;
|
||||
}
|
||||
|
||||
interface UserRow {
|
||||
user: string;
|
||||
count: number;
|
||||
amount: number;
|
||||
branch: string;
|
||||
}
|
||||
|
||||
interface AppRow {
|
||||
app: UpiApp;
|
||||
count: number;
|
||||
amount: number;
|
||||
share: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-upi-report',
|
||||
templateUrl: './upi-report.component.html',
|
||||
// Reuse the NEFT/RTGS report SCSS so all three rail-reports share the same shell.
|
||||
styleUrls: ['../neft-rtgs-report/neft-rtgs-report.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
FormsModule,
|
||||
FaIconComponent,
|
||||
MatTooltip
|
||||
]
|
||||
})
|
||||
export class UpiReportComponent implements OnInit {
|
||||
activeTab: ReportTab = 'trends';
|
||||
|
||||
search = '';
|
||||
typeFilter: '' | UpiTxnType = '';
|
||||
stateFilter: '' | UpiState = '';
|
||||
|
||||
txns: UpiTxn[] = [];
|
||||
|
||||
readonly tabs: { id: ReportTab; label: string; icon: string }[] = [
|
||||
{ id: 'trends', label: 'Trends', icon: 'chart-line' },
|
||||
{ id: 'bank', label: 'Handler-Bank', icon: 'university' },
|
||||
{ id: 'date', label: 'Date-wise', icon: 'calendar-alt' },
|
||||
{ id: 'pending', label: 'Success / Pending / Failed', icon: 'tasks' },
|
||||
{ id: 'app', label: 'App-wise', icon: 'mobile-alt' },
|
||||
{ id: 'user', label: 'User-wise', icon: 'user-tie' }
|
||||
];
|
||||
|
||||
ngOnInit(): void {
|
||||
this.txns = seedUpiTransactions();
|
||||
}
|
||||
|
||||
// ───────────────────── KPIs ─────────────────────
|
||||
get totalCount(): number {
|
||||
return this.txns.length;
|
||||
}
|
||||
get totalAmount(): number {
|
||||
return this.txns.reduce((s, t) => s + t.amount, 0);
|
||||
}
|
||||
get successCount(): number {
|
||||
return this.txns.filter((t) => t.state === 'SUCCESS').length;
|
||||
}
|
||||
get pendingCount(): number {
|
||||
return this.txns.filter((t) => [
|
||||
'INITIATED',
|
||||
'PENDING'
|
||||
].includes(t.state)).length;
|
||||
}
|
||||
get failedCount(): number {
|
||||
return this.txns.filter((t) => [
|
||||
'FAILED',
|
||||
'EXPIRED',
|
||||
'DECLINED'
|
||||
].includes(t.state)).length;
|
||||
}
|
||||
get p2pCount(): number {
|
||||
return this.txns.filter((t) => t.txnType === 'P2P').length;
|
||||
}
|
||||
get p2mCount(): number {
|
||||
return this.txns.filter((t) => t.txnType === 'P2M').length;
|
||||
}
|
||||
get successRate(): number {
|
||||
if (this.totalCount === 0) return 0;
|
||||
return (this.successCount / this.totalCount) * 100;
|
||||
}
|
||||
get avgTicket(): number {
|
||||
if (this.totalCount === 0) return 0;
|
||||
return this.totalAmount / this.totalCount;
|
||||
}
|
||||
|
||||
// ───────────────────── Filtered list ─────────────────────
|
||||
get filteredTxns(): UpiTxn[] {
|
||||
const q = this.search.trim().toLowerCase();
|
||||
return this.txns.filter((t) => {
|
||||
if (this.typeFilter && t.txnType !== this.typeFilter) return false;
|
||||
if (this.stateFilter && t.state !== this.stateFilter) return false;
|
||||
if (!q) return true;
|
||||
return (
|
||||
t.rrn.toLowerCase().includes(q) ||
|
||||
t.upiRef.toLowerCase().includes(q) ||
|
||||
t.payerVpa.toLowerCase().includes(q) ||
|
||||
t.payerName.toLowerCase().includes(q) ||
|
||||
t.payeeVpa.toLowerCase().includes(q) ||
|
||||
t.payeeName.toLowerCase().includes(q) ||
|
||||
t.payeeBank.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ───────────────────── Aggregations ─────────────────────
|
||||
get bankRows(): BankRow[] {
|
||||
const map = new Map<string, BankRow>();
|
||||
for (const t of this.txns) {
|
||||
const key = t.payeeBank;
|
||||
if (!map.has(key)) {
|
||||
map.set(key, { bank: key, count: 0, amount: 0, success: 0, pending: 0, failed: 0 });
|
||||
}
|
||||
const r = map.get(key)!;
|
||||
r.count++;
|
||||
r.amount += t.amount;
|
||||
if (t.state === 'SUCCESS') r.success++;
|
||||
else if (t.state === 'FAILED' || t.state === 'EXPIRED' || t.state === 'DECLINED') r.failed++;
|
||||
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, p2p: 0, p2m: 0 });
|
||||
}
|
||||
const r = map.get(t.valueDate)!;
|
||||
r.count++;
|
||||
r.amount += t.amount;
|
||||
if (t.txnType === 'P2P') r.p2p++;
|
||||
else if (t.txnType === 'P2M') r.p2m++;
|
||||
}
|
||||
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.payerName}|${t.payerBranch}`;
|
||||
if (!map.has(key)) {
|
||||
map.set(key, { user: t.payerName, branch: t.payerBranch, 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 appRows(): AppRow[] {
|
||||
const total = this.totalAmount || 1;
|
||||
const map = new Map<UpiApp, AppRow>();
|
||||
for (const t of this.txns) {
|
||||
if (!map.has(t.payerApp)) {
|
||||
map.set(t.payerApp, { app: t.payerApp, count: 0, amount: 0, share: 0 });
|
||||
}
|
||||
const r = map.get(t.payerApp)!;
|
||||
r.count++;
|
||||
r.amount += t.amount;
|
||||
}
|
||||
map.forEach((r) => (r.share = (r.amount / total) * 100));
|
||||
return Array.from(map.values()).sort((a, b) => b.amount - a.amount);
|
||||
}
|
||||
|
||||
get trendBuckets(): DateRow[] {
|
||||
return this.dateRows.slice(0, 7).reverse();
|
||||
}
|
||||
|
||||
get trendMaxAmount(): number {
|
||||
const buckets = this.trendBuckets;
|
||||
if (!buckets.length) return 1;
|
||||
return Math.max(...buckets.map((b) => b.amount));
|
||||
}
|
||||
|
||||
get failureCodeRows(): { 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) {
|
||||
const failed = t.state === 'FAILED' || t.state === 'EXPIRED' || t.state === 'DECLINED';
|
||||
if (!failed || !t.failureCode) continue;
|
||||
if (!map.has(t.failureCode)) {
|
||||
map.set(t.failureCode, {
|
||||
code: t.failureCode,
|
||||
reason: t.failureReason || '',
|
||||
count: 0,
|
||||
amount: 0
|
||||
});
|
||||
}
|
||||
const r = map.get(t.failureCode)!;
|
||||
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;
|
||||
}
|
||||
|
||||
exportCsv(): void {
|
||||
const headers = [
|
||||
'RRN',
|
||||
'UPI Ref',
|
||||
'Payer VPA',
|
||||
'Payer Name',
|
||||
'Payer App',
|
||||
'Payee VPA',
|
||||
'Payee Name',
|
||||
'Payee Bank',
|
||||
'Payee Type',
|
||||
'Txn Type',
|
||||
'Amount',
|
||||
'Value Date',
|
||||
'State',
|
||||
'Failure Code',
|
||||
'Sent Timestamp'
|
||||
];
|
||||
const rows: string[][] = [headers];
|
||||
for (const t of this.filteredTxns) {
|
||||
rows.push([
|
||||
t.rrn,
|
||||
t.upiRef,
|
||||
t.payerVpa,
|
||||
t.payerName,
|
||||
t.payerApp,
|
||||
t.payeeVpa,
|
||||
t.payeeName,
|
||||
t.payeeBank,
|
||||
t.payeeType,
|
||||
t.txnType,
|
||||
t.amount.toFixed(2),
|
||||
t.valueDate,
|
||||
t.state,
|
||||
t.failureCode || '',
|
||||
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 = `UPI_Report_${new Date().toISOString().slice(0, 10)}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
clearFilters(): void {
|
||||
this.search = '';
|
||||
this.typeFilter = '';
|
||||
this.stateFilter = '';
|
||||
}
|
||||
|
||||
// ───────────────────── Helpers ─────────────────────
|
||||
stateColor(state: UpiState): string {
|
||||
switch (state) {
|
||||
case 'SUCCESS':
|
||||
return 'green';
|
||||
case 'FAILED':
|
||||
case 'EXPIRED':
|
||||
case 'DECLINED':
|
||||
return 'red';
|
||||
case 'INITIATED':
|
||||
return 'teal';
|
||||
case 'PENDING':
|
||||
return 'amber';
|
||||
}
|
||||
}
|
||||
|
||||
trendBarHeight(amount: number): number {
|
||||
return Math.max(6, Math.round((amount / this.trendMaxAmount) * 100));
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────── Shared mock seed (top-level export) ─────────────────────
|
||||
/**
|
||||
* Mock UPI history. Exported so any per-sender history dialogs (or future
|
||||
* dashboards) can render from the same dataset. Replace with an API call.
|
||||
*/
|
||||
export function seedUpiTransactions(): UpiTxn[] {
|
||||
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 [
|
||||
{
|
||||
rrn: 'UPI52608001',
|
||||
upiRef: 'YBL5202608111',
|
||||
payerVpa: 'anita.desai@ybl',
|
||||
payerName: 'Anita Desai',
|
||||
payerBranch: 'Mumbai — BKC',
|
||||
payerApp: 'PhonePe',
|
||||
payeeVpa: 'amazon@apb',
|
||||
payeeName: 'Amazon Pay India',
|
||||
payeeBank: 'Axis Bank',
|
||||
payeeType: 'MERCHANT',
|
||||
txnType: 'P2M',
|
||||
amount: 1899,
|
||||
valueDate: vd(0),
|
||||
state: 'SUCCESS',
|
||||
sentTimestamp: dt(0, 9, 14)
|
||||
},
|
||||
{
|
||||
rrn: 'UPI52608002',
|
||||
upiRef: 'OKAXIS520260822',
|
||||
payerVpa: 'anita.desai@ybl',
|
||||
payerName: 'Anita Desai',
|
||||
payerBranch: 'Mumbai — BKC',
|
||||
payerApp: 'PhonePe',
|
||||
payeeVpa: 'rohan.m@oksbi',
|
||||
payeeName: 'Rohan S. Mehta',
|
||||
payeeBank: 'State Bank of India',
|
||||
payeeType: 'PERSON',
|
||||
txnType: 'P2P',
|
||||
amount: 2500,
|
||||
valueDate: vd(0),
|
||||
state: 'SUCCESS',
|
||||
sentTimestamp: dt(0, 10, 38)
|
||||
},
|
||||
{
|
||||
rrn: 'UPI52608003',
|
||||
upiRef: 'OKHDFC520260833',
|
||||
payerVpa: 'karthik@okhdfcbank',
|
||||
payerName: 'Karthik Rao',
|
||||
payerBranch: 'Bengaluru — MG Road',
|
||||
payerApp: 'GPay',
|
||||
payeeVpa: 'swiggy@hdfcbank',
|
||||
payeeName: 'Swiggy',
|
||||
payeeBank: 'HDFC Bank',
|
||||
payeeType: 'MERCHANT',
|
||||
txnType: 'P2M',
|
||||
amount: 645,
|
||||
valueDate: vd(0),
|
||||
state: 'SUCCESS',
|
||||
sentTimestamp: dt(0, 13, 22)
|
||||
},
|
||||
{
|
||||
rrn: 'UPI52608004',
|
||||
upiRef: 'BHIM5202608044',
|
||||
payerVpa: 'sushma.i@upi',
|
||||
payerName: 'Sushma Iyer',
|
||||
payerBranch: 'Delhi — Connaught',
|
||||
payerApp: 'BHIM',
|
||||
payeeVpa: 'uber@okhdfcbank',
|
||||
payeeName: 'Uber India',
|
||||
payeeBank: 'HDFC Bank',
|
||||
payeeType: 'MERCHANT',
|
||||
txnType: 'P2M',
|
||||
amount: 248,
|
||||
valueDate: vd(1),
|
||||
state: 'FAILED',
|
||||
failureCode: 'U28',
|
||||
failureReason: 'Beneficiary Account is Frozen',
|
||||
sentTimestamp: dt(1, 18, 5)
|
||||
},
|
||||
{
|
||||
rrn: 'UPI52608005',
|
||||
upiRef: 'OKICICI520260855',
|
||||
payerVpa: 'ramesh.k@oklicici',
|
||||
payerName: 'Ramesh Krishnan',
|
||||
payerBranch: 'Chennai — T. Nagar',
|
||||
payerApp: 'Paytm',
|
||||
payeeVpa: 'padma.r@upi',
|
||||
payeeName: 'Padma Rao',
|
||||
payeeBank: 'Canara Bank',
|
||||
payeeType: 'PERSON',
|
||||
txnType: 'P2P',
|
||||
amount: 1200,
|
||||
valueDate: vd(1),
|
||||
state: 'SUCCESS',
|
||||
sentTimestamp: dt(1, 11, 27)
|
||||
},
|
||||
{
|
||||
rrn: 'UPI52608006',
|
||||
upiRef: 'OKAXIS520260866',
|
||||
payerVpa: 'vikram.j@axl',
|
||||
payerName: 'Vikram Joshi',
|
||||
payerBranch: 'Pune — Koregaon Park',
|
||||
payerApp: 'GPay',
|
||||
payeeVpa: 'flipkart@apb',
|
||||
payeeName: 'Flipkart',
|
||||
payeeBank: 'Axis Bank',
|
||||
payeeType: 'MERCHANT',
|
||||
txnType: 'P2M',
|
||||
amount: 4799,
|
||||
valueDate: vd(2),
|
||||
state: 'SUCCESS',
|
||||
sentTimestamp: dt(2, 16, 48)
|
||||
},
|
||||
{
|
||||
rrn: 'UPI52608007',
|
||||
upiRef: 'BHIM5202608077',
|
||||
payerVpa: 'lakshmi.n@upi',
|
||||
payerName: 'Lakshmi Naidu',
|
||||
payerBranch: 'Hyderabad — Banjara',
|
||||
payerApp: 'BHIM',
|
||||
payeeVpa: 'aarav.ent@hdfcbank',
|
||||
payeeName: 'Aarav Enterprises',
|
||||
payeeBank: 'Kotak Mahindra Bank',
|
||||
payeeType: 'MERCHANT',
|
||||
txnType: 'P2M',
|
||||
amount: 11500,
|
||||
valueDate: vd(2),
|
||||
state: 'PENDING',
|
||||
sentTimestamp: dt(2, 9, 11)
|
||||
},
|
||||
{
|
||||
rrn: 'UPI52608008',
|
||||
upiRef: 'OKWA5202608088',
|
||||
payerVpa: 'arjun.b@oklicici',
|
||||
payerName: 'Arjun Banerjee',
|
||||
payerBranch: 'Kolkata — Park Street',
|
||||
payerApp: 'WhatsApp',
|
||||
payeeVpa: 'indrani.sen@axl',
|
||||
payeeName: 'Indrani Sen',
|
||||
payeeBank: 'Axis Bank',
|
||||
payeeType: 'PERSON',
|
||||
txnType: 'P2P',
|
||||
amount: 800,
|
||||
valueDate: vd(3),
|
||||
state: 'SUCCESS',
|
||||
sentTimestamp: dt(3, 14, 56)
|
||||
},
|
||||
{
|
||||
rrn: 'UPI52608009',
|
||||
upiRef: 'OKAMP520260899',
|
||||
payerVpa: 'anita.desai@apl',
|
||||
payerName: 'Anita Desai',
|
||||
payerBranch: 'Mumbai — BKC',
|
||||
payerApp: 'Amazon Pay',
|
||||
payeeVpa: 'amazon@apb',
|
||||
payeeName: 'Amazon Pay India',
|
||||
payeeBank: 'Axis Bank',
|
||||
payeeType: 'MERCHANT',
|
||||
txnType: 'P2M',
|
||||
amount: 2349,
|
||||
valueDate: vd(3),
|
||||
state: 'DECLINED',
|
||||
failureCode: 'U30',
|
||||
failureReason: 'Daily Limit Exceeded',
|
||||
sentTimestamp: dt(3, 21, 32)
|
||||
},
|
||||
{
|
||||
rrn: 'UPI52608010',
|
||||
upiRef: 'OKPAYTM5202608010',
|
||||
payerVpa: 'karthik@paytm',
|
||||
payerName: 'Karthik Rao',
|
||||
payerBranch: 'Bengaluru — MG Road',
|
||||
payerApp: 'Paytm',
|
||||
payeeVpa: 'zomato@paytm',
|
||||
payeeName: 'Zomato',
|
||||
payeeBank: 'Paytm Payments Bank',
|
||||
payeeType: 'MERCHANT',
|
||||
txnType: 'P2M',
|
||||
amount: 420,
|
||||
valueDate: vd(4),
|
||||
state: 'SUCCESS',
|
||||
sentTimestamp: dt(4, 20, 5)
|
||||
},
|
||||
{
|
||||
rrn: 'UPI52608011',
|
||||
upiRef: 'OKICICI520260811',
|
||||
payerVpa: 'sushma.i@upi',
|
||||
payerName: 'Sushma Iyer',
|
||||
payerBranch: 'Delhi — Connaught',
|
||||
payerApp: 'BHIM',
|
||||
payeeVpa: 'madhavi.s@upi',
|
||||
payeeName: 'Madhavi Singh',
|
||||
payeeBank: 'Punjab National Bank',
|
||||
payeeType: 'PERSON',
|
||||
txnType: 'P2P',
|
||||
amount: 6500,
|
||||
valueDate: vd(4),
|
||||
state: 'SUCCESS',
|
||||
sentTimestamp: dt(4, 12, 14)
|
||||
},
|
||||
{
|
||||
rrn: 'UPI52608012',
|
||||
upiRef: 'OKHDFC520260812',
|
||||
payerVpa: 'ramesh.k@okhdfcbank',
|
||||
payerName: 'Ramesh Krishnan',
|
||||
payerBranch: 'Chennai — T. Nagar',
|
||||
payerApp: 'GPay',
|
||||
payeeVpa: 'bbasket@axl',
|
||||
payeeName: 'Big Basket',
|
||||
payeeBank: 'Axis Bank',
|
||||
payeeType: 'MERCHANT',
|
||||
txnType: 'P2M',
|
||||
amount: 1380,
|
||||
valueDate: vd(5),
|
||||
state: 'SUCCESS',
|
||||
sentTimestamp: dt(5, 8, 49)
|
||||
},
|
||||
{
|
||||
rrn: 'UPI52608013',
|
||||
upiRef: 'OKAXIS520260813',
|
||||
payerVpa: 'lakshmi.n@upi',
|
||||
payerName: 'Lakshmi Naidu',
|
||||
payerBranch: 'Hyderabad — Banjara',
|
||||
payerApp: 'PhonePe',
|
||||
payeeVpa: 'spectra.cc@axl',
|
||||
payeeName: 'Spectra Components',
|
||||
payeeBank: 'State Bank of India',
|
||||
payeeType: 'MERCHANT',
|
||||
txnType: 'P2M',
|
||||
amount: 28999,
|
||||
valueDate: vd(5),
|
||||
state: 'EXPIRED',
|
||||
failureCode: 'U23',
|
||||
failureReason: 'Collect Request Expired',
|
||||
sentTimestamp: dt(5, 15, 18)
|
||||
},
|
||||
{
|
||||
rrn: 'UPI52608014',
|
||||
upiRef: 'OKICICI520260814',
|
||||
payerVpa: 'arjun.b@oklicici',
|
||||
payerName: 'Arjun Banerjee',
|
||||
payerBranch: 'Kolkata — Park Street',
|
||||
payerApp: 'GPay',
|
||||
payeeVpa: 'aircel@okhdfcbank',
|
||||
payeeName: 'Airtel Recharge',
|
||||
payeeBank: 'HDFC Bank',
|
||||
payeeType: 'MERCHANT',
|
||||
txnType: 'P2M',
|
||||
amount: 349,
|
||||
valueDate: vd(6),
|
||||
state: 'SUCCESS',
|
||||
sentTimestamp: dt(6, 10, 2)
|
||||
},
|
||||
{
|
||||
rrn: 'UPI52608015',
|
||||
upiRef: 'OKAXIS520260815',
|
||||
payerVpa: 'vikram.j@axl',
|
||||
payerName: 'Vikram Joshi',
|
||||
payerBranch: 'Pune — Koregaon Park',
|
||||
payerApp: 'WhatsApp',
|
||||
payeeVpa: 'neha.k@upi',
|
||||
payeeName: 'Neha Kapoor',
|
||||
payeeBank: 'HDFC Bank',
|
||||
payeeType: 'PERSON',
|
||||
txnType: 'P2P',
|
||||
amount: 1750,
|
||||
valueDate: vd(6),
|
||||
state: 'SUCCESS',
|
||||
sentTimestamp: dt(6, 17, 41)
|
||||
}
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user