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,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