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:
Aditya
2026-05-19 10:09:06 +05:30
co-authored by Claude Opus 4.7
parent 4322f37c6d
commit 325228c514
138 changed files with 27473 additions and 3997 deletions
@@ -6,23 +6,25 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="ac-container">
<!-- Page header -->
<div class="ac-page-head">
<div class="ac-page-head__icon"><fa-icon icon="shield-alt"></fa-icon></div>
<div class="ac-page-head__text">
<h2>Access Control · Multi-Factor Authentication</h2>
<p>
Pick a method to set up. The factor stays disabled until verification or registration completes for the
signed-in user <strong>{{ username }}</strong
>.
</p>
<div class="ac-container" [class.is-in-setup]="selectedMethod">
<!-- Page header (hidden once a method is being set up) -->
@if (!selectedMethod) {
<div class="ac-page-head">
<div class="ac-page-head__icon"><fa-icon icon="shield-alt"></fa-icon></div>
<div class="ac-page-head__text">
<h2>Access Control · Multi-Factor Authentication</h2>
<p>
Pick a method to set up. The factor stays disabled until verification or registration completes for the
signed-in user <strong>{{ username }}</strong
>.
</p>
</div>
<div class="ac-page-head__status" [class.is-on]="anyMethodActive">
<mat-icon>{{ anyMethodActive ? 'lock' : 'lock_open' }}</mat-icon>
{{ anyMethodActive ? 'MFA Active' : 'MFA Off' }}
</div>
</div>
<div class="ac-page-head__status" [class.is-on]="anyMethodActive">
<mat-icon>{{ anyMethodActive ? 'lock' : 'lock_open' }}</mat-icon>
{{ anyMethodActive ? 'MFA Active' : 'MFA Off' }}
</div>
</div>
}
<!-- ════════════ METHOD PICKER (default view) ════════════ -->
@if (!selectedMethod) {
@@ -918,4 +920,22 @@
}
</section>
}
<!-- Floating "Logs" chip — bottom-left of the viewport -->
<button
type="button"
class="ac-logs-chip"
(click)="openLogsDialog()"
matTooltip="View MFA activity & audit logs"
matTooltipPosition="right"
aria-label="View MFA activity logs"
>
<span class="ac-logs-chip__ic">
<fa-icon icon="history"></fa-icon>
</span>
<span class="ac-logs-chip__label">
<span class="ac-logs-chip__title">Logs</span>
<span class="ac-logs-chip__sub">Method &amp; sign-in activity</span>
</span>
</button>
</div>
@@ -398,9 +398,9 @@
.ac-back {
display: inline-flex;
align-items: center;
gap: 12px;
padding: 6px 16px 6px 6px;
margin-bottom: 16px;
gap: 10px;
padding: 4px 14px 4px 4px;
margin-bottom: 0;
border: 1px solid #e2e8f0;
border-radius: 999px;
background: #fff;
@@ -433,8 +433,8 @@
}
&__icon {
width: 32px;
height: 32px;
width: 28px;
height: 28px;
border-radius: 999px;
background: #eff6ff;
color: #1e40af;
@@ -447,9 +447,9 @@
color 0.18s;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
font-size: 16px;
width: 16px;
height: 16px;
transition: transform 0.18s;
}
}
@@ -457,12 +457,12 @@
&__text {
display: flex;
flex-direction: column;
line-height: 1.15;
line-height: 1.1;
text-align: left;
}
&__sub {
font-size: 10px;
font-size: 9px;
font-weight: 700;
letter-spacing: 0.5px;
text-transform: uppercase;
@@ -470,13 +470,13 @@
}
&__main {
font-size: 13px;
font-size: 12px;
font-weight: 700;
color: #0f172a;
}
&--inner {
margin-bottom: 14px;
margin-bottom: 10px;
background: #f8fafc;
.ac-back__icon {
@@ -545,71 +545,99 @@
/* ───────────────────────── Setup panel ───────────────────────── */
.ac-panel {
margin-top: 14px;
margin-top: 10px;
border: 1px solid #e2e8f0;
border-radius: 16px;
background: #fff;
padding: 16px 20px;
padding: 12px 18px 16px;
scroll-margin-top: 80px;
/* Place the outer back button and the per-method header on the SAME row,
so the form below appears immediately (no extra scroll). */
display: grid;
grid-template-columns: auto 1fr;
gap: 0 14px;
align-items: center;
// stylelint-disable-next-line selector-max-universal
> * {
grid-column: 1 / -1;
min-width: 0;
}
> .ac-back {
grid-column: 1;
grid-row: 1;
align-self: center;
}
> .ac-panel__head {
grid-column: 2;
grid-row: 1;
}
&__head {
display: flex;
align-items: center;
gap: 14px;
padding-bottom: 12px;
border-bottom: 1px solid #f1f5f9;
margin-bottom: 16px;
gap: 12px;
padding-bottom: 0;
border-bottom: 0;
margin: 0 0 6px;
}
&__icon {
width: 50px;
height: 50px;
border-radius: 14px;
width: 38px;
height: 38px;
border-radius: 11px;
display: inline-flex;
align-items: center;
justify-content: center;
mat-icon {
font-size: 24px;
width: 24px;
height: 24px;
font-size: 20px;
width: 20px;
height: 20px;
}
}
&__title {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
h3 {
margin: 0 0 3px;
font-size: 16px;
margin: 0;
font-size: 15px;
font-weight: 700;
color: #0f172a;
}
}
&__sub {
margin: 0 0 16px;
font-size: 12.5px;
margin: 0 0 10px;
font-size: 12px;
color: #64748b;
line-height: 1.5;
line-height: 1.45;
}
&__body {
display: flex;
flex-direction: column;
gap: 14px;
gap: 12px;
}
}
/* ───────────────────────── Progress stepper ───────────────────────── */
.ac-stepper {
list-style: none;
margin: 0 0 14px;
padding: 10px 16px;
margin: 0 0 10px;
padding: 8px 14px;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
gap: 6px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 12px;
@@ -618,8 +646,8 @@
position: relative;
display: flex;
align-items: center;
gap: 10px;
padding: 6px 4px;
gap: 8px;
padding: 4px 2px;
&::after {
content: '';
@@ -638,15 +666,15 @@
}
&__num {
width: 28px;
height: 28px;
width: 24px;
height: 24px;
border-radius: 999px;
background: #e2e8f0;
color: #94a3b8;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-size: 11px;
font-weight: 700;
flex-shrink: 0;
transition:
@@ -1628,4 +1656,142 @@
.ac-hw-row__cta {
grid-column: 1 / -1;
}
/* Stack back button above the per-method header on narrow screens. */
.ac-panel {
grid-template-columns: 1fr;
row-gap: 8px;
}
.ac-panel > .ac-back,
.ac-panel > .ac-panel__head {
grid-column: 1 / -1;
}
.ac-panel > .ac-back {
grid-row: 1;
justify-self: start;
}
.ac-panel > .ac-panel__head {
grid-row: 2;
}
}
/* ═══════════════════════════════════════════════════
Floating "Logs" chip — bottom-left, sticky to viewport
═══════════════════════════════════════════════════ */
.ac-logs-chip {
position: fixed;
left: 22px;
bottom: 22px;
display: inline-flex;
align-items: center;
gap: 11px;
padding: 10px 16px 10px 12px;
background: linear-gradient(135deg, #0b1e3d 0%, #1d4ed8 100%);
color: #fff;
border: 0;
border-radius: 999px;
box-shadow:
0 12px 26px rgb(11 30 61 / 35%),
0 4px 10px rgb(11 30 61 / 22%),
inset 0 1px 0 rgb(255 255 255 / 12%);
cursor: pointer;
font-family: inherit;
z-index: 60;
transition:
transform 0.18s ease,
box-shadow 0.18s ease;
&:hover {
transform: translateY(-2px);
box-shadow:
0 16px 32px rgb(11 30 61 / 42%),
0 6px 14px rgb(11 30 61 / 28%),
inset 0 1px 0 rgb(255 255 255 / 18%);
}
&:active {
transform: translateY(0);
}
&:focus-visible {
outline: 2px solid #60a5fa;
outline-offset: 3px;
}
}
.ac-logs-chip__ic {
width: 32px;
height: 32px;
border-radius: 50%;
background: rgb(255 255 255 / 14%);
border: 1px solid rgb(255 255 255 / 22%);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 13px;
flex-shrink: 0;
}
.ac-logs-chip__label {
display: flex;
flex-direction: column;
line-height: 1.15;
text-align: left;
}
.ac-logs-chip__title {
font-size: 13px;
font-weight: 700;
letter-spacing: 0.04em;
}
.ac-logs-chip__sub {
font-size: 10px;
font-weight: 500;
color: rgb(255 255 255 / 70%);
margin-top: 2px;
text-transform: uppercase;
letter-spacing: 0.06em;
}
/* Narrow screens — collapse to an icon-only round button */
@media (width <= 600px) {
.ac-logs-chip {
padding: 10px;
border-radius: 50%;
}
.ac-logs-chip__label {
display: none;
}
.ac-logs-chip__ic {
width: 28px;
height: 28px;
}
}
/* ───── Dialog panel override — kill default Material padding ───── */
:host ::ng-deep .ml-dialog-panel {
.mat-mdc-dialog-surface {
padding: 0 !important;
border-radius: 14px !important;
overflow: hidden !important;
box-shadow:
0 24px 48px rgb(15 23 42 / 28%),
0 8px 16px rgb(15 23 42 / 18%) !important;
}
.mat-mdc-dialog-container .mdc-dialog__surface {
padding: 0 !important;
}
.mat-mdc-dialog-content {
padding: 0 !important;
margin: 0 !important;
max-height: none !important;
}
}
@@ -26,6 +26,7 @@ import { EnrollDscDialogComponent } from 'app/users/dsc/enroll-dsc-dialog/enroll
import { RevokeDscDialogComponent } from 'app/users/dsc/revoke-dsc-dialog/revoke-dsc-dialog.component';
import { SuccessDialogComponent } from 'app/shared/success-dialog/success-dialog.component';
import { ErrorDialogComponent } from 'app/shared/error-dialog/error-dialog.component';
import { MfaLogsDialogComponent } from './mfa-logs-dialog/mfa-logs-dialog.component';
type HwSubKind = 'smart-card' | 'digital-signature' | 'biometric';
type MethodKey = 'sms' | 'email' | 'totp' | 'hardware';
@@ -938,6 +939,17 @@ export class AccessControlComponent implements OnInit, OnDestroy {
this.dialog.open(ErrorDialogComponent, { data: message });
}
/** Floating Logs chip → open the tabbed audit-logs dialog. */
openLogsDialog(): void {
this.dialog.open(MfaLogsDialogComponent, {
width: '880px',
maxWidth: '95vw',
maxHeight: '90vh',
autoFocus: false,
panelClass: 'ml-dialog-panel'
});
}
/**
* Reconcile the DSC tile with the server: if /v1/dsc/me returns at least one ACTIVE
* DSC, the tile is considered "registered". Called from ngOnInit alongside the
@@ -7,7 +7,7 @@
*/
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
export interface OtpGenerateResponse {
@@ -46,6 +46,57 @@ export interface TotpVerifyResponse {
message?: string;
}
/** Which logical view the audit row belongs to. */
export type MfaLogTab = 'enrollment' | 'login';
/** Factor types reported by the audit feed (matches MfaFactorType minus TOKEN). */
export type MfaLogFactorType = 'SMS' | 'EMAIL' | 'TOTP' | 'DSC';
/** Status values — see API doc. Enrolment tab: ACTIVE/DISABLED; Login tab: the rest. */
export type MfaLogStatus = 'ACTIVE' | 'DISABLED' | 'PENDING' | 'SENT' | 'VERIFIED' | 'FAILED' | 'EXPIRED' | 'LOCKED';
/** Unified audit-log row returned by GET /v1/mfa/logs. */
export interface MfaLogEntry {
tab: 'ENROLLMENT' | 'LOGIN';
id: number;
source: 'USER_MFA_FACTOR' | 'OTP_LOG' | 'DSC_CHALLENGE';
userId: number;
username: string;
userFullName: string;
factorType: MfaLogFactorType;
destination: string;
status: MfaLogStatus;
/** Local datetime — when the event happened. */
eventOn: string;
/** UTC offset datetime — when the verification succeeded (null if not verified). */
verifiedOn?: string | null;
/** Login tab only. */
attempts?: number | null;
ipAddress?: string | null;
userAgent?: string | null;
message: string;
provider: string;
}
/** Paged response wrapper. */
export interface MfaLogPage {
totalFilteredRecords: number;
pageItems: MfaLogEntry[];
}
/** Optional filters — same shape for both tabs. */
export interface MfaLogFilters {
factorType?: MfaLogFactorType;
status?: MfaLogStatus;
userId?: number;
username?: string;
search?: string;
/** yyyy-MM-dd, treated as start-of-day. */
dateFrom?: string;
/** yyyy-MM-dd, treated as end-of-day (inclusive). */
dateTo?: string;
}
export interface MfaFactor {
id?: number;
factorType: MfaFactorType;
@@ -135,4 +186,28 @@ export class AccessControlService {
verifyTotp(code: string): Observable<TotpVerifyResponse> {
return this.http.post<TotpVerifyResponse>('/v1/mfa/totp/verify', { code }, { headers: this.silentHeaders });
}
// ───── MFA Logs (enrolment + login audit) ─────
/**
* Unified audit feed for MFA events.
* `tab=enrollment` returns one row per successfully verified factor
* (USER_MFA_FACTOR snapshot). `tab=login` returns every 2FA verification
* attempt at sign-in (OTP_LOG + DSC_CHALLENGE combined).
*
* Filters and pagination are applied server-side per the API spec.
*/
getMfaLogs(tab: MfaLogTab, page = 0, pageSize = 25, filters: MfaLogFilters = {}): Observable<MfaLogPage> {
let params = new HttpParams().set('tab', tab).set('page', String(page)).set('pageSize', String(pageSize));
Object.entries(filters).forEach(
([
k,
v
]) => {
if (v !== undefined && v !== null && v !== '') {
params = params.set(k, String(v));
}
}
);
return this.http.get<MfaLogPage>('/v1/mfa/logs', { params, headers: this.silentHeaders });
}
}
@@ -0,0 +1,299 @@
<!--
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="ml-dialog">
<!-- Header -->
<div class="ml-header">
<div class="ml-header-ic">
<fa-icon icon="history"></fa-icon>
</div>
<div class="ml-header-text">
<h2>MFA Activity Logs</h2>
<p>Enrolled factors and 2FA / MFA sign-in attempts</p>
</div>
<button type="button" class="ml-close" (click)="close()" matTooltip="Close">
<fa-icon icon="times"></fa-icon>
</button>
</div>
<!-- Tab strip -->
<div class="ml-tabs">
<button class="ml-tab" [class.active]="activeTab === 'enrollment'" (click)="setTab('enrollment')" type="button">
<fa-icon icon="cog"></fa-icon>
<span>Enrolment</span>
@if (enrollment.state === 'loaded') {
<span class="ml-tab-count">{{ enrollment.total }}</span>
}
</button>
<button class="ml-tab" [class.active]="activeTab === 'login'" (click)="setTab('login')" type="button">
<fa-icon icon="shield-alt"></fa-icon>
<span>2FA / MFA Login</span>
@if (login.state === 'loaded') {
<span class="ml-tab-count">{{ login.total }}</span>
}
</button>
</div>
<!-- Filter bar — shared layout, status options switch per tab -->
<div class="ml-filters">
<div class="ml-filter-search">
<fa-icon icon="search" class="ml-filter-search-ic"></fa-icon>
<input
type="text"
[ngModel]="current.searchInput"
(ngModelChange)="onSearchInput(activeTab, $event)"
placeholder="Search username, destination, OTP reference…"
/>
</div>
<select
class="ml-filter-select"
[ngModel]="current.filters.factorType || ''"
(ngModelChange)="setFilter(activeTab, 'factorType', $event)"
matTooltip="Filter by factor type"
>
<option value="">All factors</option>
@for (f of factorTypes; track f) {
<option [value]="f">{{ factorLabel(f) }}</option>
}
</select>
<select
class="ml-filter-select"
[ngModel]="current.filters.status || ''"
(ngModelChange)="setFilter(activeTab, 'status', $event)"
matTooltip="Filter by status"
>
<option value="">All statuses</option>
@if (activeTab === 'enrollment') {
@for (s of enrollmentStatuses; track s) {
<option [value]="s">{{ s }}</option>
}
} @else {
@for (s of loginStatuses; track s) {
<option [value]="s">{{ s }}</option>
}
}
</select>
<div class="ml-filter-dates">
<label>
<span>From</span>
<input
type="date"
[ngModel]="current.filters.dateFrom || ''"
(ngModelChange)="setFilter(activeTab, 'dateFrom', $event)"
/>
</label>
<label>
<span>To</span>
<input
type="date"
[ngModel]="current.filters.dateTo || ''"
(ngModelChange)="setFilter(activeTab, 'dateTo', $event)"
/>
</label>
</div>
@if (hasActiveFilters(activeTab)) {
<button type="button" class="ml-filter-clear" (click)="clearFilters(activeTab)">
<fa-icon icon="times"></fa-icon> Clear
</button>
}
<button type="button" class="ml-filter-refresh" (click)="refresh(activeTab)" matTooltip="Refresh">
<fa-icon icon="redo"></fa-icon>
</button>
</div>
<!-- Body -->
<div class="ml-body">
<!-- Loading -->
@if (current.state === 'loading') {
<div class="ml-skeleton">
@for (i of skeletonRows(); track i) {
<div class="ml-skel-row">
<span class="ml-skel ml-skel--xs"></span>
<span class="ml-skel ml-skel--md"></span>
<span class="ml-skel ml-skel--sm"></span>
<span class="ml-skel ml-skel--lg"></span>
<span class="ml-skel ml-skel--sm"></span>
</div>
}
</div>
}
<!-- Empty -->
@if (current.state === 'empty') {
<div class="ml-empty">
<div class="ml-empty-ic">
<fa-icon [icon]="activeTab === 'enrollment' ? 'clipboard-list' : 'shield-alt'"></fa-icon>
</div>
@if (activeTab === 'enrollment') {
<h3>No enrolled factors</h3>
<p>
Activity here will populate as factors are verified or removed. Try clearing filters if you're looking for
an older record.
</p>
} @else {
<h3>No sign-in attempts logged</h3>
<p>
2FA / MFA challenges raised at sign-in will appear here — successful verifications, failed codes, expired
sessions, and account locks.
</p>
}
@if (hasActiveFilters(activeTab)) {
<button type="button" class="ml-empty-btn" (click)="clearFilters(activeTab)">
<fa-icon icon="times"></fa-icon> Clear filters
</button>
} @else {
<button type="button" class="ml-empty-btn" (click)="refresh(activeTab)">
<fa-icon icon="redo"></fa-icon> Refresh
</button>
}
</div>
}
<!-- Error -->
@if (current.state === 'error') {
<div class="ml-error">
<fa-icon icon="exclamation-triangle"></fa-icon>
<div>
<strong>Couldn't load logs</strong>
<p>{{ current.error }}</p>
</div>
<button type="button" class="ml-error-btn" (click)="refresh(activeTab)">
<fa-icon icon="redo"></fa-icon> Retry
</button>
</div>
}
<!-- Loaded — Enrolment table -->
@if (current.state === 'loaded' && activeTab === 'enrollment') {
<div class="ml-table-wrap">
<table class="ml-table">
<thead>
<tr>
<th>Event time</th>
<th>User</th>
<th>Factor</th>
<th>Destination</th>
<th>Status</th>
<th>Verified on</th>
<th>Message</th>
</tr>
</thead>
<tbody>
@for (row of enrollment.rows; track $index) {
<tr>
<td class="mono">{{ formatTs(row.eventOn) }}</td>
<td>
<div>{{ row.userFullName || row.username }}</div>
<div class="muted small mono">{{ row.username }}</div>
</td>
<td>
<span class="ml-factor-chip">
<fa-icon [icon]="factorIcon(row.factorType)"></fa-icon>
{{ factorLabel(row.factorType) }}
</span>
</td>
<td class="mono small">{{ row.destination || '—' }}</td>
<td>
<span class="ml-pill" [class]="'ml-pill--' + statusColor(row.status)">{{ row.status }}</span>
</td>
<td class="mono small">{{ formatTs(row.verifiedOn) }}</td>
<td class="small">{{ row.message || '—' }}</td>
</tr>
}
</tbody>
</table>
</div>
}
<!-- Loaded — Login table -->
@if (current.state === 'loaded' && activeTab === 'login') {
<div class="ml-table-wrap">
<table class="ml-table">
<thead>
<tr>
<th>Event time</th>
<th>User</th>
<th>Factor</th>
<th>Destination</th>
<th>Status</th>
<th class="num">Attempts</th>
<th>IP / Device</th>
<th>Message</th>
</tr>
</thead>
<tbody>
@for (row of login.rows; track $index) {
<tr>
<td class="mono">{{ formatTs(row.eventOn) }}</td>
<td>
<div>{{ row.userFullName || row.username }}</div>
<div class="muted small mono">{{ row.username }}</div>
</td>
<td>
<span class="ml-factor-chip">
<fa-icon [icon]="factorIcon(row.factorType)"></fa-icon>
{{ factorLabel(row.factorType) }}
</span>
</td>
<td class="mono small">{{ row.destination || '—' }}</td>
<td>
<span class="ml-pill" [class]="'ml-pill--' + statusColor(row.status)">{{ row.status }}</span>
</td>
<td class="num">{{ row.attempts ?? '—' }}</td>
<td class="mono small">
{{ row.ipAddress || '—' }}
@if (row.userAgent) {
<div class="muted small">{{ row.userAgent }}</div>
}
</td>
<td class="small">{{ row.message || '—' }}</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
<!-- Pagination footer -->
@if (current.state === 'loaded' || (current.state === 'empty' && current.total > 0)) {
<div class="ml-pager">
<div class="ml-pager-info">{{ currentRangeLabel }}</div>
<div class="ml-pager-ctrls">
<label class="ml-pager-pagesize">
Rows
<select [ngModel]="current.pageSize" (ngModelChange)="changePageSize(+$event)">
<option [value]="10">10</option>
<option [value]="25">25</option>
<option [value]="50">50</option>
<option [value]="100">100</option>
</select>
</label>
<button type="button" class="ml-pager-btn" [disabled]="current.page === 0" (click)="prevPage()">
<fa-icon icon="chevron-left"></fa-icon> Prev
</button>
<span class="ml-pager-page">
Page <strong>{{ current.page + 1 }}</strong> of {{ currentTotalPages }}
</span>
<button
type="button"
class="ml-pager-btn"
[disabled]="current.page + 1 >= currentTotalPages"
(click)="nextPage()"
>
Next <fa-icon icon="chevron-right"></fa-icon>
</button>
</div>
</div>
}
</div>
@@ -0,0 +1,702 @@
/**
* 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 {
--ml-navy: #0b1e3d;
--ml-blue: #1d4ed8;
--ml-blue-l: #eff6ff;
--ml-green: #15803d;
--ml-green-l: #dcfce7;
--ml-amber: #b45309;
--ml-amber-l: #fef3c7;
--ml-red: #c0392b;
--ml-red-l: #fee2e2;
--ml-gray-l: #f1f5f9;
--ml-gray: #64748b;
--ml-g200: #e2e8f0;
--ml-g500: #64748b;
--ml-g700: #334155;
--ml-g900: #0f172a;
display: block;
font-family:
'DM Sans',
system-ui,
-apple-system,
sans-serif;
color: var(--ml-g900);
font-size: 13px;
}
.ml-dialog {
display: flex;
flex-direction: column;
width: 100%;
max-height: 82vh;
background: #fff;
}
/* ───────── HEADER ───────── */
.ml-header {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 18px;
background: linear-gradient(135deg, #0b1e3d 0%, #1d4ed8 100%);
color: #fff;
position: sticky;
top: 0;
z-index: 2;
}
.ml-header-ic {
width: 38px;
height: 38px;
border-radius: 10px;
background: rgb(255 255 255 / 14%);
border: 1px solid rgb(255 255 255 / 22%);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 17px;
flex-shrink: 0;
}
.ml-header-text {
flex: 1;
h2 {
margin: 0;
font-size: 15px;
font-weight: 700;
letter-spacing: 0.01em;
}
p {
margin: 2px 0 0;
font-size: 11.5px;
color: rgb(255 255 255 / 80%);
}
}
.ml-close {
width: 30px;
height: 30px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgb(255 255 255 / 10%);
color: #fff;
border: 1px solid rgb(255 255 255 / 18%);
font-size: 12px;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: rgb(255 255 255 / 22%);
border-color: rgb(255 255 255 / 40%);
}
}
/* ───────── TABS ───────── */
.ml-tabs {
display: flex;
gap: 4px;
padding: 10px 14px 0;
background: #fff;
border-bottom: 1px solid var(--ml-g200);
}
.ml-tab {
display: inline-flex;
align-items: center;
gap: 8px;
background: transparent;
border: 0;
border-bottom: 2px solid transparent;
padding: 10px 14px;
font-size: 12.5px;
font-weight: 600;
color: var(--ml-g500);
cursor: pointer;
transition: all 0.15s ease;
font-family: inherit;
margin-bottom: -1px;
fa-icon {
font-size: 12px;
}
&:hover {
color: var(--ml-g700);
}
&.active {
color: var(--ml-blue);
border-bottom-color: var(--ml-blue);
}
}
.ml-tab-count {
font-size: 10px;
font-weight: 700;
padding: 2px 7px;
border-radius: 999px;
background: var(--ml-blue-l);
color: var(--ml-blue);
}
.ml-tab.active .ml-tab-count {
background: var(--ml-blue);
color: #fff;
}
/* ───────── BODY ───────── */
.ml-body {
flex: 1;
overflow-y: auto;
background: var(--ml-gray-l);
padding: 14px;
}
/* ───────── TABLE ───────── */
.ml-table-wrap {
background: #fff;
border-radius: 10px;
overflow: hidden;
border: 1px solid var(--ml-g200);
box-shadow: 0 1px 2px rgb(15 23 42 / 4%);
}
.ml-table {
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
thead th {
background: #f8fafc;
color: var(--ml-g700);
text-align: left;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 9px 12px;
border-bottom: 1px solid var(--ml-g200);
white-space: nowrap;
}
tbody td {
padding: 9px 12px;
border-bottom: 1px solid #f1f5f9;
vertical-align: top;
}
tbody tr:hover {
background: #f8fafc;
}
tbody tr:last-child td {
border-bottom: 0;
}
}
.mono {
font-family: 'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace;
font-size: 12px;
}
.small {
font-size: 11.5px;
}
.muted {
color: var(--ml-g500);
}
/* Factor chip in the cell */
.ml-factor-chip {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
color: var(--ml-g700);
fa-icon {
font-size: 11px;
color: var(--ml-blue);
}
}
/* Numeric cell alignment */
.ml-table .num {
text-align: right;
}
/* Pills */
.ml-pill {
display: inline-flex;
align-items: center;
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 3px 9px;
border-radius: 999px;
&--blue {
background: #eff6ff;
color: #1d4ed8;
border: 1px solid #dbeafe;
}
&--darkred {
background: #450a0a;
color: #fecaca;
border: 1px solid #7f1d1d;
}
&--green {
background: var(--ml-green-l);
color: var(--ml-green);
border: 1px solid #bbf7d0;
}
&--red {
background: var(--ml-red-l);
color: var(--ml-red);
border: 1px solid #fecaca;
}
&--amber {
background: var(--ml-amber-l);
color: var(--ml-amber);
border: 1px solid #fde68a;
}
&--gray {
background: #f1f5f9;
color: var(--ml-g700);
border: 1px solid #e2e8f0;
}
}
/* ───────── SKELETON ───────── */
.ml-skeleton {
background: #fff;
border-radius: 10px;
border: 1px solid var(--ml-g200);
padding: 12px;
overflow: hidden;
}
.ml-skel-row {
display: flex;
align-items: center;
gap: 14px;
padding: 10px 4px;
border-bottom: 1px dashed #eef2f7;
&:last-child {
border-bottom: 0;
}
}
.ml-skel {
height: 10px;
border-radius: 5px;
background: linear-gradient(90deg, #eef2f7 0%, #f8fafc 50%, #eef2f7 100%);
background-size: 200% 100%;
animation: ml-shimmer 1.4s ease-in-out infinite;
&--xs {
width: 12%;
}
&--sm {
width: 16%;
}
&--md {
width: 22%;
}
&--lg {
width: 32%;
}
}
@keyframes ml-shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
/* ───────── EMPTY ───────── */
.ml-empty {
background: #fff;
border: 1px dashed #cbd5e1;
border-radius: 12px;
padding: 36px 24px;
text-align: center;
}
.ml-empty-ic {
width: 56px;
height: 56px;
border-radius: 14px;
background: var(--ml-blue-l);
color: var(--ml-blue);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 22px;
margin-bottom: 12px;
}
.ml-empty h3 {
margin: 0 0 6px;
font-size: 15px;
font-weight: 700;
color: var(--ml-g900);
}
.ml-empty p {
margin: 0 0 18px;
font-size: 12.5px;
color: var(--ml-g500);
line-height: 1.5;
max-width: 460px;
margin-left: auto;
margin-right: auto;
}
.ml-empty-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 16px;
border-radius: 999px;
background: var(--ml-blue-l);
color: var(--ml-blue);
border: 1px solid #dbeafe;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: var(--ml-blue);
color: #fff;
border-color: var(--ml-blue);
}
}
/* ───────── ERROR ───────── */
.ml-error {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px 16px;
border-radius: 10px;
background: var(--ml-red-l);
border: 1px solid #fecaca;
color: var(--ml-g900);
> fa-icon {
color: var(--ml-red);
font-size: 16px;
margin-top: 2px;
}
> div {
flex: 1;
strong {
font-size: 13px;
color: var(--ml-red);
}
p {
margin: 4px 0 0;
font-size: 12px;
color: var(--ml-g700);
}
}
}
.ml-error-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
border-radius: 999px;
background: #fff;
color: var(--ml-red);
border: 1px solid #fecaca;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
flex-shrink: 0;
&:hover {
background: var(--ml-red);
color: #fff;
border-color: var(--ml-red);
}
}
/* ───────── FILTER BAR ───────── */
.ml-filters {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
padding: 10px 14px;
background: #fff;
border-bottom: 1px solid var(--ml-g200);
}
.ml-filter-search {
position: relative;
flex: 1;
min-width: 220px;
input {
width: 100%;
box-sizing: border-box;
padding: 7px 12px 7px 30px;
border: 1px solid var(--ml-g200);
border-radius: 8px;
background: #f8fafc;
font-size: 12.5px;
color: var(--ml-g900);
font-family: inherit;
&::placeholder {
color: #94a3b8;
}
&:focus {
outline: none;
border-color: var(--ml-blue);
background: #fff;
box-shadow: 0 0 0 3px rgb(29 78 216 / 14%);
}
}
}
.ml-filter-search-ic {
position: absolute;
top: 50%;
left: 10px;
transform: translateY(-50%);
font-size: 12px;
color: #94a3b8;
pointer-events: none;
}
.ml-filter-select {
height: 32px;
padding: 0 10px;
border: 1px solid var(--ml-g200);
border-radius: 8px;
background: #fff;
color: var(--ml-g900);
font-size: 12.5px;
font-family: inherit;
cursor: pointer;
&:focus {
outline: none;
border-color: var(--ml-blue);
box-shadow: 0 0 0 3px rgb(29 78 216 / 14%);
}
}
.ml-filter-dates {
display: inline-flex;
align-items: center;
gap: 6px;
label {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--ml-g500);
}
// stylelint-disable-next-line selector-no-qualifying-type
input[type='date'] {
height: 32px;
padding: 0 8px;
border: 1px solid var(--ml-g200);
border-radius: 8px;
background: #fff;
color: var(--ml-g900);
font-size: 12px;
font-family: inherit;
&:focus {
outline: none;
border-color: var(--ml-blue);
box-shadow: 0 0 0 3px rgb(29 78 216 / 14%);
}
}
}
.ml-filter-clear {
display: inline-flex;
align-items: center;
gap: 5px;
background: var(--ml-red-l);
color: var(--ml-red);
border: 1px solid #fecaca;
border-radius: 999px;
padding: 4px 11px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
cursor: pointer;
fa-icon {
font-size: 10px;
}
&:hover {
background: var(--ml-red);
color: #fff;
border-color: var(--ml-red);
}
}
.ml-filter-refresh {
width: 32px;
height: 32px;
border-radius: 8px;
border: 1px solid var(--ml-g200);
background: #fff;
color: var(--ml-g700);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
transition: all 0.15s ease;
&:hover {
background: var(--ml-blue-l);
color: var(--ml-blue);
border-color: #dbeafe;
}
}
/* ───────── PAGINATION FOOTER ───────── */
.ml-pager {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
background: #f8fafc;
border-top: 1px solid var(--ml-g200);
font-size: 12px;
flex-wrap: wrap;
gap: 10px;
}
.ml-pager-info {
color: var(--ml-g500);
font-weight: 600;
}
.ml-pager-ctrls {
display: inline-flex;
align-items: center;
gap: 10px;
}
.ml-pager-pagesize {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--ml-g500);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
select {
height: 28px;
padding: 0 8px;
border: 1px solid var(--ml-g200);
border-radius: 6px;
background: #fff;
font-size: 12px;
font-family: inherit;
color: var(--ml-g900);
cursor: pointer;
&:focus {
outline: none;
border-color: var(--ml-blue);
}
}
}
.ml-pager-btn {
display: inline-flex;
align-items: center;
gap: 5px;
height: 28px;
padding: 0 10px;
border-radius: 6px;
background: #fff;
color: var(--ml-g700);
border: 1px solid var(--ml-g200);
font-size: 12px;
font-weight: 600;
cursor: pointer;
font-family: inherit;
transition: all 0.15s ease;
fa-icon {
font-size: 10px;
}
&:hover:not(:disabled) {
background: var(--ml-blue-l);
color: var(--ml-blue);
border-color: #dbeafe;
}
&:disabled {
opacity: 0.45;
cursor: not-allowed;
}
}
.ml-pager-page {
font-size: 12px;
color: var(--ml-g700);
strong {
color: var(--ml-g900);
}
}
@@ -0,0 +1,319 @@
/**
* 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 { ChangeDetectorRef, Component, inject, OnDestroy, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatDialogRef } from '@angular/material/dialog';
import { MatTabsModule } from '@angular/material/tabs';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { Subject, debounceTime } from 'rxjs';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import {
AccessControlService,
MfaLogEntry,
MfaLogFactorType,
MfaLogFilters,
MfaLogStatus,
MfaLogTab
} from '../access-control.service';
type LoadState = 'loading' | 'empty' | 'loaded' | 'error';
interface TabState {
state: LoadState;
rows: MfaLogEntry[];
total: number;
page: number;
pageSize: number;
error: string;
filters: MfaLogFilters;
/** Local copy of search input so we can debounce typing. */
searchInput: string;
}
const ENROLLMENT_STATUSES: MfaLogStatus[] = [
'ACTIVE',
'DISABLED'
];
const LOGIN_STATUSES: MfaLogStatus[] = [
'PENDING',
'SENT',
'VERIFIED',
'FAILED',
'EXPIRED',
'LOCKED'
];
const FACTOR_TYPES: MfaLogFactorType[] = [
'SMS',
'EMAIL',
'TOTP',
'DSC'
];
@Component({
selector: 'mifosx-mfa-logs-dialog',
templateUrl: './mfa-logs-dialog.component.html',
styleUrls: ['./mfa-logs-dialog.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule,
MatTabsModule,
FaIconComponent
]
})
export class MfaLogsDialogComponent implements OnInit, OnDestroy {
private service = inject(AccessControlService);
private dialogRef = inject(MatDialogRef<MfaLogsDialogComponent>);
private cdr = inject(ChangeDetectorRef);
/** Active tab id. */
activeTab: MfaLogTab = 'enrollment';
/** Dropdown option lists. */
readonly factorTypes = FACTOR_TYPES;
readonly enrollmentStatuses = ENROLLMENT_STATUSES;
readonly loginStatuses = LOGIN_STATUSES;
/** Per-tab state — each tab owns its own filters, page and rows. */
enrollment: TabState = this.makeTabState();
login: TabState = this.makeTabState();
/** Debounce search input so we don't fire a request per keystroke. */
private search$ = new Subject<{ tab: MfaLogTab; value: string }>();
ngOnInit(): void {
this.search$.pipe(debounceTime(350)).subscribe(({ tab, value }) => {
const state = this.stateFor(tab);
state.filters.search = value || undefined;
state.page = 0;
this.load(tab);
});
this.load('enrollment');
this.load('login');
}
ngOnDestroy(): void {
this.search$.complete();
}
// ─────────────── Tab switching ───────────────
setTab(tab: MfaLogTab): void {
this.activeTab = tab;
}
/** Convenience accessor — current tab's state object. */
get current(): TabState {
return this.stateFor(this.activeTab);
}
stateFor(tab: MfaLogTab): TabState {
return tab === 'enrollment' ? this.enrollment : this.login;
}
// ─────────────── Loading ───────────────
load(tab: MfaLogTab): void {
const state = this.stateFor(tab);
state.state = 'loading';
state.error = '';
this.service.getMfaLogs(tab, state.page, state.pageSize, state.filters).subscribe({
next: (res) => {
// Reassign to fresh array refs so Angular's CD reliably picks up the change
// even when the dialog is mid-animation or another async load is in flight.
state.rows = [
...(res?.pageItems || [])
];
state.total = res?.totalFilteredRecords ?? 0;
state.state = state.rows.length === 0 ? 'empty' : 'loaded';
this.cdr.markForCheck();
this.cdr.detectChanges();
},
error: (err) => {
if (err?.status === 404) {
state.rows = [];
state.total = 0;
state.state = 'empty';
this.cdr.detectChanges();
return;
}
state.error =
err?.error?.defaultUserMessage || err?.error?.message || err?.message || 'Could not load MFA logs.';
state.state = 'error';
this.cdr.detectChanges();
}
});
}
refresh(tab: MfaLogTab): void {
this.load(tab);
}
// ─────────────── Filters ───────────────
/** Drop the prior search debounce and fire immediately. */
onSearchInput(tab: MfaLogTab, value: string): void {
const state = this.stateFor(tab);
state.searchInput = value;
this.search$.next({ tab, value: value.trim() });
}
setFilter<K extends keyof MfaLogFilters>(tab: MfaLogTab, key: K, value: MfaLogFilters[K] | ''): void {
const state = this.stateFor(tab);
if (value === '' || value === null || value === undefined) {
delete state.filters[key];
} else {
state.filters[key] = value as MfaLogFilters[K];
}
state.page = 0;
this.load(tab);
}
clearFilters(tab: MfaLogTab): void {
const state = this.stateFor(tab);
state.filters = {};
state.searchInput = '';
state.page = 0;
this.load(tab);
}
hasActiveFilters(tab: MfaLogTab): boolean {
const state = this.stateFor(tab);
return Object.keys(state.filters).length > 0 || !!state.searchInput;
}
// ─────────────── Pagination ───────────────
get currentTotalPages(): number {
const s = this.current;
return Math.max(1, Math.ceil(s.total / s.pageSize));
}
nextPage(): void {
const s = this.current;
if (s.page + 1 >= this.currentTotalPages) return;
s.page++;
this.load(this.activeTab);
}
prevPage(): void {
const s = this.current;
if (s.page <= 0) return;
s.page--;
this.load(this.activeTab);
}
changePageSize(size: number): void {
const s = this.current;
s.pageSize = size;
s.page = 0;
this.load(this.activeTab);
}
get currentRangeLabel(): string {
const s = this.current;
if (s.total === 0) return '0 of 0';
const start = s.page * s.pageSize + 1;
const end = Math.min(s.total, start + s.rows.length - 1);
return `${start}–${end} of ${s.total}`;
}
close(): void {
this.dialogRef.close();
}
// ─────────────── Display helpers ───────────────
formatTs(ts?: string | null): string {
if (!ts) return '—';
const d = new Date(ts);
if (Number.isNaN(d.getTime())) return ts;
const pad = (n: number) => String(n).padStart(2, '0');
return (
d.getFullYear() +
'-' +
pad(d.getMonth() + 1) +
'-' +
pad(d.getDate()) +
' ' +
pad(d.getHours()) +
':' +
pad(d.getMinutes()) +
':' +
pad(d.getSeconds())
);
}
factorLabel(f?: string): string {
switch ((f || '').toUpperCase()) {
case 'SMS':
return 'SMS OTP';
case 'EMAIL':
return 'Email OTP';
case 'TOTP':
return 'Authenticator';
case 'DSC':
return 'DSC';
default:
return f || '';
}
}
/** Matches the 2FA picker icon mapping in two-factor-authentication. */
factorIcon(f?: string): string {
switch ((f || '').toUpperCase()) {
case 'SMS':
return 'mobile-alt';
case 'EMAIL':
return 'envelope';
case 'TOTP':
return 'qrcode';
case 'DSC':
return 'signature';
default:
return 'shield-alt';
}
}
/** Status pill colour per the API spec's suggested mapping. */
statusColor(status?: string): string {
switch ((status || '').toUpperCase()) {
case 'ACTIVE':
case 'VERIFIED':
return 'green';
case 'DISABLED':
return 'gray';
case 'PENDING':
case 'SENT':
return 'blue';
case 'FAILED':
return 'red';
case 'EXPIRED':
return 'amber';
case 'LOCKED':
return 'darkred';
default:
return 'gray';
}
}
/** Skeleton rows for the loading shimmer (count, not data). */
skeletonRows(n = 6): number[] {
return Array.from({ length: n }, (_, i) => i);
}
// ─────────────── Internal ───────────────
private makeTabState(): TabState {
return {
state: 'loading',
rows: [],
total: 0,
page: 0,
pageSize: 25,
error: '',
filters: {},
searchInput: ''
};
}
}
+2
View File
@@ -62,6 +62,7 @@ import { DicgcModule } from './dicgc/dicgc.module';
import { FiuModule } from './fiu/fiu.module';
import { SftModule } from './sft/sft.module';
import { DigitalModule } from './digital/digital.module';
import { LetterMgmtModule } from './letter-mgmt/letter-mgmt.module';
import { PortalModule } from '@angular/cdk/portal';
/** Main Routing Module */
@@ -160,6 +161,7 @@ export function HttpLoaderFactory(http: HttpClient) {
FiuModule,
SftModule,
DigitalModule,
LetterMgmtModule,
AppRoutingModule,
NotFoundComponent,
CallbackComponent,
@@ -6,41 +6,62 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="container">
<mat-card>
<form [formGroup]="activateClientForm" (ngSubmit)="submit()">
<mat-card-content>
<div class="layout-column">
<mat-form-field (click)="activationDatePicker.open()">
<mat-label>{{ 'labels.inputs.Activated On Date' | translate }}</mat-label>
<input
matInput
[min]="minDate"
[max]="maxDate"
[matDatepicker]="activationDatePicker"
required
formControlName="activationDate"
/>
<mat-datepicker-toggle matSuffix [for]="activationDatePicker"></mat-datepicker-toggle>
<mat-datepicker #activationDatePicker></mat-datepicker>
@if (activateClientForm.controls.activationDate.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Activated On Date' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
</div>
</mat-card-content>
<div class="ca-root">
<!-- HERO -->
<div class="ca-hero">
<div class="ca-hero-icon"><fa-icon icon="check-circle"></fa-icon></div>
<div class="ca-hero-text">
<div class="ca-hero-eyebrow">Client Action</div>
<h2 class="ca-hero-title">{{ 'labels.heading.Activate Client' | translate }}</h2>
<p class="ca-hero-sub">
Activating moves this client from <strong>Pending</strong> to <strong>Active</strong>. Once active, the client
can hold accounts, take loans and transact normally.
</p>
</div>
</div>
<mat-card-actions class="layout-row align-center gap-5px responsive-column">
<button type="button" mat-raised-button [routerLink]="['../../']">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-raised-button color="primary" [disabled]="!activateClientForm.valid">
{{ 'labels.buttons.Confirm' | translate }}
</button>
</mat-card-actions>
</form>
</mat-card>
<form [formGroup]="activateClientForm" (ngSubmit)="submit()" class="ca-card">
<div class="ca-card-body">
<div class="ca-form-row">
<mat-form-field appearance="outline" (click)="activationDatePicker.open()">
<mat-label>{{ 'labels.inputs.Activated On Date' | translate }}</mat-label>
<input
matInput
[min]="minDate"
[max]="maxDate"
[matDatepicker]="activationDatePicker"
required
formControlName="activationDate"
/>
<mat-datepicker-toggle matSuffix [for]="activationDatePicker"></mat-datepicker-toggle>
<mat-datepicker #activationDatePicker></mat-datepicker>
@if (activateClientForm.controls.activationDate.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Activated On Date' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
</div>
<div class="ca-notice">
<fa-icon icon="info-circle"></fa-icon>
<span>
The activation date must be on or before today's business date. After confirmation, account opening and
onboarding workflows are unlocked for this client.
</span>
</div>
</div>
<div class="ca-actions">
<button type="button" class="ca-btn ca-btn--cancel" [routerLink]="['../../']">
<fa-icon icon="times"></fa-icon>
<span>{{ 'labels.buttons.Cancel' | translate }}</span>
</button>
<button type="submit" class="ca-btn ca-btn--confirm" [disabled]="!activateClientForm.valid">
<fa-icon icon="check"></fa-icon>
<span>{{ 'labels.buttons.Confirm' | translate }}</span>
</button>
</div>
</form>
</div>
@@ -6,6 +6,12 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.container {
max-width: 30rem;
@use 'assets/styles/client-action' as ca;
@include ca.client-action-styles;
/* Activate-specific green accent (success / go) */
:host {
--ca-accent: #15803d;
--ca-accent-l: #dcfce7;
}
@@ -15,6 +15,7 @@ import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { ClientsService } from 'app/clients/clients.service';
import { Dates } from 'app/core/utils/dates';
import { SettingsService } from 'app/settings/settings.service';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
@@ -25,7 +26,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
templateUrl: './activate-client.component.html',
styleUrls: ['./activate-client.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS
...STANDALONE_SHARED_IMPORTS,
FaIconComponent
]
})
export class ActivateClientComponent implements OnInit {
@@ -6,121 +6,150 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="container">
<mat-card>
<form [formGroup]="clientChargeForm" (ngSubmit)="submit()">
<mat-card-content>
<div class="layout-column">
<mat-form-field>
<mat-label>{{ 'labels.inputs.Charge' | translate }}</mat-label>
<mat-select required formControlName="chargeId">
@for (clientCharge of clientChargeOptions; track clientCharge) {
<mat-option [value]="clientCharge.id">
{{ clientCharge.name + ' (' + clientCharge.currency.name + ')' }}
</mat-option>
}
</mat-select>
@if (clientChargeForm.controls.chargeId.hasError('required')) {
<div class="ca-root">
<div class="ca-hero">
<div class="ca-hero-icon"><fa-icon icon="receipt"></fa-icon></div>
<div class="ca-hero-text">
<div class="ca-hero-eyebrow">Client Action</div>
<h2 class="ca-hero-title">{{ 'labels.buttons.Add Charge' | translate }}</h2>
<p class="ca-hero-sub">
Attach a one-time fee or a recurring charge to this client. The charge can be linked to a specific account or
held at the client level for ad-hoc collection.
</p>
</div>
</div>
<form [formGroup]="clientChargeForm" (ngSubmit)="submit()" class="ca-card">
<div class="ca-card-body">
<div class="ca-form-row">
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Charge' | translate }}</mat-label>
<mat-select required formControlName="chargeId">
@for (clientCharge of clientChargeOptions; track clientCharge) {
<mat-option [value]="clientCharge.id">{{
clientCharge.name + ' (' + clientCharge.currency.name + ')'
}}</mat-option>
}
</mat-select>
@if (clientChargeForm.controls.chargeId.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Charge' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
</div>
@if (chargeDetails) {
<div class="ca-form-row">
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Amount' | translate }}</mat-label>
<input type="number" required matInput formControlName="amount" />
@if (clientChargeForm.controls.amount.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Charge' | translate }} {{ 'labels.commons.is' | translate }}
{{ 'labels.inputs.Amount' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
@if (chargeDetails) {
<div class="layout-column">
<mat-form-field>
<mat-label>{{ 'labels.inputs.Amount' | translate }}</mat-label>
<input type="number" required matInput formControlName="amount" />
@if (clientChargeForm.controls.amount.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Amount' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<mat-form-field>
<mat-label>{{ 'labels.inputs.Charge Calculation' | translate }}</mat-label>
<mat-select formControlName="chargeCalculationType">
@for (chargeCalculation of chargeDetails.chargeCalculationTypeOptions; track chargeCalculation) {
<mat-option [value]="chargeCalculation.id">
{{ chargeCalculation.value }}
</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field>
<mat-label>{{ 'labels.inputs.Charge time type' | translate }}</mat-label>
<mat-select formControlName="chargeTimeType">
@for (chargeTime of chargeDetails.chargeTimeTypeOptions; track chargeTime) {
<mat-option [value]="chargeTime.id">
{{ chargeTime.value }}
</mat-option>
}
</mat-select>
</mat-form-field>
@if (clientChargeForm.contains('dueDate')) {
<mat-form-field (click)="dueDatePicker.open()">
<mat-label>{{ 'labels.inputs.Due for collection on' | translate }}</mat-label>
<input
matInput
[min]="minDate"
[max]="maxDate"
[matDatepicker]="dueDatePicker"
required
formControlName="dueDate"
/>
<mat-datepicker-toggle matSuffix [for]="dueDatePicker"></mat-datepicker-toggle>
<mat-datepicker #dueDatePicker></mat-datepicker>
@if (clientChargeForm.controls.dueDate.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Due for collection on' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
}
@if (clientChargeForm.contains('feeOnMonthDay')) {
<mat-form-field (click)="feeOnMonthDayPicker.open()">
<mat-label>{{ 'labels.inputs.Due On' | translate }}</mat-label>
<input
matInput
[min]="minDate"
[max]="maxDate"
[matDatepicker]="feeOnMonthDayPicker"
required
formControlName="feeOnMonthDay"
/>
<mat-datepicker-toggle matSuffix [for]="feeOnMonthDayPicker"></mat-datepicker-toggle>
<mat-datepicker #feeOnMonthDayPicker></mat-datepicker>
@if (clientChargeForm.controls.feeOnMonthDay.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Due Date' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
}
@if (clientChargeForm.contains('feeInterval')) {
<mat-form-field>
<mat-label>{{ 'labels.inputs.Repeats Every' | translate }}</mat-label>
<input matInput formControlName="feeInterval" />
</mat-form-field>
}
</div>
}
</div>
<mat-card-actions class="layout-row layout-xs-column layout-align-center gap-5px">
<button type="button" mat-raised-button [routerLink]="['../../']">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-raised-button color="primary" [disabled]="!clientChargeForm.valid">
{{ 'labels.buttons.Submit' | translate }}
</button>
</mat-card-actions>
</mat-card-content>
</form>
</mat-card>
<div class="ca-form-row">
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Charge Calculation' | translate }}</mat-label>
<mat-select formControlName="chargeCalculationType">
@for (chargeCalculation of chargeDetails.chargeCalculationTypeOptions; track chargeCalculation) {
<mat-option [value]="chargeCalculation.id">{{ chargeCalculation.value }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="ca-form-row">
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Charge time type' | translate }}</mat-label>
<mat-select formControlName="chargeTimeType">
@for (chargeTime of chargeDetails.chargeTimeTypeOptions; track chargeTime) {
<mat-option [value]="chargeTime.id">{{ chargeTime.value }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
@if (clientChargeForm.contains('dueDate')) {
<div class="ca-form-row">
<mat-form-field appearance="outline" (click)="dueDatePicker.open()">
<mat-label>{{ 'labels.inputs.Due for collection on' | translate }}</mat-label>
<input
matInput
[min]="minDate"
[max]="maxDate"
[matDatepicker]="dueDatePicker"
required
formControlName="dueDate"
/>
<mat-datepicker-toggle matSuffix [for]="dueDatePicker"></mat-datepicker-toggle>
<mat-datepicker #dueDatePicker></mat-datepicker>
@if (clientChargeForm.controls.dueDate.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Due for collection on' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
</div>
}
@if (clientChargeForm.contains('feeOnMonthDay')) {
<div class="ca-form-row">
<mat-form-field appearance="outline" (click)="feeOnMonthDayPicker.open()">
<mat-label>{{ 'labels.inputs.Due On' | translate }}</mat-label>
<input
matInput
[min]="minDate"
[max]="maxDate"
[matDatepicker]="feeOnMonthDayPicker"
required
formControlName="feeOnMonthDay"
/>
<mat-datepicker-toggle matSuffix [for]="feeOnMonthDayPicker"></mat-datepicker-toggle>
<mat-datepicker #feeOnMonthDayPicker></mat-datepicker>
@if (clientChargeForm.controls.feeOnMonthDay.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Due Date' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
</div>
}
@if (clientChargeForm.contains('feeInterval')) {
<div class="ca-form-row">
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Repeats Every' | translate }}</mat-label>
<input matInput formControlName="feeInterval" />
</mat-form-field>
</div>
}
<div class="ca-notice">
<fa-icon icon="info-circle"></fa-icon>
<span>
The calculation type and timing determine when this charge is auto-applied (e.g. monthly fee, annual
membership, ad-hoc). Verify the amount and due date before submitting.
</span>
</div>
}
</div>
<div class="ca-actions">
<button type="button" class="ca-btn ca-btn--cancel" [routerLink]="['../../']">
<fa-icon icon="times"></fa-icon><span>{{ 'labels.buttons.Cancel' | translate }}</span>
</button>
<button type="submit" class="ca-btn ca-btn--confirm" [disabled]="!clientChargeForm.valid">
<fa-icon icon="check"></fa-icon><span>{{ 'labels.buttons.Submit' | translate }}</span>
</button>
</div>
</form>
</div>
@@ -6,6 +6,12 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.container {
width: 37rem;
@use 'assets/styles/client-action' as ca;
@include ca.client-action-styles;
/* Add Charge — amber accent (financial / fee) */
:host {
--ca-accent: #b45309;
--ca-accent-l: #fef3c7;
}
@@ -21,6 +21,7 @@ import { Router, ActivatedRoute, RouterLink } from '@angular/router';
import { ClientsService } from 'app/clients/clients.service';
import { Dates } from 'app/core/utils/dates';
import { SettingsService } from 'app/settings/settings.service';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
@@ -31,7 +32,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
templateUrl: './add-client-charge.component.html',
styleUrls: ['./add-client-charge.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS
...STANDALONE_SHARED_IMPORTS,
FaIconComponent
]
})
export class AddClientChargeComponent implements OnInit {
@@ -6,81 +6,118 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="container">
<mat-card>
<form [formGroup]="clientCollateralForm" (ngSubmit)="submit()">
<mat-card-content>
<div class="layout-column">
<mat-form-field>
<mat-label>{{ 'labels.inputs.Collateral' | translate }}</mat-label>
<mat-select required formControlName="collateralId">
@for (clientCollateral of clientCollateralOptions; track clientCollateral) {
<mat-option [value]="clientCollateral.id">
{{ clientCollateral.name }}
</mat-option>
}
</mat-select>
@if (clientCollateralForm.controls.collateralId.hasError('required')) {
<div class="ca-root">
<div class="ca-hero">
<div class="ca-hero-icon"><fa-icon icon="cube"></fa-icon></div>
<div class="ca-hero-text">
<div class="ca-hero-eyebrow">Client Action</div>
<h2 class="ca-hero-title">{{ 'labels.buttons.Create Collateral' | translate }}</h2>
<p class="ca-hero-sub">
Record an asset pledged by this client as security for credit (e.g. property, vehicle, deposit). Collateral
values feed loan eligibility and LTV calculations.
</p>
</div>
</div>
<form [formGroup]="clientCollateralForm" (ngSubmit)="submit()" class="ca-card">
<div class="ca-card-body">
<div class="ca-form-row">
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Collateral' | translate }}</mat-label>
<mat-select required formControlName="collateralId">
@for (clientCollateral of clientCollateralOptions; track clientCollateral) {
<mat-option [value]="clientCollateral.id">{{ clientCollateral.name }}</mat-option>
}
</mat-select>
@if (clientCollateralForm.controls.collateralId.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Collateral' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
</div>
@if (collateralDetails) {
<div class="ca-form-row">
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.name' | translate }}</mat-label>
<input matInput required formControlName="name" />
</mat-form-field>
</div>
<div class="ca-form-row">
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Type/Quality' | translate }}</mat-label>
<input matInput required formControlName="quality" />
</mat-form-field>
</div>
<div class="ca-form-row">
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Unit Type' | translate }}</mat-label>
<input matInput required formControlName="unitType" />
</mat-form-field>
</div>
<div class="ca-form-row">
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Base Price' | translate }}</mat-label>
<input matInput required formControlName="basePrice" />
</mat-form-field>
</div>
<div class="ca-form-row">
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Percentage To Base' | translate }}</mat-label>
<input matInput required formControlName="pctToBase" />
</mat-form-field>
</div>
<div class="ca-form-row">
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Quantity' | translate }}</mat-label>
<input matInput required formControlName="quantity" />
@if (clientCollateralForm.controls.quantity.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Collateral' | translate }} {{ 'labels.commons.is' | translate }}
{{ 'labels.inputs.Quantity' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
@if (collateralDetails) {
<div class="layout-column">
<mat-form-field>
<mat-label>{{ 'labels.inputs.name' | translate }}</mat-label>
<input matInput required formControlName="name" />
</mat-form-field>
<mat-form-field>
<mat-label>{{ 'labels.inputs.Type/Quality' | translate }}</mat-label>
<input matInput required formControlName="quality" />
</mat-form-field>
<mat-form-field>
<mat-label>{{ 'labels.inputs.Unit Type' | translate }}</mat-label>
<input matInput required formControlName="unitType" />
</mat-form-field>
<mat-form-field>
<mat-label>{{ 'labels.inputs.Base Price' | translate }}</mat-label>
<input matInput required formControlName="basePrice" />
</mat-form-field>
<mat-form-field>
<mat-label>{{ 'labels.inputs.Percentage To Base' | translate }}</mat-label>
<input matInput required formControlName="pctToBase" />
</mat-form-field>
<mat-form-field>
<mat-label>{{ 'labels.inputs.Quantity' | translate }}</mat-label>
<input matInput required formControlName="quantity" />
@if (clientCollateralForm.controls.quantity.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Quantity' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<mat-form-field>
<mat-label>{{ 'labels.inputs.Total' | translate }}</mat-label>
<input matInput required formControlName="totalValue" />
</mat-form-field>
<mat-form-field>
<mat-label>{{ 'labels.inputs.Total Collateral Value' | translate }}</mat-label>
<input matInput required formControlName="totalCollateralValue" />
</mat-form-field>
</div>
}
</div>
</mat-card-content>
<mat-card-actions class="layout-row layout-xs-column layout-align-center gap-5px">
<button type="button" mat-raised-button [routerLink]="['../../']">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-raised-button color="primary" [disabled]="!clientCollateralForm.valid">
{{ 'labels.buttons.Submit' | translate }}
</button>
</mat-card-actions>
</form>
</mat-card>
<div class="ca-form-row">
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Total' | translate }}</mat-label>
<input matInput required formControlName="totalValue" />
</mat-form-field>
</div>
<div class="ca-form-row">
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Total Collateral Value' | translate }}</mat-label>
<input matInput required formControlName="totalCollateralValue" />
</mat-form-field>
</div>
<div class="ca-notice">
<fa-icon icon="info-circle"></fa-icon>
<span>
Total Collateral Value = Base Price × Percentage-to-Base × Quantity. Verify the figures before submitting —
this is used by underwriting and LTV checks.
</span>
</div>
}
</div>
<div class="ca-actions">
<button type="button" class="ca-btn ca-btn--cancel" [routerLink]="['../../']">
<fa-icon icon="times"></fa-icon><span>{{ 'labels.buttons.Cancel' | translate }}</span>
</button>
<button type="submit" class="ca-btn ca-btn--confirm" [disabled]="!clientCollateralForm.valid">
<fa-icon icon="check"></fa-icon><span>{{ 'labels.buttons.Submit' | translate }}</span>
</button>
</div>
</form>
</div>
@@ -6,6 +6,12 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.container {
width: 37rem;
@use 'assets/styles/client-action' as ca;
@include ca.client-action-styles;
/* Create Collateral — purple accent (asset / security) */
:host {
--ca-accent: #6d28d9;
--ca-accent-l: #ede9fe;
}
@@ -17,6 +17,7 @@ import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { ClientsService } from 'app/clients/clients.service';
import { ProductsService } from 'app/products/products.service';
import { SettingsService } from 'app/settings/settings.service';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@Component({
@@ -24,7 +25,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
templateUrl: './add-client-collateral.component.html',
styleUrls: ['./add-client-collateral.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS
...STANDALONE_SHARED_IMPORTS,
FaIconComponent
]
})
export class AddClientCollateralComponent implements OnInit {
@@ -6,53 +6,72 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="container m-b-20">
<mat-card>
<form [formGroup]="clientScreenReportForm" (ngSubmit)="generate()">
<mat-card-content>
<div>
<mat-form-field class="flex-fill">
<mat-label>{{ 'labels.inputs.Client Screen Reports' | translate }}</mat-label>
<mat-select formControlName="templateId">
@for (template of templatesData; track template) {
<mat-option [value]="template.id">
{{ template.name }}
</mat-option>
}
</mat-select>
@if (clientScreenReportForm.controls.templateId.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Screen Report' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
<div class="ca-root ca-root--wide">
<div class="ca-hero">
<div class="ca-hero-icon"><fa-icon icon="chart-bar"></fa-icon></div>
<div class="ca-hero-text">
<div class="ca-hero-eyebrow">Client Action</div>
<h2 class="ca-hero-title">{{ 'labels.buttons.Client Screen Reports' | translate }}</h2>
<p class="ca-hero-sub">
Generate an on-screen client report from one of the configured templates. The rendered output is printable and
can be saved as PDF from the browser print dialog.
</p>
</div>
</div>
<form [formGroup]="clientScreenReportForm" (ngSubmit)="generate()" class="ca-card">
<div class="ca-card-body">
<div class="ca-form-row">
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Client Screen Reports' | translate }}</mat-label>
<mat-select formControlName="templateId">
@for (template of templatesData; track template) {
<mat-option [value]="template.id">{{ template.name }}</mat-option>
}
</mat-form-field>
</div>
</mat-card-content>
</mat-select>
@if (clientScreenReportForm.controls.templateId.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Screen Report' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
</div>
</div>
<mat-card-actions class="layout-row align-center gap-5px actions-spacing responsive-column">
<button type="button" mat-raised-button [routerLink]="['../../']">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-raised-button color="primary" [disabled]="!clientScreenReportForm.valid">
<fa-icon icon="cogs" size="sm" class="m-r-10"></fa-icon>{{ 'labels.buttons.Generate Report' | translate }}
</button>
</mat-card-actions>
</form>
</mat-card>
</div>
<div #output class="container m-t-20">
<mat-card class="layout-column gap-3percent">
<div class="layout-align-end">
<button mat-raised-button color="primary" [disabled]="!template" (click)="print()">
<fa-icon icon="file" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Print' | translate }}
<div class="ca-actions">
<button type="button" class="ca-btn ca-btn--cancel" [routerLink]="['../../']">
<fa-icon icon="times"></fa-icon><span>{{ 'labels.buttons.Cancel' | translate }}</span>
</button>
<button type="submit" class="ca-btn ca-btn--confirm" [disabled]="!clientScreenReportForm.valid">
<fa-icon icon="cogs"></fa-icon><span>{{ 'labels.buttons.Generate Report' | translate }}</span>
</button>
</div>
</form>
<div #screenReport class="layout-align-center">
<p>{{ 'labels.text.Please select a screen report.' | translate }}</p>
<div #output class="ca-card">
<div class="ca-card-body">
<div class="csr-output-head">
<div>
<div class="csr-output-eyebrow">Report Output</div>
<div class="csr-output-title">
{{ template ? template.name : ('labels.text.Please select a screen report.' | translate) }}
</div>
</div>
<button type="button" class="ca-btn ca-btn--confirm" [disabled]="!template" (click)="print()">
<fa-icon icon="file"></fa-icon>
<span>{{ 'labels.buttons.Print' | translate }}</span>
</button>
</div>
<div #screenReport class="csr-output-body">
@if (!template) {
<div class="csr-empty">
<div class="csr-empty-ic"><fa-icon icon="chart-bar"></fa-icon></div>
<p>{{ 'labels.text.Please select a screen report.' | translate }}</p>
</div>
}
</div>
</div>
</mat-card>
</div>
</div>
@@ -6,32 +6,79 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.container {
max-width: 37rem;
@use 'assets/styles/client-action' as ca;
.print {
align-self: flex-end;
}
@include ca.client-action-styles;
/* Screen Reports — indigo accent (analytics / reporting) */
:host {
--ca-accent: #3730a3;
--ca-accent-l: #e0e7ff;
}
::ng-deep mat-card {
margin-bottom: 1rem;
padding: 1rem;
box-shadow: 0 2px 4px rgb(0 0 0 / 10%);
border: 1px solid var(--border-color, #ddd);
border-radius: 8px;
background-color: var(--card-background, #fff);
transition:
background-color 0.3s ease,
border-color 0.3s ease;
/* Wider container for the report output */
.ca-root--wide {
max-width: 1100px;
}
::ng-deep .container.m-t-20 mat-card p {
display: block;
margin-top: -1.9rem;
/* Report output header inside the second card */
.csr-output-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
padding-bottom: 14px;
margin-bottom: 14px;
border-bottom: 1px dashed var(--ca-g200);
flex-wrap: wrap;
}
.actions-spacing {
margin-top: 1rem;
padding: 0 1rem;
.csr-output-eyebrow {
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--ca-g500);
margin-bottom: 2px;
}
.csr-output-title {
font-size: 15px;
font-weight: 700;
color: var(--ca-g900);
}
.csr-output-body {
background: var(--ca-g50);
border: 1px solid var(--ca-g200);
border-radius: 10px;
padding: 18px;
min-height: 140px;
font-size: 13px;
color: var(--ca-g900);
overflow-x: auto;
}
.csr-empty {
text-align: center;
padding: 28px 16px;
}
.csr-empty-ic {
width: 52px;
height: 52px;
margin: 0 auto 10px;
border-radius: 14px;
background: var(--ca-accent-l);
color: var(--ca-accent);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 22px;
}
.csr-empty p {
margin: 0;
font-size: 13px;
color: var(--ca-g500);
}
@@ -6,58 +6,79 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="container">
<mat-card>
<form [formGroup]="closeClientForm" (ngSubmit)="submit()">
<mat-card-content>
<div class="layout-column">
<mat-form-field (click)="closureDatePicker.open()">
<mat-label>{{ 'labels.inputs.Closed On Date' | translate }}</mat-label>
<input
matInput
[min]="minDate"
[max]="maxDate"
[matDatepicker]="closureDatePicker"
required
formControlName="closureDate"
/>
<mat-datepicker-toggle matSuffix [for]="closureDatePicker"></mat-datepicker-toggle>
<mat-datepicker #closureDatePicker></mat-datepicker>
@if (closeClientForm.controls.closureDate.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Closed On Date' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<div class="ca-root">
<!-- HERO -->
<div class="ca-hero">
<div class="ca-hero-icon"><fa-icon icon="times-circle"></fa-icon></div>
<div class="ca-hero-text">
<div class="ca-hero-eyebrow">Client Action</div>
<h2 class="ca-hero-title">{{ 'labels.heading.Close Client' | translate }}</h2>
<p class="ca-hero-sub">
Closing the client moves them to a <strong>Closed</strong> state. Outstanding loans or active savings must be
settled or transferred before closure is permitted.
</p>
</div>
</div>
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Closure Reason' | translate }}</mat-label>
<mat-select formControlName="closureReasonId">
@for (reason of closureData; track reason) {
<mat-option [value]="reason.id">
{{ reason.name }}
</mat-option>
}
</mat-select>
@if (closeClientForm.controls.closureReasonId.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Closure Reason' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
</div>
</mat-card-content>
<form [formGroup]="closeClientForm" (ngSubmit)="submit()" class="ca-card">
<div class="ca-card-body">
<div class="ca-form-row">
<mat-form-field appearance="outline" (click)="closureDatePicker.open()">
<mat-label>{{ 'labels.inputs.Closed On Date' | translate }}</mat-label>
<input
matInput
[min]="minDate"
[max]="maxDate"
[matDatepicker]="closureDatePicker"
required
formControlName="closureDate"
/>
<mat-datepicker-toggle matSuffix [for]="closureDatePicker"></mat-datepicker-toggle>
<mat-datepicker #closureDatePicker></mat-datepicker>
@if (closeClientForm.controls.closureDate.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Closed On Date' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
</div>
<mat-card-actions class="layout-row align-center gap-5px responsive-column">
<button type="button" mat-raised-button [routerLink]="['../../']">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-raised-button color="warn" [disabled]="!closeClientForm.valid">
{{ 'labels.buttons.Confirm' | translate }}
</button>
</mat-card-actions>
</form>
</mat-card>
<div class="ca-form-row">
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Closure Reason' | translate }}</mat-label>
<mat-select formControlName="closureReasonId">
@for (reason of closureData; track reason) {
<mat-option [value]="reason.id">{{ reason.name }}</mat-option>
}
</mat-select>
@if (closeClientForm.controls.closureReasonId.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Closure Reason' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
</div>
<div class="ca-notice">
<fa-icon icon="exclamation-triangle"></fa-icon>
<span>
This action is <strong>irreversible</strong>. Once closed, the client cannot transact or open new accounts.
Use <em>Reactivate</em> only if the closure was made in error.
</span>
</div>
</div>
<div class="ca-actions">
<button type="button" class="ca-btn ca-btn--cancel" [routerLink]="['../../']">
<fa-icon icon="times"></fa-icon>
<span>{{ 'labels.buttons.Cancel' | translate }}</span>
</button>
<button type="submit" class="ca-btn ca-btn--confirm" [disabled]="!closeClientForm.valid">
<fa-icon icon="check"></fa-icon>
<span>{{ 'labels.buttons.Confirm' | translate }}</span>
</button>
</div>
</form>
</div>
@@ -6,6 +6,12 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.container {
max-width: 37rem;
@use 'assets/styles/client-action' as ca;
@include ca.client-action-styles;
/* Close-specific red accent (destructive) */
:host {
--ca-accent: #c0392b;
--ca-accent-l: #fee2e2;
}
@@ -15,6 +15,7 @@ import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { ClientsService } from 'app/clients/clients.service';
import { Dates } from 'app/core/utils/dates';
import { SettingsService } from 'app/settings/settings.service';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
@@ -25,7 +26,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
templateUrl: './close-client.component.html',
styleUrls: ['./close-client.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS
...STANDALONE_SHARED_IMPORTS,
FaIconComponent
]
})
export class CloseClientComponent implements OnInit {
@@ -6,11 +6,23 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="container">
<mat-card>
<form [formGroup]="reactivateClientForm" (ngSubmit)="submit()">
<mat-card-content>
<mat-form-field class="flex-fill" (click)="reactivationDatePicker.open()">
<div class="ca-root">
<div class="ca-hero">
<div class="ca-hero-icon"><fa-icon icon="redo"></fa-icon></div>
<div class="ca-hero-text">
<div class="ca-hero-eyebrow">Client Action</div>
<h2 class="ca-hero-title">{{ 'labels.heading.Reactivate Client' | translate }}</h2>
<p class="ca-hero-sub">
Bring a previously <strong>Closed</strong> client back to <strong>Active</strong>. After reactivation the client
may transact and open accounts again.
</p>
</div>
</div>
<form [formGroup]="reactivateClientForm" (ngSubmit)="submit()" class="ca-card">
<div class="ca-card-body">
<div class="ca-form-row">
<mat-form-field appearance="outline" (click)="reactivationDatePicker.open()">
<mat-label>{{ 'labels.inputs.Re Activation On Date' | translate }}</mat-label>
<input
matInput
@@ -29,16 +41,24 @@
</mat-error>
}
</mat-form-field>
</mat-card-content>
</div>
<mat-card-actions class="layout-row align-center gap-5px responsive-column">
<button type="button" mat-raised-button [routerLink]="['../../']">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-raised-button color="primary" [disabled]="!reactivateClientForm.valid">
{{ 'labels.buttons.Confirm' | translate }}
</button>
</mat-card-actions>
</form>
</mat-card>
<div class="ca-notice">
<fa-icon icon="info-circle"></fa-icon>
<span>
Reactivation re-opens the client record. Closed loans and savings stay closed; new accounts can now be
created.
</span>
</div>
</div>
<div class="ca-actions">
<button type="button" class="ca-btn ca-btn--cancel" [routerLink]="['../../']">
<fa-icon icon="times"></fa-icon><span>{{ 'labels.buttons.Cancel' | translate }}</span>
</button>
<button type="submit" class="ca-btn ca-btn--confirm" [disabled]="!reactivateClientForm.valid">
<fa-icon icon="check"></fa-icon><span>{{ 'labels.buttons.Confirm' | translate }}</span>
</button>
</div>
</form>
</div>
@@ -6,6 +6,12 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.container {
max-width: 37rem;
@use 'assets/styles/client-action' as ca;
@include ca.client-action-styles;
/* Reactivate-specific green accent */
:host {
--ca-accent: #15803d;
--ca-accent-l: #dcfce7;
}
@@ -15,6 +15,7 @@ import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { ClientsService } from 'app/clients/clients.service';
import { Dates } from 'app/core/utils/dates';
import { SettingsService } from 'app/settings/settings.service';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
@@ -25,7 +26,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
templateUrl: './reactivate-client.component.html',
styleUrls: ['./reactivate-client.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS
...STANDALONE_SHARED_IMPORTS,
FaIconComponent
]
})
export class ReactivateClientComponent implements OnInit {
@@ -6,58 +6,79 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="container">
<mat-card>
<form [formGroup]="rejectClientForm" (ngSubmit)="submit()">
<mat-card-content>
<div class="layout-column">
<mat-form-field (click)="rejectionDatePicker.open()">
<mat-label>{{ 'labels.inputs.Rejection Date' | translate }}</mat-label>
<input
matInput
[min]="minDate"
[max]="maxDate"
[matDatepicker]="rejectionDatePicker"
required
formControlName="rejectionDate"
/>
<mat-datepicker-toggle matSuffix [for]="rejectionDatePicker"></mat-datepicker-toggle>
<mat-datepicker #rejectionDatePicker></mat-datepicker>
@if (rejectClientForm.controls.rejectionDate.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Rejection Date' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<div class="ca-root">
<!-- HERO -->
<div class="ca-hero">
<div class="ca-hero-icon"><fa-icon icon="ban"></fa-icon></div>
<div class="ca-hero-text">
<div class="ca-hero-eyebrow">Client Action</div>
<h2 class="ca-hero-title">{{ 'labels.heading.Reject Client' | translate }}</h2>
<p class="ca-hero-sub">
Reject this client application when onboarding cannot be approved (e.g. failed KYC, regulatory hit, document
mismatch). The reason is recorded for audit and compliance.
</p>
</div>
</div>
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Rejection Reason' | translate }}</mat-label>
<mat-select formControlName="rejectionReasonId">
@for (reason of rejectionData; track reason) {
<mat-option [value]="reason.id">
{{ reason.name }}
</mat-option>
}
</mat-select>
@if (rejectClientForm.controls.rejectionReasonId.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Rejection Reason' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
</div>
</mat-card-content>
<form [formGroup]="rejectClientForm" (ngSubmit)="submit()" class="ca-card">
<div class="ca-card-body">
<div class="ca-form-row">
<mat-form-field appearance="outline" (click)="rejectionDatePicker.open()">
<mat-label>{{ 'labels.inputs.Rejection Date' | translate }}</mat-label>
<input
matInput
[min]="minDate"
[max]="maxDate"
[matDatepicker]="rejectionDatePicker"
required
formControlName="rejectionDate"
/>
<mat-datepicker-toggle matSuffix [for]="rejectionDatePicker"></mat-datepicker-toggle>
<mat-datepicker #rejectionDatePicker></mat-datepicker>
@if (rejectClientForm.controls.rejectionDate.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Rejection Date' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
</div>
<mat-card-actions class="layout-row align-center gap-5px responsive-column">
<button type="button" mat-raised-button [routerLink]="['../../']">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-raised-button color="warn" [disabled]="!rejectClientForm.valid">
{{ 'labels.buttons.Confirm' | translate }}
</button>
</mat-card-actions>
</form>
</mat-card>
<div class="ca-form-row">
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Rejection Reason' | translate }}</mat-label>
<mat-select formControlName="rejectionReasonId">
@for (reason of rejectionData; track reason) {
<mat-option [value]="reason.id">{{ reason.name }}</mat-option>
}
</mat-select>
@if (rejectClientForm.controls.rejectionReasonId.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Rejection Reason' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
</div>
<div class="ca-notice">
<fa-icon icon="exclamation-triangle"></fa-icon>
<span>
Rejection moves the client to a <strong>Rejected</strong> state and is final. Use <em>Undo Rejection</em> if
the decision was made in error.
</span>
</div>
</div>
<div class="ca-actions">
<button type="button" class="ca-btn ca-btn--cancel" [routerLink]="['../../']">
<fa-icon icon="times"></fa-icon>
<span>{{ 'labels.buttons.Cancel' | translate }}</span>
</button>
<button type="submit" class="ca-btn ca-btn--confirm" [disabled]="!rejectClientForm.valid">
<fa-icon icon="check"></fa-icon>
<span>{{ 'labels.buttons.Confirm' | translate }}</span>
</button>
</div>
</form>
</div>
@@ -6,6 +6,12 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.container {
max-width: 37rem;
@use 'assets/styles/client-action' as ca;
@include ca.client-action-styles;
/* Reject-specific red accent (final / blocked) */
:host {
--ca-accent: #c0392b;
--ca-accent-l: #fee2e2;
}
@@ -15,6 +15,7 @@ import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { ClientsService } from 'app/clients/clients.service';
import { Dates } from 'app/core/utils/dates';
import { SettingsService } from 'app/settings/settings.service';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
@@ -25,7 +26,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
templateUrl: './reject-client.component.html',
styleUrls: ['./reject-client.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS
...STANDALONE_SHARED_IMPORTS,
FaIconComponent
]
})
export class RejectClientComponent implements OnInit {
@@ -6,63 +6,92 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="container">
<mat-card>
<form [formGroup]="transferClientForm" (ngSubmit)="submit()">
<mat-card-content>
<div class="layout-column">
<mat-form-field>
<mat-label>{{ 'labels.inputs.Office' | translate }}</mat-label>
<mat-select required formControlName="destinationOfficeId">
@for (office of officeData; track office) {
<mat-option [value]="office.id">
{{ office.name }}
</mat-option>
}
</mat-select>
@if (transferClientForm.controls.destinationOfficeId.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Office' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
<div class="ca-root">
<!-- HERO -->
<div class="ca-hero">
<div class="ca-hero-icon"><fa-icon icon="exchange"></fa-icon></div>
<div class="ca-hero-text">
<div class="ca-hero-eyebrow">Client Action</div>
<h2 class="ca-hero-title">{{ 'labels.heading.Transfer Client' | translate }}</h2>
<p class="ca-hero-sub">
Move this client to a different office or branch. The destination office will need to
<strong>accept</strong> the proposed transfer before it takes effect. The client's accounts move with them.
</p>
</div>
</div>
<form [formGroup]="transferClientForm" (ngSubmit)="submit()" class="ca-card">
<div class="ca-card-body">
<div class="ca-form-row">
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Office' | translate }}</mat-label>
<mat-select required formControlName="destinationOfficeId">
@for (office of officeData; track office) {
<mat-option [value]="office.id">{{ office.name }}</mat-option>
}
</mat-form-field>
</mat-select>
@if (transferClientForm.controls.destinationOfficeId.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Office' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
</div>
<mat-form-field (click)="transferDatePicker.open()">
<mat-label>{{ 'labels.inputs.Transfer Date' | translate }}</mat-label>
<input
matInput
[min]="minDate"
[max]="maxDate"
[matDatepicker]="transferDatePicker"
required
formControlName="transferDate"
/>
<mat-datepicker-toggle matSuffix [for]="transferDatePicker"></mat-datepicker-toggle>
<mat-datepicker #transferDatePicker></mat-datepicker>
@if (transferClientForm.controls.transferDate.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Transfer Date' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<div class="ca-form-row">
<mat-form-field appearance="outline" (click)="transferDatePicker.open()">
<mat-label>{{ 'labels.inputs.Transfer Date' | translate }}</mat-label>
<input
matInput
[min]="minDate"
[max]="maxDate"
[matDatepicker]="transferDatePicker"
required
formControlName="transferDate"
/>
<mat-datepicker-toggle matSuffix [for]="transferDatePicker"></mat-datepicker-toggle>
<mat-datepicker #transferDatePicker></mat-datepicker>
@if (transferClientForm.controls.transferDate.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Transfer Date' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
</div>
<mat-form-field>
<mat-label>{{ 'labels.inputs.Note' | translate }}</mat-label>
<textarea matInput formControlName="note" cdkTextareaAutosize cdkAutosizeMinRows="2"></textarea>
</mat-form-field>
</div>
</mat-card-content>
<div class="ca-form-row">
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Note' | translate }}</mat-label>
<textarea
matInput
formControlName="note"
cdkTextareaAutosize
cdkAutosizeMinRows="2"
placeholder="Optional — reason or context for the transfer"
></textarea>
</mat-form-field>
</div>
<mat-card-actions class="layout-row align-center gap-5px responsive-column">
<button type="button" mat-raised-button [routerLink]="['../../']">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-raised-button color="primary" [disabled]="!transferClientForm.valid">
{{ 'labels.buttons.Confirm' | translate }}
</button>
</mat-card-actions>
</form>
</mat-card>
<div class="ca-notice">
<fa-icon icon="info-circle"></fa-icon>
<span>
The transfer is created as <strong>proposed</strong> until the destination office accepts it. Until then it
can be withdrawn from the client's <em>Actions → Undo Transfer</em> menu.
</span>
</div>
</div>
<div class="ca-actions">
<button type="button" class="ca-btn ca-btn--cancel" [routerLink]="['../../']">
<fa-icon icon="times"></fa-icon>
<span>{{ 'labels.buttons.Cancel' | translate }}</span>
</button>
<button type="submit" class="ca-btn ca-btn--confirm" [disabled]="!transferClientForm.valid">
<fa-icon icon="paper-plane"></fa-icon>
<span>{{ 'labels.buttons.Confirm' | translate }}</span>
</button>
</div>
</form>
</div>
@@ -6,6 +6,12 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.container {
max-width: 37rem;
@use 'assets/styles/client-action' as ca;
@include ca.client-action-styles;
/* Transfer-specific blue accent (informational / routing) */
:host {
--ca-accent: #1d4ed8;
--ca-accent-l: #eff6ff;
}
@@ -16,6 +16,7 @@ import { ClientsService } from 'app/clients/clients.service';
import { Dates } from 'app/core/utils/dates';
import { SettingsService } from 'app/settings/settings.service';
import { CdkTextareaAutosize } from '@angular/cdk/text-field';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
@@ -27,7 +28,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
styleUrls: ['./transfer-client.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
CdkTextareaAutosize
CdkTextareaAutosize,
FaIconComponent
]
})
export class TransferClientComponent implements OnInit {
@@ -6,11 +6,22 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="container">
<mat-card>
<form [formGroup]="undoClientRejectionForm" (ngSubmit)="submit()">
<mat-card-content>
<mat-form-field class="flex-fill" (click)="reopenedDatePicker.open()">
<div class="ca-root">
<div class="ca-hero">
<div class="ca-hero-icon"><fa-icon icon="undo"></fa-icon></div>
<div class="ca-hero-text">
<div class="ca-hero-eyebrow">Client Action</div>
<h2 class="ca-hero-title">{{ 'labels.heading.Undo Client Rejection' | translate }}</h2>
<p class="ca-hero-sub">
Reverse a prior rejection and re-open this client back to the <strong>Pending</strong> state for further review.
</p>
</div>
</div>
<form [formGroup]="undoClientRejectionForm" (ngSubmit)="submit()" class="ca-card">
<div class="ca-card-body">
<div class="ca-form-row">
<mat-form-field appearance="outline" (click)="reopenedDatePicker.open()">
<mat-label>{{ 'labels.inputs.Reopened On Date' | translate }}</mat-label>
<input
matInput
@@ -29,16 +40,24 @@
</mat-error>
}
</mat-form-field>
</mat-card-content>
</div>
<mat-card-actions class="layout-row align-center gap-5px responsive-column">
<button type="button" mat-raised-button [routerLink]="['../../']">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-raised-button color="primary" [disabled]="!undoClientRejectionForm.valid">
{{ 'labels.buttons.Confirm' | translate }}
</button>
</mat-card-actions>
</form>
</mat-card>
<div class="ca-notice">
<fa-icon icon="info-circle"></fa-icon>
<span
>The client returns to <strong>Pending</strong> — you can then activate, withdraw or re-reject as
appropriate.</span
>
</div>
</div>
<div class="ca-actions">
<button type="button" class="ca-btn ca-btn--cancel" [routerLink]="['../../']">
<fa-icon icon="times"></fa-icon><span>{{ 'labels.buttons.Cancel' | translate }}</span>
</button>
<button type="submit" class="ca-btn ca-btn--confirm" [disabled]="!undoClientRejectionForm.valid">
<fa-icon icon="check"></fa-icon><span>{{ 'labels.buttons.Confirm' | translate }}</span>
</button>
</div>
</form>
</div>
@@ -15,6 +15,7 @@ import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { ClientsService } from 'app/clients/clients.service';
import { Dates } from 'app/core/utils/dates';
import { SettingsService } from 'app/settings/settings.service';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
@@ -25,7 +26,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
templateUrl: './undo-client-rejection.component.html',
styleUrls: ['./undo-client-rejection.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS
...STANDALONE_SHARED_IMPORTS,
FaIconComponent
]
})
export class UndoClientRejectionComponent implements OnInit {
@@ -6,38 +6,54 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="container">
<mat-card>
<form [formGroup]="clientSavingsAccountForm" (ngSubmit)="submit()">
<mat-card-content>
<div class="layout-column">
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Default Savings Account' | translate }}</mat-label>
<mat-select formControlName="savingsAccountId">
@for (account of savingsAccounts; track account) {
<mat-option [value]="account.id">
{{ account.accountNo }}
</mat-option>
}
</mat-select>
@if (clientSavingsAccountForm.controls.savingsAccountId.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Account' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
</div>
</mat-card-content>
<div class="ca-root">
<div class="ca-hero">
<div class="ca-hero-icon"><fa-icon icon="piggy-bank"></fa-icon></div>
<div class="ca-hero-text">
<div class="ca-hero-eyebrow">Client Action</div>
<h2 class="ca-hero-title">{{ 'labels.buttons.Update Default Savings' | translate }}</h2>
<p class="ca-hero-sub">
Pick which savings account is the <strong>default</strong> for this client. Auto-debit instructions, fee
collections and loan disbursals will use this account unless explicitly overridden.
</p>
</div>
</div>
<mat-card-actions class="layout-row align-center gap-5px responsive-column">
<button type="button" mat-raised-button [routerLink]="['../../']">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-raised-button color="primary" [disabled]="!clientSavingsAccountForm.valid">
{{ 'labels.buttons.Confirm' | translate }}
</button>
</mat-card-actions>
</form>
</mat-card>
<form [formGroup]="clientSavingsAccountForm" (ngSubmit)="submit()" class="ca-card">
<div class="ca-card-body">
<div class="ca-form-row">
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Default Savings Account' | translate }}</mat-label>
<mat-select formControlName="savingsAccountId">
@for (account of savingsAccounts; track account) {
<mat-option [value]="account.id">{{ account.accountNo }}</mat-option>
}
</mat-select>
@if (clientSavingsAccountForm.controls.savingsAccountId.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Account' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
</div>
<div class="ca-notice">
<fa-icon icon="info-circle"></fa-icon>
<span>
Changing the default does not move any existing balances or scheduled instructions — it only affects
<strong>new</strong> debits/credits that look up the client's default account.
</span>
</div>
</div>
<div class="ca-actions">
<button type="button" class="ca-btn ca-btn--cancel" [routerLink]="['../../']">
<fa-icon icon="times"></fa-icon><span>{{ 'labels.buttons.Cancel' | translate }}</span>
</button>
<button type="submit" class="ca-btn ca-btn--confirm" [disabled]="!clientSavingsAccountForm.valid">
<fa-icon icon="check"></fa-icon><span>{{ 'labels.buttons.Confirm' | translate }}</span>
</button>
</div>
</form>
</div>
@@ -6,6 +6,12 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.container {
max-width: 37rem;
@use 'assets/styles/client-action' as ca;
@include ca.client-action-styles;
/* Update Default Savings — teal accent */
:host {
--ca-accent: #0e7e6b;
--ca-accent-l: #ccfbf1;
}
@@ -13,6 +13,7 @@ import { ActivatedRoute, Router, RouterLink } from '@angular/router';
/** Custom Services */
import { ClientsService } from 'app/clients/clients.service';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
@@ -23,7 +24,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
templateUrl: './update-client-savings-account.component.html',
styleUrls: ['./update-client-savings-account.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS
...STANDALONE_SHARED_IMPORTS,
FaIconComponent
]
})
export class UpdateClientSavingsAccountComponent implements OnInit {
@@ -6,58 +6,79 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="container">
<mat-card>
<form [formGroup]="withdrawClientForm" (ngSubmit)="submit()">
<mat-card-content>
<div class="layout-column">
<mat-form-field (click)="withdrawalDatePicker.open()">
<mat-label>{{ 'labels.inputs.Withdrawal Date' | translate }}</mat-label>
<input
matInput
[min]="minDate"
[max]="maxDate"
[matDatepicker]="withdrawalDatePicker"
required
formControlName="withdrawalDate"
/>
<mat-datepicker-toggle matSuffix [for]="withdrawalDatePicker"></mat-datepicker-toggle>
<mat-datepicker #withdrawalDatePicker></mat-datepicker>
@if (withdrawClientForm.controls.withdrawalDate.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Withdrawal Date' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<div class="ca-root">
<!-- HERO -->
<div class="ca-hero">
<div class="ca-hero-icon"><fa-icon icon="undo"></fa-icon></div>
<div class="ca-hero-text">
<div class="ca-hero-eyebrow">Client Action</div>
<h2 class="ca-hero-title">{{ 'labels.heading.Withdraw Client' | translate }}</h2>
<p class="ca-hero-sub">
Withdraw a <strong>Pending</strong> client when onboarding will not proceed (e.g. customer changed their mind).
The record is preserved with a withdrawn status and can be re-opened later if needed.
</p>
</div>
</div>
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Withdrawal Reason' | translate }}</mat-label>
<mat-select formControlName="withdrawalReasonId">
@for (reason of withdrawalData; track reason) {
<mat-option [value]="reason.id">
{{ reason.name }}
</mat-option>
}
</mat-select>
@if (withdrawClientForm.controls.withdrawalReasonId.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Withdrawal Reason' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
</div>
</mat-card-content>
<form [formGroup]="withdrawClientForm" (ngSubmit)="submit()" class="ca-card">
<div class="ca-card-body">
<div class="ca-form-row">
<mat-form-field appearance="outline" (click)="withdrawalDatePicker.open()">
<mat-label>{{ 'labels.inputs.Withdrawal Date' | translate }}</mat-label>
<input
matInput
[min]="minDate"
[max]="maxDate"
[matDatepicker]="withdrawalDatePicker"
required
formControlName="withdrawalDate"
/>
<mat-datepicker-toggle matSuffix [for]="withdrawalDatePicker"></mat-datepicker-toggle>
<mat-datepicker #withdrawalDatePicker></mat-datepicker>
@if (withdrawClientForm.controls.withdrawalDate.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Withdrawal Date' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
</div>
<mat-card-actions class="layout-row align-center gap-5px responsive-column">
<button type="button" mat-raised-button [routerLink]="['../../']">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-raised-button color="primary" [disabled]="!withdrawClientForm.valid">
{{ 'labels.buttons.Confirm' | translate }}
</button>
</mat-card-actions>
</form>
</mat-card>
<div class="ca-form-row">
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Withdrawal Reason' | translate }}</mat-label>
<mat-select formControlName="withdrawalReasonId">
@for (reason of withdrawalData; track reason) {
<mat-option [value]="reason.id">{{ reason.name }}</mat-option>
}
</mat-select>
@if (withdrawClientForm.controls.withdrawalReasonId.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Withdrawal Reason' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
</div>
<div class="ca-notice">
<fa-icon icon="info-circle"></fa-icon>
<span>
A reason is required for audit. The client record is retained and can be reactivated later via the
<strong>Undo Withdrawal</strong> action.
</span>
</div>
</div>
<div class="ca-actions">
<button type="button" class="ca-btn ca-btn--cancel" [routerLink]="['../../']">
<fa-icon icon="times"></fa-icon>
<span>{{ 'labels.buttons.Cancel' | translate }}</span>
</button>
<button type="submit" class="ca-btn ca-btn--confirm" [disabled]="!withdrawClientForm.valid">
<fa-icon icon="check"></fa-icon>
<span>{{ 'labels.buttons.Confirm' | translate }}</span>
</button>
</div>
</form>
</div>
@@ -6,6 +6,12 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.container {
max-width: 37rem;
@use 'assets/styles/client-action' as ca;
@include ca.client-action-styles;
/* Withdraw-specific amber accent (caution / pause) */
:host {
--ca-accent: #b45309;
--ca-accent-l: #fef3c7;
}
@@ -15,6 +15,7 @@ import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { ClientsService } from 'app/clients/clients.service';
import { Dates } from 'app/core/utils/dates';
import { SettingsService } from 'app/settings/settings.service';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
@@ -25,7 +26,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
templateUrl: './withdraw-client.component.html',
styleUrls: ['./withdraw-client.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS
...STANDALONE_SHARED_IMPORTS,
FaIconComponent
]
})
export class WithdrawClientComponent implements OnInit {
+81 -42
View File
@@ -55,6 +55,40 @@
<!-- Toolbar -->
<mifosx-toolbar [sidenav]="sidenav" (collapse)="toggleCollapse($event)"></mifosx-toolbar>
<!-- Live KPI ticker items — referenced twice below for seamless marquee loop -->
<ng-template #kpiTickerItems>
<span class="ti"><span class="ti-k">CASH</span><span class="ti-v amber">₹14,82,500</span></span>
<span class="ti-d">│</span>
<span class="ti">
<span class="ti-k">TXN TODAY</span>
<span class="ti-v">1,247</span>
<span class="ti-v up">↑ 8.2%</span>
</span>
<span class="ti-d">│</span>
<span class="ti"><span class="ti-k">CLEARING IN</span><span class="ti-v">42</span></span>
<span class="ti-d">│</span>
<span class="ti"><span class="ti-k">CLEARING OUT</span><span class="ti-v">38</span></span>
<span class="ti-d">│</span>
<span class="ti">
<span class="ti-k">NPA·GROSS</span>
<span class="ti-v amber">3.47%</span>
<span class="ti-v down">↑ 0.12</span>
</span>
<span class="ti-d">│</span>
<span class="ti"><span class="ti-k">CASA</span><span class="ti-v">58.2%</span></span>
<span class="ti-d">│</span>
<span class="ti"><span class="ti-k">CD RATIO</span><span class="ti-v">71.8%</span></span>
<span class="ti-d">│</span>
<span class="ti">
<span class="ti-k">RBI CIRC</span>
<span class="ti-v">DBR.AML.NO.4/14.01.001</span>
<span class="ti-v amber">NEW</span>
</span>
<span class="ti-d">│</span>
<span class="ti"><span class="ti-k">REPO</span><span class="ti-v">6.50%</span></span>
<span class="ti-d">│</span>
</ng-template>
<!-- Main Header — 2-Row Navigation -->
<div class="main-header">
<!-- Row 1 — 11 items (permission-gated) -->
@@ -170,6 +204,19 @@
</div>
</div>
<!-- LIVE KPI TICKER (below the menu — pauses on hover) -->
<div class="kpi-ticker" role="marquee" aria-label="Live key-performance ticker">
<span class="kpi-live">
<span class="kpi-live-dot"></span>
LIVE
</span>
<div class="kpi-track">
<!-- Render the item block twice so the marquee loops seamlessly. -->
<ng-container *ngTemplateOutlet="kpiTickerItems"></ng-container>
<ng-container *ngTemplateOutlet="kpiTickerItems"></ng-container>
</div>
</div>
<!-- Row 1 Menus -->
<mat-menu #institutionMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
<button mat-menu-item routerLink="/clients" tabindex="0">
@@ -794,19 +841,8 @@
</mat-menu>
<mat-menu #ckycMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
<button mat-menu-item tabindex="0"><fa-icon icon="camera" class="menu-item-icon"></fa-icon> Photo</button>
<button mat-menu-item tabindex="0"><fa-icon icon="pen" class="menu-item-icon"></fa-icon> Sign Upload</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="users" class="menu-item-icon"></fa-icon> Client Management
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="file-signature" class="menu-item-icon"></fa-icon> Signature Report
</button>
<button mat-menu-item tabindex="0" (click)="openCkycReportsDialog('RISK-REVIEW')">
<fa-icon icon="exclamation-triangle" class="menu-item-icon" style="color: #be185d"></fa-icon> Risk Category
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="chart-bar" class="menu-item-icon"></fa-icon> Risk Category and Turnover Profile
<button mat-menu-item routerLink="/ckyc" tabindex="0">
<fa-icon icon="id-card" class="menu-item-icon"></fa-icon> CKYC Management
</button>
<button mat-menu-item tabindex="0" (click)="openCkycReportsDialog('REKYC')">
<fa-icon icon="sync-alt" class="menu-item-icon" style="color: #be185d"></fa-icon> Re-KYC Report
@@ -868,15 +904,18 @@
<!-- Notices & Renewals -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Notices &amp; Renewals</div>
<button mat-menu-item tabindex="0"><fa-icon icon="file-alt" class="menu-item-icon"></fa-icon> Notice</button>
<button mat-menu-item routerLink="/letter-mgmt/notice" tabindex="0">
<fa-icon icon="file-alt" class="menu-item-icon" style="color: #15803d"></fa-icon> Notice
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="sync" class="menu-item-icon"></fa-icon> Limit Renewal
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="file-invoice" class="menu-item-icon"></fa-icon> Loan Interest Certificate
<button mat-menu-item routerLink="/letter-mgmt/loan-interest-certificate" tabindex="0">
<fa-icon icon="file-invoice" class="menu-item-icon" style="color: #15803d"></fa-icon>
Loan Interest Certificate
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="certificate" class="menu-item-icon"></fa-icon> Share Certificate
<button mat-menu-item routerLink="/letter-mgmt/share-certificate" tabindex="0">
<fa-icon icon="certificate" class="menu-item-icon" style="color: #15803d"></fa-icon> Share Certificate
</button>
</div>
<!-- Printing -->
@@ -896,25 +935,30 @@
<!-- Certificates -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Certificates</div>
<button mat-menu-item tabindex="0">
<fa-icon icon="balance-scale" class="menu-item-icon"></fa-icon> Balance Certificate
<button mat-menu-item routerLink="/letter-mgmt/balance-certificate" tabindex="0">
<fa-icon icon="balance-scale" class="menu-item-icon" style="color: #15803d"></fa-icon>
Balance Certificate
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="file-signature" class="menu-item-icon"></fa-icon> Form 60
<button mat-menu-item routerLink="/letter-mgmt/form-60" tabindex="0">
<fa-icon icon="file-signature" class="menu-item-icon" style="color: #15803d"></fa-icon> Form 60
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="file-signature" class="menu-item-icon"></fa-icon> Form 15G and H
<button mat-menu-item routerLink="/letter-mgmt/form-15gh" tabindex="0">
<fa-icon icon="file-signature" class="menu-item-icon" style="color: #15803d"></fa-icon> Form 15G and H
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="file-alt" class="menu-item-icon"></fa-icon> Hypothication Certificate
<button mat-menu-item routerLink="/letter-mgmt/hypothecation-certificate" tabindex="0">
<fa-icon icon="file-alt" class="menu-item-icon" style="color: #15803d"></fa-icon>
Hypothecation Certificate
</button>
</div>
<!-- Others -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Others</div>
<button mat-menu-item tabindex="0"><fa-icon icon="id-card" class="menu-item-icon"></fa-icon> NOC</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="fingerprint" class="menu-item-icon"></fa-icon> Rekyc Letter Pre-Post
<button mat-menu-item routerLink="/letter-mgmt/noc" tabindex="0">
<fa-icon icon="id-card" class="menu-item-icon" style="color: #15803d"></fa-icon> NOC
</button>
<button mat-menu-item routerLink="/kyc-management" tabindex="0">
<fa-icon icon="fingerprint" class="menu-item-icon" style="color: #15803d"></fa-icon>
KYC &amp; ReKYC Management
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="money-bill-wave" class="menu-item-icon"></fa-icon> Digital Transaction UTR
@@ -947,27 +991,22 @@
</mat-menu>
<mat-menu #digitalMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
<button mat-menu-item routerLink="/digital/neft-rtgs" tabindex="0">
<button mat-menu-item routerLink="/digital/neft-rtgs-report" tabindex="0">
<fa-icon icon="exchange" class="menu-item-icon"></fa-icon> NEFT / RTGS
</button>
<button mat-menu-item routerLink="/digital/neft-rtgs-report" tabindex="0">
<fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> NEFT / RTGS Report
<button mat-menu-item routerLink="/digital/imps-report" tabindex="0">
<fa-icon icon="bolt" class="menu-item-icon"></fa-icon> IMPS
</button>
<button mat-menu-item tabindex="0"><fa-icon icon="bolt" class="menu-item-icon"></fa-icon> IMPS / BANL</button>
<button mat-menu-item tabindex="0"><fa-icon icon="mobile-alt" class="menu-item-icon"></fa-icon> UPI</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="credit-card" class="menu-item-icon"></fa-icon> ATM / POS / ECOM
<button mat-menu-item routerLink="/digital/upi-report" tabindex="0">
<fa-icon icon="mobile-alt" class="menu-item-icon"></fa-icon> UPI Reports
</button>
<button mat-menu-item routerLink="/digital/atm-pos-ecom" tabindex="0">
<fa-icon icon="credit-card" class="menu-item-icon"></fa-icon> ATM / POS / ECOM Reports
</button>
<button mat-menu-item tabindex="0"><fa-icon icon="sync" class="menu-item-icon"></fa-icon> NACH</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="file-signature" class="menu-item-icon"></fa-icon> Mandate
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="list-alt" class="menu-item-icon"></fa-icon> Transaction Register Cycle Wise
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="info-circle" class="menu-item-icon"></fa-icon> ATM TXN Status
</button>
</mat-menu>
<!-- RBI Mega Menu -->
+305 -39
View File
@@ -106,8 +106,24 @@ $brand: #1353a0;
$brand-light: #eaf1fc;
$brand-hover: #ddeafb;
$gray-200: #e2e8f0;
$gray-400: #94a3b8;
$gray-500: #64748b;
$gray-700: #334155;
/* ============================================================
Main header (top 2-row navigation)
--------------------------------------------------------------
Redesigned 2026 — clean, modern enterprise SaaS feel:
• Both rows live on the same white surface separated only by a
hairline; r2 has a barely-there blue-50 wash so frequent vs
secondary menus read at a glance without dark/light contrast.
• Items are now equal-width "tabs" with NO vertical dividers
between them (the old cell borders made it look spreadsheet-y).
• Hover / active = a 3 px brand-coloured TOP bar + brand text +
coloured icon. Reads instantly without colour-flooding the row.
• Icons larger (12.5 px) and full-opacity always — easier to scan
during demos. Caret is subtle and rotates on open.
============================================================ */
.main-header {
background: #fff;
border-bottom: 2.5px solid $brand;
@@ -119,48 +135,70 @@ $gray-700: #334155;
}
.nav-row {
height: 38px;
display: flex;
height: 44px;
display: grid;
grid-template-columns: repeat(11, minmax(0, 1fr));
align-items: stretch;
justify-content: space-between;
overflow: visible;
position: relative;
/* Grid container background shows through the 1 px gap as crisp 1 px borders
on every side of every cell — no doubled-up 2 px lines between adjacent
items, perfect 1 px outer frame too. */
gap: 1px;
background: $gray-200;
border: 1px solid $gray-200;
}
.r1 {
.r1 .ni {
background: #fff;
border-bottom: 1px solid $gray-200;
}
.r2 {
.r2 .ni {
background: #f8fafc;
}
// ===== Nav Item =====
// ===== Nav Item — modern tab style =====
// Typography spec — same family / size / weight / color for BOTH rows.
.ni {
position: relative;
display: flex;
display: inline-flex;
align-items: center;
gap: 4px;
flex: 1;
justify-content: center;
gap: 7px;
height: 100%;
padding: 0 10px;
cursor: pointer;
white-space: nowrap;
color: $gray-700;
font-size: 11px;
font-family:
'IBM Plex Sans',
-apple-system,
sans-serif;
font-size: 12px;
font-weight: 500;
color: #0a1a2e;
line-height: 1.3;
letter-spacing: 0.01em;
transition:
background 0.12s,
color 0.12s;
user-select: none;
border-right: 1px solid $gray-200;
text-decoration: none;
font-family: 'DM Sans', Roboto, sans-serif;
user-select: none;
transition:
background 0.15s ease,
color 0.15s ease,
box-shadow 0.15s ease;
border-radius: 0;
&:last-child {
border-right: none;
/* 3px accent strip that fills on hover / active (uses ::before so we
don't reflow the row when toggling). */
&::before {
content: '';
position: absolute;
left: 8px;
right: 8px;
top: 0;
height: 3px;
background: transparent;
border-radius: 0 0 3px 3px;
transition: background 0.15s ease;
}
&:hover {
@@ -168,48 +206,276 @@ $gray-700: #334155;
color: $brand;
}
&:hover::before {
background: $brand;
}
&.active {
background: $brand-light;
color: $brand;
font-weight: 600;
background: $brand-light;
}
&.active::before {
content: '';
position: absolute;
left: 0;
top: 18%;
bottom: 18%;
width: 2.5px;
background: $brand;
border-radius: 0 2px 2px 0;
}
&:focus-visible {
outline: 2px solid $brand;
outline-offset: -2px;
}
}
.ni-ico {
font-size: 11px;
opacity: 0.55;
width: 12px;
height: 12px;
font-size: 12px;
color: $gray-500;
flex-shrink: 0;
transition: color 0.15s ease;
.ni:hover &,
.ni.active & {
opacity: 1;
color: $brand;
}
}
.ni-caret {
font-size: 8px;
opacity: 0.3;
transition:
transform 0.18s,
opacity 0.18s;
width: 7px;
height: 7px;
font-size: 7px;
color: $gray-400;
flex-shrink: 0;
transition:
transform 0.18s ease,
color 0.18s ease;
.ni:hover & {
opacity: 0.7;
.ni:hover &,
.ni.active & {
color: $brand;
}
}
@media (width <= 1200px) {
.ni {
padding: 0 8px;
gap: 5px;
}
}
@media (width <= 980px) {
.ni {
padding: 0 6px;
gap: 4px;
}
}
/* ============================================================
Live KPI ticker — sits directly under the 2-row menu
============================================================ */
.kpi-ticker {
position: relative;
display: flex;
align-items: center;
height: 30px;
/* Premium rich brand-blue gradient — matches the CKYC banner you saw in the
screenshot, anchoring the ticker into the same brand surface family.
Same hues, same direction, same depth = visual cohesion across the app. */
background: linear-gradient(135deg, #0b1e3d 0%, #1e3a8a 35%, #1d4ed8 65%, #1e40af 100%);
color: #fff;
overflow: hidden;
font-family:
'IBM Plex Sans',
-apple-system,
sans-serif;
font-size: 11.5px;
line-height: 1;
user-select: none;
box-shadow: inset 0 1px 0 rgb(255 255 255 / 10%);
/* Thin amber accent strip at the bottom — classic financial-ticker premium
touch, picks up the same amber used inside the value chips. */
border-bottom: 1.5px solid #fbbf24;
}
/* Slow-moving sheen overlay that gives the band a subtle "live" pulse without
stealing focus from the data. Pointer-events: none so it never blocks hover
pausing on the track. */
.kpi-ticker::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
115deg,
transparent 0%,
transparent 38%,
rgb(255 255 255 / 8%) 50%,
transparent 62%,
transparent 100%
);
background-size: 300% 100%;
animation: kpi-sheen 8s linear infinite;
pointer-events: none;
z-index: 1;
}
.kpi-ticker:hover::before {
animation-play-state: paused;
}
@keyframes kpi-sheen {
from {
background-position: 200% 0;
}
to {
background-position: -100% 0;
}
}
/* Sticky "LIVE" badge — a punchy red chip so the eye finds it instantly on
the dark blue band. Classic financial-ticker vibe. */
.kpi-live {
position: sticky;
left: 0;
z-index: 3;
display: inline-flex;
align-items: center;
gap: 7px;
height: 100%;
padding: 0 14px;
background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%);
font-weight: 800;
letter-spacing: 0.18em;
font-size: 10px;
color: #fff;
text-transform: uppercase;
border-right: 1px solid rgb(255 255 255 / 18%);
box-shadow:
8px 0 14px -8px rgb(11 30 61 / 60%),
inset 0 1px 0 rgb(255 255 255 / 16%);
flex-shrink: 0;
text-shadow: 0 1px 0 rgb(0 0 0 / 25%);
}
.kpi-live-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #fef08a; /* bright yellow dot on red — high-contrast attention */
box-shadow:
0 0 0 0 rgb(254 240 138 / 80%),
0 0 6px 1px rgb(254 240 138 / 60%);
animation: kpi-pulse 1.3s ease-in-out infinite;
}
@keyframes kpi-pulse {
0% {
box-shadow:
0 0 0 0 rgb(254 240 138 / 70%),
0 0 6px 1px rgb(254 240 138 / 60%);
transform: scale(1);
}
70% {
box-shadow:
0 0 0 8px rgb(254 240 138 / 0%),
0 0 6px 1px rgb(254 240 138 / 0%);
transform: scale(1.18);
}
100% {
box-shadow:
0 0 0 0 rgb(254 240 138 / 0%),
0 0 6px 1px rgb(254 240 138 / 30%);
transform: scale(1);
}
}
/* Scrolling track — rendered with the item list twice so the loop is seamless.
z-index keeps the data above the sheen overlay. */
.kpi-track {
position: relative;
z-index: 2;
display: inline-flex;
align-items: center;
gap: 0;
white-space: nowrap;
width: max-content;
padding-left: 18px;
animation: kpi-scroll 75s linear infinite;
}
.kpi-ticker:hover .kpi-track,
.kpi-ticker:focus-within .kpi-track {
animation-play-state: paused;
}
@keyframes kpi-scroll {
from {
transform: translateX(0);
}
to {
transform: translateX(-50%);
}
}
/* Individual ticker item — key + one or more values */
.ti {
display: inline-flex;
align-items: baseline;
gap: 8px;
padding: 0 14px;
position: relative;
z-index: 2; /* above the sheen overlay */
}
.ti-k {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: rgb(255 255 255 / 62%);
}
.ti-v {
font-size: 12px;
font-weight: 700;
color: #fff;
font-variant-numeric: tabular-nums;
letter-spacing: 0.02em;
text-shadow: 0 1px 0 rgb(0 0 0 / 20%);
&.amber {
/* Bright gold value — financial-ticker classic, pops on deep blue */
color: #fcd34d;
text-shadow:
0 1px 0 rgb(0 0 0 / 25%),
0 0 8px rgb(252 211 77 / 30%);
}
&.up {
color: #4ade80; /* green-400 — bright on dark blue */
text-shadow: 0 1px 0 rgb(0 0 0 / 25%);
}
&.down {
color: #fca5a5; /* red-300 — bright on dark blue */
text-shadow: 0 1px 0 rgb(0 0 0 / 25%);
}
}
/* Vertical pipe separator between items */
.ti-d {
position: relative;
z-index: 2;
color: rgb(255 255 255 / 26%);
font-weight: 300;
font-size: 14px;
padding: 0 2px;
}
.menu-item-icon {
margin-right: 8px;
width: 16px;
+3
View File
@@ -82,6 +82,9 @@ export class ShellComponent implements OnInit, OnDestroy {
rbiMenuOpen = false;
rbiSearch = '';
/* Live KPI ticker — markup is static inside the template; the marquee
animation, LIVE badge, and pause-on-hover handle the live feel. */
matchesSubLedgerSearch(label: string): boolean {
if (!this.subLedgerSearch) return true;
return label.toLowerCase().includes(this.subLedgerSearch.toLowerCase());
@@ -68,14 +68,14 @@
<mat-list-item
[routerLink]="['/kyc-management']"
[matTooltipPosition]="tooltipPosition"
matTooltip="KYC Management"
matTooltip="KYC & ReKYC Management"
routerLinkActive="active-menu"
[routerLinkActiveOptions]="{ exact: false }"
>
<mat-icon matListIcon>
<fa-icon icon="user-shield" size="sm"></fa-icon>
</mat-icon>
<a matLine>KYC Management</a>
<a matLine>KYC &amp; ReKYC Management</a>
</mat-list-item>
<mat-list-item
[routerLink]="['/bank-calendar']"
@@ -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]);
}
}
+19 -7
View File
@@ -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' }
}
])
];
+13 -1
View File
@@ -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">₹&nbsp;</span>
<input matInput type="number" min="1" step="0.01" required formControlName="amount" placeholder="0.00" />
@if (entryForm.get('amount')?.hasError('required')) {
<mat-error>Amount is <strong>required</strong></mat-error>
}
@if (entryForm.get('amount')?.hasError('min') && !entryForm.get('amount')?.hasError('required')) {
<mat-error>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 &amp; 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&nbsp;</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 &amp; 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 &amp; 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 &amp; 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 &middot; {{ 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 &middot;
<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">₹&nbsp;</span>
<input matInput type="number" min="1" step="0.01" required formControlName="amount" placeholder="0.00" />
@if (entryForm.get('amount')?.hasError('required')) {
<mat-error>Amount is <strong>required</strong></mat-error>
}
@if (entryForm.get('amount')?.hasError('min') && !entryForm.get('amount')?.hasError('required')) {
<mat-error>
@if (rtgsUnderMin) {
RTGS min ₹{{ RTGS_MIN | number: '1.0-0' }}
} @else {
Amount must be > 0
}
</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline" class="nr-field">
<mat-label>Sender Account No.</mat-label>
<input matInput required formControlName="senderAccount" placeholder="e.g. SB10029384" autocomplete="off" />
<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">₹&nbsp;</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 &amp; 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 &middot;
<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 &amp; 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 &middot; {{ 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)
}
];
}
@@ -0,0 +1,152 @@
<!--
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="cert-root">
<div class="cert-head">
<div class="cert-head-l">
<fa-icon icon="balance-scale" class="cert-head-ic"></fa-icon>
<div>
<h2 class="cert-head-t">Balance Certificate</h2>
<div class="cert-head-s">Generate an "as-on-date" balance certificate for any deposit / current account.</div>
</div>
</div>
</div>
<!-- KPI strip -->
<div class="cert-kpis">
<div class="cert-kpi cert-kpi--blue">
<fa-icon icon="balance-scale" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ filteredRecords.length | number }}</div>
<div class="cert-kpi-l">Accounts</div>
</div>
</div>
<div class="cert-kpi cert-kpi--green">
<fa-icon icon="coins" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">₹{{ totalBalance() | number: '1.0-0' }}</div>
<div class="cert-kpi-l">Balance (as on {{ asOnDate | date: 'd MMM y' }})</div>
</div>
</div>
<div class="cert-kpi cert-kpi--teal">
<fa-icon icon="calendar-alt" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ asOnDate | date: 'd MMM y' }}</div>
<div class="cert-kpi-l">Snapshot Date</div>
</div>
</div>
</div>
<!-- Filters -->
<div class="cert-filters">
<div class="cert-filter">
<label>Account / Customer / PAN</label>
<div class="cert-input-ic">
<fa-icon icon="search"></fa-icon>
<input type="text" [(ngModel)]="search" placeholder="e.g. SB10029384 or customer name" />
</div>
</div>
<div class="cert-filter">
<label>Account type</label>
<select [(ngModel)]="accountTypeFilter">
<option value="">All types</option>
@for (o of accountTypeOptions; track o.value) {
<option [value]="o.value">{{ o.label }}</option>
}
</select>
</div>
<div class="cert-filter">
<label>Balance as on</label>
<input type="date" [(ngModel)]="asOnDate" />
</div>
@if (search || accountTypeFilter) {
<button type="button" class="cert-clear" (click)="clearFilters()"><fa-icon icon="times"></fa-icon> Clear</button>
}
</div>
<!-- Table -->
<div class="cert-card">
<div class="cert-table-wrap">
<table class="cert-table">
<thead>
<tr>
<th>Customer · A/C</th>
<th>Type</th>
<th>Branch</th>
<th>Opened</th>
<th class="num">Available Bal.</th>
<th class="num">Accrued Int.</th>
<th class="num">Ledger Bal.</th>
<th class="col-actions">Actions</th>
</tr>
</thead>
<tbody>
@for (r of filteredRecords; track r.accountNo) {
<tr>
<td>
<div class="cert-cust-name">{{ r.customerName }}</div>
<div class="mono small muted">{{ r.accountNo }} · {{ r.customerId }} · PAN {{ r.customerPan }}</div>
@if (r.jointHolders) {
<div class="small muted">Jointly with {{ r.jointHolders }}</div>
}
</td>
<td>
<span class="cert-pill cert-pill--type">{{ accountTypeLabel(r.accountType) }}</span>
<div class="small muted">{{ r.productName }}</div>
</td>
<td class="small">{{ r.branch }}</td>
<td class="small">{{ r.openedOn | date: 'd MMM y' }}</td>
<td class="num">
<strong>₹{{ r.availableBalance | number: '1.2-2' }}</strong>
</td>
<td class="num">
@if (r.accruedInterest > 0) {
₹{{ r.accruedInterest | number: '1.2-2' }}
} @else {
<span class="muted">—</span>
}
</td>
<td class="num">₹{{ r.ledgerBalance | number: '1.2-2' }}</td>
<td class="col-actions">
<div class="cert-row-actions">
<button
type="button"
class="cert-icon-btn primary"
(click)="viewCertificate(r)"
matTooltip="View / Print certificate"
>
<fa-icon icon="eye"></fa-icon>
</button>
<button
type="button"
class="cert-icon-btn"
(click)="downloadCertificate(r)"
matTooltip="Download PDF"
>
<fa-icon icon="download"></fa-icon>
</button>
</div>
</td>
</tr>
}
@if (filteredRecords.length === 0) {
<tr>
<td colspan="8" class="cert-empty">
<fa-icon icon="info-circle"></fa-icon>
No accounts match the current filters.
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
@@ -0,0 +1,234 @@
/**
* 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 { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTooltip } from '@angular/material/tooltip';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import {
CertificatePreviewDialogComponent,
CertificatePreviewDialogData
} from '../certificate-preview-dialog/certificate-preview-dialog.component';
import { downloadBalanceCertificatePdf } from '../certificate-pdf';
export type BalanceAccountType = 'SAVINGS' | 'CURRENT' | 'FIXED_DEPOSIT' | 'RD' | 'NRE' | 'NRO';
export interface BalanceCertificateRecord {
accountNo: string;
customerId: string;
customerName: string;
customerPan: string;
customerAddress: string;
branch: string;
accountType: BalanceAccountType;
productName: string;
openedOn: string;
/** Balance available as on the snapshot date. */
availableBalance: number;
/** Total ledger balance (incl. accrued interest, hold amounts). */
ledgerBalance: number;
/** Accrued but uncredited interest. */
accruedInterest: number;
/** Interest rate p.a. (for FDs / RD / interest-bearing accounts). */
interestRate?: number;
/** Joint-holder names if any. */
jointHolders?: string;
}
@Component({
selector: 'mifosx-balance-certificate',
templateUrl: './balance-certificate.component.html',
styleUrls: ['../certificates.shared.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
CommonModule,
FormsModule,
FaIconComponent,
MatTooltip
]
})
export class BalanceCertificateComponent implements OnInit {
private dialog = inject(MatDialog);
private snack = inject(MatSnackBar);
// ── Filters ──
search = '';
accountTypeFilter: '' | BalanceAccountType = '';
/** Snapshot date for the balance "as on". */
asOnDate = todayIso();
records: BalanceCertificateRecord[] = [];
readonly accountTypeOptions: { value: BalanceAccountType; label: string }[] = [
{ value: 'SAVINGS', label: 'Savings' },
{ value: 'CURRENT', label: 'Current' },
{ value: 'FIXED_DEPOSIT', label: 'Fixed Deposit' },
{ value: 'RD', label: 'Recurring Deposit' },
{ value: 'NRE', label: 'NRE Savings' },
{ value: 'NRO', label: 'NRO Savings' }
];
ngOnInit(): void {
this.records = seedBalanceCertificates();
}
get filteredRecords(): BalanceCertificateRecord[] {
const q = this.search.trim().toLowerCase();
return this.records.filter((r) => {
if (this.accountTypeFilter && r.accountType !== this.accountTypeFilter) return false;
if (!q) return true;
return (
r.accountNo.toLowerCase().includes(q) ||
r.customerName.toLowerCase().includes(q) ||
r.customerId.toLowerCase().includes(q) ||
r.customerPan.toLowerCase().includes(q)
);
});
}
accountTypeLabel(t: BalanceAccountType): string {
return this.accountTypeOptions.find((o) => o.value === t)?.label || t;
}
totalBalance(): number {
return this.filteredRecords.reduce((s, r) => s + r.availableBalance, 0);
}
clearFilters(): void {
this.search = '';
this.accountTypeFilter = '';
this.asOnDate = todayIso();
}
viewCertificate(r: BalanceCertificateRecord): void {
const data: CertificatePreviewDialogData = {
kind: 'balance',
balance: { record: r, asOnDate: this.asOnDate }
};
this.dialog.open(CertificatePreviewDialogComponent, {
width: '880px',
maxWidth: '96vw',
maxHeight: '92vh',
autoFocus: false,
panelClass: 'cert-preview-dialog',
data
});
}
downloadCertificate(r: BalanceCertificateRecord): void {
downloadBalanceCertificatePdf(r, this.asOnDate);
this.snack.open(`Balance Certificate (${r.accountNo}) downloaded.`, 'OK', { duration: 2400 });
}
}
function todayIso(): string {
const d = new Date();
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
// ───────────────────── Mock seed (top-level export) ─────────────────────
export function seedBalanceCertificates(): BalanceCertificateRecord[] {
return [
{
accountNo: 'SB10029384',
customerId: 'CUST112233',
customerName: 'Rohan S. Mehta',
customerPan: 'ABCPM1234E',
customerAddress: 'Flat 902, Skyline Apt, Andheri (E), Mumbai 400069',
branch: 'Mumbai — Andheri (E)',
accountType: 'SAVINGS',
productName: 'Savings — Premium',
openedOn: '2018-04-22',
availableBalance: 482500,
ledgerBalance: 482500,
accruedInterest: 1820,
interestRate: 3.5,
jointHolders: 'Priya Mehta'
},
{
accountNo: 'CA20034112',
customerId: 'CUST334455',
customerName: 'Anita Verma',
customerPan: 'EFGHV5678A',
customerAddress: 'B-12, Connaught Pl, New Delhi 110001',
branch: 'New Delhi — Connaught Place',
accountType: 'CURRENT',
productName: 'Current — Business',
openedOn: '2019-08-11',
availableBalance: 1245300,
ledgerBalance: 1245300,
accruedInterest: 0
},
{
accountNo: 'FD50200077',
customerId: 'CUST556677',
customerName: 'Karthik Rao',
customerPan: 'PQRSR2233Z',
customerAddress: '24, MG Road, Bengaluru 560001',
branch: 'Bengaluru — MG Road',
accountType: 'FIXED_DEPOSIT',
productName: 'FD — Senior 18 months',
openedOn: '2024-02-15',
availableBalance: 500000,
ledgerBalance: 526500,
accruedInterest: 26500,
interestRate: 7.25
},
{
accountNo: 'RD60012345',
customerId: 'CUST667788',
customerName: 'Lakshmi Naidu',
customerPan: 'XYZAN7788Q',
customerAddress: 'Plot 14, Road No. 3, Banjara Hills, Hyderabad 500034',
branch: 'Hyderabad — Banjara Hills',
accountType: 'RD',
productName: 'Recurring Deposit — 24m',
openedOn: '2023-08-18',
availableBalance: 180000,
ledgerBalance: 188750,
accruedInterest: 8750,
interestRate: 6.85
},
{
accountNo: 'NRE70010023',
customerId: 'CUST889900',
customerName: 'Vikram Joshi',
customerPan: 'LMNOJ9988X',
customerAddress: 'Office 3B, Koregaon Park Road, Pune 411001',
branch: 'Pune — Koregaon Park',
accountType: 'NRE',
productName: 'NRE Savings — Premium',
openedOn: '2020-11-05',
availableBalance: 3850000,
ledgerBalance: 3850000,
accruedInterest: 12400,
interestRate: 3.5
},
{
accountNo: 'SB88990022',
customerId: 'CUST990011',
customerName: 'Arjun Banerjee',
customerPan: 'TUVWB4455F',
customerAddress: '42, Park Street, Kolkata 700016',
branch: 'Kolkata — Park Street',
accountType: 'SAVINGS',
productName: 'Savings — Regular',
openedOn: '2021-09-08',
availableBalance: 92300,
ledgerBalance: 92300,
accruedInterest: 450,
interestRate: 3.0
}
];
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,703 @@
<!--
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="cpd-root">
<!-- TOOLBAR -->
<div class="cpd-toolbar no-print">
<div class="cpd-toolbar-l">
<fa-icon [icon]="isInterest ? 'file-invoice' : 'certificate'" class="cpd-toolbar-ic"></fa-icon>
<div>
<div class="cpd-toolbar-t">{{ title }}</div>
<div class="cpd-toolbar-s">{{ refLine }}</div>
</div>
</div>
<div class="cpd-toolbar-r">
<button class="cpd-tb-btn" (click)="printCertificate()" matTooltip="Print or Save as PDF">
<fa-icon icon="print"></fa-icon> Print / PDF
</button>
<button class="cpd-tb-btn" (click)="downloadPdf()" matTooltip="Download as PDF">
<fa-icon icon="download"></fa-icon> Download PDF
</button>
<button class="cpd-tb-btn cpd-tb-x" (click)="close()" matTooltip="Close">
<fa-icon icon="times"></fa-icon>
</button>
</div>
</div>
<!-- CERTIFICATE PAGE -->
<div class="cpd-page" [ngClass]="pageToneClass">
<!-- Bank letterhead -->
<div class="cpd-letterhead">
<div class="cpd-bank-name">{{ bankName }}</div>
<div class="cpd-bank-sub">{{ bankAddress }}</div>
<div class="cpd-bank-sub">{{ bankRegNo }} · {{ bankContact }}</div>
</div>
<!-- Certificate title strip -->
<div class="cpd-title-strip">
<span class="cpd-title-l">{{ title }}</span>
</div>
<!-- Meta strip -->
<div class="cpd-meta-row">
<div>
<div class="cpd-meta-l">Certificate Reference</div>
<div class="cpd-meta-v mono">
@if (isInterest && interest) {
INT-{{ interest.record.accountNo }}-{{ interest.fy.fy | slice: 3 }}
} @else if (isShare && share) {
{{ share.certificateNo }}
}
</div>
</div>
<div>
<div class="cpd-meta-l">Date of Issue</div>
<div class="cpd-meta-v">{{ issuedOn | date: 'd MMMM y' }}</div>
</div>
<div>
<div class="cpd-meta-l">
@if (isInterest) {
Financial Year
} @else if (isShare) {
Folio No.
} @else if (isBalance) {
As-on Date
} @else if (isForm60) {
Trigger
} @else if (isForm15gh) {
Form
} @else if (isHypothecation) {
Status
} @else if (isNoc) {
Status
}
</div>
<div class="cpd-meta-v">
@if (isInterest && interest) {
<span class="cpd-stage-pill">{{ interest.fy.fy }}</span>
} @else if (isShare && share) {
<span class="cpd-stage-pill cpd-stage-pill--amber">{{ share.folioNo }}</span>
} @else if (isBalance && balance) {
<span class="cpd-stage-pill">{{ balance.asOnDate | date: 'd MMM y' }}</span>
} @else if (isForm60 && form60) {
<span class="cpd-stage-pill cpd-stage-pill--amber">No PAN — Form 60</span>
} @else if (isForm15gh && form15gh) {
<span class="cpd-stage-pill cpd-stage-pill--teal">Form {{ form15gh.kind }}</span>
} @else if (isHypothecation && hypothecation) {
<span class="cpd-stage-pill cpd-stage-pill--red">{{ hypothecation.status }}</span>
} @else if (isNoc && noc) {
<span class="cpd-stage-pill cpd-stage-pill--green">
{{ noc.status === 'READY_TO_ISSUE' ? 'Ready to Issue' : noc.status }}
</span>
}
</div>
</div>
</div>
<!-- ── LOAN INTEREST CERTIFICATE ── -->
@if (isInterest && interest) {
<div class="cpd-block">
<div class="cpd-block-l">To,</div>
<div class="cpd-block-v">
<div>
<strong>{{ interest.record.customerName }}</strong>
</div>
<div>
Customer ID: <span class="mono">{{ interest.record.customerId }}</span> · PAN:
<span class="mono">{{ interest.record.customerPan }}</span>
</div>
<div>{{ interest.record.customerAddress }}</div>
</div>
</div>
<p class="cpd-para">
This is to certify that <strong>{{ interest.record.customerName }}</strong> (Customer ID
<strong>{{ interest.record.customerId }}</strong
>) is availing the following loan facility from our bank, and the interest paid by the above customer during the
financial year <strong>{{ interest.fy.fy }}</strong> is as detailed below.
</p>
<table class="cpd-summary">
<tbody>
<tr>
<td>Loan Account No.</td>
<td class="mono">
<strong>{{ interest.record.accountNo }}</strong>
</td>
<td>Branch</td>
<td>{{ interest.record.branch }}</td>
</tr>
<tr>
<td>Loan Type</td>
<td>{{ interest.record.loanType }}</td>
<td>Disbursed On</td>
<td>{{ interest.record.disbursementDate | date: 'd MMM y' }}</td>
</tr>
<tr>
<td>Sanctioned Amount</td>
<td>₹{{ interest.record.sanctionedAmount | number: '1.2-2' }}</td>
<td>Interest Rate</td>
<td>{{ interest.record.interestRate }}% p.a.</td>
</tr>
<tr>
<td>Opening Balance ({{ interest.fy.fy | slice: 0 : 8 }})</td>
<td>₹{{ interest.fy.openingBalance | number: '1.2-2' }}</td>
<td>Closing Balance</td>
<td>₹{{ interest.fy.closingBalance | number: '1.2-2' }}</td>
</tr>
<tr>
<td>Principal Repaid</td>
<td>₹{{ interest.fy.principalPaid | number: '1.2-2' }}</td>
<td>Interest Paid</td>
<td>
<strong class="cpd-amount">₹{{ interest.fy.interestPaid | number: '1.2-2' }}</strong>
</td>
</tr>
<tr class="cpd-summary-total">
<td colspan="3">TOTAL INTEREST PAID DURING {{ interest.fy.fy }}</td>
<td>
<strong>₹{{ interest.fy.interestPaid | number: '1.2-2' }}</strong>
</td>
</tr>
</tbody>
</table>
<p class="cpd-para">
This certificate is issued at the request of the borrower for income-tax / financial record purposes and
reflects the interest credited to the captioned loan account during the said financial year as per our books.
</p>
}
<!-- ── SHARE CERTIFICATE ── -->
@if (isShare && share) {
<div class="cpd-block">
<div class="cpd-block-l">Issued To,</div>
<div class="cpd-block-v">
<div>
<strong>{{ share.memberName }}</strong>
</div>
<div>
Member ID: <span class="mono">{{ share.memberId }}</span> · PAN:
<span class="mono">{{ share.memberPan }}</span>
</div>
<div>{{ share.memberAddress }}</div>
</div>
</div>
<p class="cpd-para cpd-para--centered">
This is to certify that <strong>{{ share.memberName }}</strong> is the registered holder of
<strong>{{ share.shareCount | number }}</strong> ({{ classLabelText() }}) shares of
<strong>₹{{ share.faceValue | number: '1.0-0' }}</strong> each, fully paid up, in <strong>{{ bankName }}</strong
>, subject to the bye-laws of the bank.
</p>
<table class="cpd-summary">
<tbody>
<tr>
<td>Certificate No.</td>
<td class="mono">
<strong>{{ share.certificateNo }}</strong>
</td>
<td>Folio No.</td>
<td class="mono">{{ share.folioNo }}</td>
</tr>
<tr>
<td>Branch</td>
<td>{{ share.branch }}</td>
<td>Member Since</td>
<td>{{ share.joiningDate | date: 'd MMM y' }}</td>
</tr>
<tr>
<td>Share Class</td>
<td>{{ classLabelText() }}</td>
<td>Date of Issue</td>
<td>{{ share.issueDate | date: 'd MMM y' }}</td>
</tr>
<tr>
<td>Share Numbers</td>
<td class="mono">{{ share.shareNoFrom | number }} &ndash; {{ share.shareNoTo | number }}</td>
<td>Dividend Paid (Cumulative)</td>
<td>₹{{ share.dividendYtd | number: '1.0-0' }}</td>
</tr>
<tr class="cpd-summary-total">
<td colspan="3">TOTAL FACE VALUE</td>
<td>
<strong> ₹{{ share.shareCount * share.faceValue | number: '1.2-2' }} </strong>
</td>
</tr>
</tbody>
</table>
<p class="cpd-para">
Given under the common seal of the bank this {{ issuedOn | date: 'd' }} day of {{ issuedOn | date: 'MMMM y' }}.
</p>
}
<!-- ── BALANCE CERTIFICATE ── -->
@if (isBalance && balance) {
<div class="cpd-block">
<div class="cpd-block-l">To Whom It May Concern,</div>
<div class="cpd-block-v">
<div>
<strong>{{ balance.record.customerName }}</strong>
</div>
<div>
Customer ID: <span class="mono">{{ balance.record.customerId }}</span> · PAN:
<span class="mono">{{ balance.record.customerPan }}</span>
</div>
<div>{{ balance.record.customerAddress }}</div>
@if (balance.record.jointHolders) {
<div class="small">Jointly with: {{ balance.record.jointHolders }}</div>
}
</div>
</div>
<p class="cpd-para">
This is to certify that <strong>{{ balance.record.customerName }}</strong> (Customer ID
<strong>{{ balance.record.customerId }}</strong
>) maintains the following account with our bank, and the balance available in the said account as on
<strong>{{ balance.asOnDate | date: 'd MMMM y' }}</strong> is as detailed below.
</p>
<table class="cpd-summary">
<tbody>
<tr>
<td>Account No.</td>
<td class="mono">
<strong>{{ balance.record.accountNo }}</strong>
</td>
<td>Branch</td>
<td>{{ balance.record.branch }}</td>
</tr>
<tr>
<td>Account Type</td>
<td>{{ accountTypeLabelText() }}</td>
<td>Product</td>
<td>{{ balance.record.productName }}</td>
</tr>
<tr>
<td>Account Opened</td>
<td>{{ balance.record.openedOn | date: 'd MMM y' }}</td>
<td>Interest Rate</td>
<td>
@if (balance.record.interestRate) {
{{ balance.record.interestRate }}% p.a.
} @else {
—
}
</td>
</tr>
<tr>
<td>Available Balance</td>
<td>₹{{ balance.record.availableBalance | number: '1.2-2' }}</td>
<td>Accrued Interest</td>
<td>₹{{ balance.record.accruedInterest | number: '1.2-2' }}</td>
</tr>
<tr class="cpd-summary-total">
<td colspan="3">TOTAL LEDGER BALANCE AS ON {{ balance.asOnDate | date: 'd MMM y' }}</td>
<td>
<strong>₹{{ balance.record.ledgerBalance | number: '1.2-2' }}</strong>
</td>
</tr>
</tbody>
</table>
<p class="cpd-para">
This certificate is issued at the request of the customer for record purposes and reflects the balance available
in the said account as per our books as on the date mentioned above.
</p>
}
<!-- ── FORM 60 ── -->
@if (isForm60 && form60) {
<p class="cpd-para">
Declaration under <strong>Section 114B</strong> of the Income-tax Rules, 1962, to be made by a person who does
not have a Permanent Account Number (PAN) and who enters into any transaction specified in rule 114B.
</p>
<table class="cpd-summary">
<tbody>
<tr>
<td>1. Full Name (in Block Letters)</td>
<td colspan="3">
<strong>{{ form60.customerName }}</strong>
</td>
</tr>
<tr>
<td>2. Father's / Spouse's Name</td>
<td colspan="3">{{ form60.fatherName }}</td>
</tr>
<tr>
<td>3. Date of Birth</td>
<td>{{ form60.dob | date: 'd MMM y' }}</td>
<td>4. Customer ID</td>
<td class="mono">{{ form60.customerId }}</td>
</tr>
<tr>
<td>5. Address</td>
<td colspan="3">{{ form60.customerAddress }}</td>
</tr>
<tr>
<td>6. ID Proof Submitted</td>
<td>{{ idProofLabelText() }}</td>
<td>7. ID Document No.</td>
<td class="mono">{{ form60.idProofNumber }}</td>
</tr>
<tr>
<td>8. Nature of Transaction</td>
<td colspan="3">{{ form60.transactionType }}</td>
</tr>
<tr>
<td>9. Transaction Amount</td>
<td>₹{{ form60.transactionAmount | number: '1.2-2' }}</td>
<td>10. Transaction Date</td>
<td>{{ form60.transactionDate | date: 'd MMM y' }}</td>
</tr>
<tr>
<td>11. Estimated Total Income (Current FY)</td>
<td>₹{{ form60.estimatedIncome | number: '1.0-0' }}</td>
<td>12. Agricultural Income</td>
<td>₹{{ form60.agriIncome | number: '1.0-0' }}</td>
</tr>
<tr class="cpd-summary-total">
<td colspan="3">PAN STATUS</td>
<td>
<strong>NOT ALLOTTED — Form 60 declared</strong>
</td>
</tr>
</tbody>
</table>
<p class="cpd-para">
I, <strong>{{ form60.customerName }}</strong
>, do hereby declare that the particulars given above are true and correct to the best of my knowledge and
belief. I further declare that I have applied / not applied for a Permanent Account Number with the Income-tax
department, and that I have neither been assessed nor am liable to be assessed under the Income-tax Act, 1961.
</p>
}
<!-- ── FORM 15G / 15H ── -->
@if (isForm15gh && form15gh) {
<p class="cpd-para">
Declaration under <strong>Section {{ form15gh.kind === '15H' ? '197A(1C)' : '197A(1) / 197A(1A)' }}</strong>
of the Income-tax Act, 1961, for non-deduction of tax at source on certain incomes —
{{
form15gh.kind === '15H'
? 'applicable to resident individuals aged 60 years or above'
: 'applicable to resident individuals (other than companies / firms) below 60 years'
}}.
</p>
<table class="cpd-summary">
<tbody>
<tr>
<td>1. Name of Assessee</td>
<td colspan="3">
<strong>{{ form15gh.record.customerName }}</strong>
</td>
</tr>
<tr>
<td>2. PAN of Assessee</td>
<td class="mono">{{ form15gh.record.customerPan }}</td>
<td>3. Status</td>
<td>Individual</td>
</tr>
<tr>
<td>4. Previous Year</td>
<td>{{ form15gh.record.fy }}</td>
<td>5. Age (years)</td>
<td>{{ form15gh.age }}</td>
</tr>
<tr>
<td>6. Address</td>
<td colspan="3">{{ form15gh.record.customerAddress }}</td>
</tr>
<tr>
<td>7. Branch</td>
<td>{{ form15gh.record.branch }}</td>
<td>8. Customer ID</td>
<td class="mono">{{ form15gh.record.customerId }}</td>
</tr>
<tr>
<td>9. Deposit Principal</td>
<td>₹{{ form15gh.record.depositPrincipal | number: '1.0-0' }}</td>
<td>10. Estimated Interest ({{ form15gh.record.fy }})</td>
<td>
<strong>₹{{ form15gh.record.estimatedInterest | number: '1.0-0' }}</strong>
</td>
</tr>
<tr>
<td>11. Estimated Total Income</td>
<td>₹{{ form15gh.record.estimatedIncome | number: '1.0-0' }}</td>
<td>12. Previously Assessed?</td>
<td>{{ form15gh.record.assessed ? 'Yes' : 'No' }}</td>
</tr>
<tr class="cpd-summary-total">
<td colspan="3">TAX PAYABLE ON ESTIMATED INCOME</td>
<td><strong>NIL</strong></td>
</tr>
</tbody>
</table>
<p class="cpd-para">
I, <strong>{{ form15gh.record.customerName }}</strong
>, do hereby declare that the particulars given above are true and correct, and that the tax on my estimated
total income for the previous year {{ form15gh.record.fy }} will be NIL. I therefore request that no tax be
deducted at source on the interest paid to me by the bank on the captioned deposits during the said previous
year.
</p>
}
<!-- ── HYPOTHECATION CERTIFICATE ── -->
@if (isHypothecation && hypothecation) {
<div class="cpd-block">
<div class="cpd-block-l">Issued To,</div>
<div class="cpd-block-v">
<div>
<strong>{{ hypothecation.customerName }}</strong>
</div>
<div>
Customer ID: <span class="mono">{{ hypothecation.customerId }}</span> · PAN:
<span class="mono">{{ hypothecation.customerPan }}</span>
</div>
<div>{{ hypothecation.customerAddress }}</div>
</div>
</div>
<p class="cpd-para">
This is to certify that the asset described below stands hypothecated to
<strong>{{ bankName }}</strong> as security for the captioned loan facility extended to
<strong>{{ hypothecation.customerName }}</strong
>, and the bank holds the first and exclusive charge over the said asset until the loan is fully discharged.
</p>
<table class="cpd-summary">
<tbody>
<tr>
<td>Certificate No.</td>
<td class="mono">
<strong>{{ hypothecation.certificateNo }}</strong>
</td>
<td>Loan Account No.</td>
<td class="mono">{{ hypothecation.accountNo }}</td>
</tr>
<tr>
<td>Loan Type</td>
<td>{{ hypothecation.loanType }}</td>
<td>Branch</td>
<td>{{ hypothecation.branch }}</td>
</tr>
<tr>
<td>Sanctioned Amount</td>
<td>₹{{ hypothecation.sanctionedAmount | number: '1.0-0' }}</td>
<td>Disbursed On</td>
<td>{{ hypothecation.disbursementDate | date: 'd MMM y' }}</td>
</tr>
<tr>
<td>Asset Type</td>
<td>{{ assetLabelText() }}</td>
<td>Identifier</td>
<td class="mono">{{ hypothecation.assetIdentifier }}</td>
</tr>
<tr>
<td>Asset Description</td>
<td colspan="3">{{ hypothecation.assetDescription }}</td>
</tr>
@if (hypothecation.insuredValue > 0) {
<tr>
<td>Insured Value</td>
<td>₹{{ hypothecation.insuredValue | number: '1.0-0' }}</td>
<td>Insurance Valid Till</td>
<td>{{ hypothecation.insuranceValidTill | date: 'd MMM y' }}</td>
</tr>
}
<tr class="cpd-summary-total">
<td colspan="3">OUTSTANDING PRINCIPAL</td>
<td>
<strong>₹{{ hypothecation.outstandingPrincipal | number: '1.2-2' }}</strong>
</td>
</tr>
</tbody>
</table>
<p class="cpd-para">
The lien on the said asset shall stand released only upon the bank issuing a No-Objection Certificate (NOC)
after the entire outstanding under the captioned facility is paid in full, together with all interest, charges,
and incidental costs.
</p>
}
<!-- ── NO-OBJECTION CERTIFICATE ── -->
@if (isNoc && noc) {
<div class="cpd-block">
<div class="cpd-block-l">Issued To,</div>
<div class="cpd-block-v">
<div>
<strong>{{ noc.customerName }}</strong>
</div>
<div>
Customer ID: <span class="mono">{{ noc.customerId }}</span> · PAN:
<span class="mono">{{ noc.customerPan }}</span>
</div>
<div>{{ noc.customerAddress }}</div>
</div>
</div>
<p class="cpd-para">
This is to certify that the captioned loan account in the name of
<strong>{{ noc.customerName }}</strong>
has been
@if (noc.status === 'ISSUED') {
duly closed and the bank
} @else {
closed and the bank
}
has <strong>NO OBJECTION</strong> to the release of the lien hereinafter recorded over the asset described
below. All dues under the said facility, including principal, interest, and incidental charges, have been
received by the bank as on the closure date.
</p>
<table class="cpd-summary">
<tbody>
<tr>
<td>NOC No.</td>
<td class="mono">
<strong>{{ noc.nocNo }}</strong>
</td>
<td>Loan Account No.</td>
<td class="mono">{{ noc.accountNo }}</td>
</tr>
<tr>
<td>Loan Type</td>
<td>{{ nocLoanTypeLabel() }}</td>
<td>Branch</td>
<td>{{ noc.branch }}</td>
</tr>
<tr>
<td>Sanctioned Amount</td>
<td>₹{{ noc.sanctionedAmount | number: '1.0-0' }}</td>
<td>Disbursed On</td>
<td>{{ noc.disbursementDate | date: 'd MMM y' }}</td>
</tr>
@if (noc.closureDate) {
<tr>
<td>Account Closed On</td>
<td>
<strong>{{ noc.closureDate | date: 'd MMM y' }}</strong>
</td>
<td>Total Paid</td>
<td>₹{{ noc.totalPaid | number: '1.2-2' }}</td>
</tr>
}
@if (noc.assetType !== 'NONE') {
<tr>
<td>Asset Type</td>
<td>{{ nocAssetLabel() }}</td>
<td>Identifier</td>
<td class="mono">{{ noc.assetIdentifier }}</td>
</tr>
<tr>
<td>Asset Description</td>
<td colspan="3">{{ noc.assetDescription }}</td>
</tr>
}
@if (noc.forwardTo) {
<tr>
<td>Forward To</td>
<td colspan="3">{{ noc.forwardTo }}</td>
</tr>
}
<tr class="cpd-summary-total">
<td colspan="3">
@if (noc.assetType === 'NONE') {
ACCOUNT CLOSED — NO DUES OUTSTANDING
} @else {
LIEN RELEASED — NO OBJECTION TO TRANSFER
}
</td>
<td><strong>NIL</strong></td>
</tr>
</tbody>
</table>
<p class="cpd-para">
@if (noc.assetType === 'VEHICLE') {
The Regional Transport Authority is hereby requested to delete the hypothecation endorsement of the bank from
the registration certificate of the captioned vehicle.
} @else if (noc.assetType === 'PROPERTY') {
The Sub-Registrar's office is hereby requested to delete the equitable / registered mortgage of the bank from
the property records of the captioned property.
} @else if (noc.assetType === 'GOLD') {
The pledged gold ornaments described above may be released to the customer / authorised representative after
due verification at the branch counter.
} @else if (noc.assetType !== 'NONE') {
The bank's lien over the captioned asset stands released with effect from the closure date, and the customer
is free to deal with the said asset.
} @else {
This certificate is issued at the request of the customer for record purposes and confirms that no dues are
outstanding under the captioned facility.
}
</p>
@if (noc.status === 'ISSUED' && noc.issuedOn) {
<p class="cpd-para">
<strong>Issued on:</strong> {{ noc.issuedOn | date: 'd MMM y' }}
@if (noc.issuedBy) {
<strong>· By:</strong> {{ noc.issuedBy }}
}
</p>
}
}
<!-- Common sign-off -->
<div class="cpd-signoff">
<div class="cpd-sign-block">
<div class="cpd-sig-line"></div>
<div class="cpd-sig-l">Authorised Signatory</div>
@if (isInterest && interest) {
<div class="cpd-sig-sub">{{ interest.record.branch }}</div>
}
@if (isShare && share) {
<div class="cpd-sig-sub">{{ share.branch }}</div>
}
@if (isBalance && balance) {
<div class="cpd-sig-sub">{{ balance.record.branch }}</div>
}
@if (isForm60 && form60) {
<div class="cpd-sig-sub">{{ form60.branch }}</div>
}
@if (isForm15gh && form15gh) {
<div class="cpd-sig-sub">{{ form15gh.record.branch }}</div>
}
@if (isHypothecation && hypothecation) {
<div class="cpd-sig-sub">{{ hypothecation.branch }}</div>
}
@if (isNoc && noc) {
<div class="cpd-sig-sub">{{ noc.branch }}</div>
}
</div>
<div class="cpd-seal">
<div class="cpd-seal-ring">
<div class="cpd-seal-inner">
<div class="cpd-seal-bank">NEXORA</div>
<div class="cpd-seal-line"></div>
<div class="cpd-seal-sub">CO-OPERATIVE BANK</div>
</div>
</div>
</div>
</div>
<!-- Footer -->
<div class="cpd-footer">
<span>
Issued by {{ bankName }} on {{ issuedOn | date: 'd MMM y' }}. Computer generated — does not require physical
signature for record purposes.
</span>
</div>
</div>
</div>
@@ -0,0 +1,607 @@
/**
* 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 {
--cpd-blue: #1d4ed8;
--cpd-blue-d: #1e40af;
--cpd-blue-l: #eff6ff;
--cpd-blue-tint: #dbeafe;
--cpd-amber: #d97706;
--cpd-amber-l: #fef3c7;
--cpd-amber-tint: #fde68a;
--cpd-red: #b91c1c;
--cpd-red-l: #fee2e2;
--cpd-red-tint: #fecaca;
--cpd-green: #15803d;
--cpd-green-l: #dcfce7;
--cpd-purple: #6d28d9;
--cpd-g50: #f8fafc;
--cpd-g100: #f1f5f9;
--cpd-g200: #e2e8f0;
--cpd-g300: #cbd5e1;
--cpd-g500: #64748b;
--cpd-g700: #334155;
--cpd-g900: #0f172a;
display: block;
}
.cpd-root {
background: var(--cpd-g100);
width: 100%;
max-height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ───────── Toolbar (hidden on print) ───────── */
.cpd-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding: 12px 16px;
background: #fff;
border-bottom: 1px solid var(--cpd-g200);
flex-shrink: 0;
}
.cpd-toolbar-l {
display: flex;
align-items: center;
gap: 12px;
}
.cpd-toolbar-ic {
width: 36px;
height: 36px;
border-radius: 10px;
background: var(--cpd-blue-d);
color: #fff;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 15px;
}
.cpd-toolbar-t {
font-size: 14px;
font-weight: 800;
color: var(--cpd-g900);
}
.cpd-toolbar-s {
font-size: 11.5px;
color: var(--cpd-g500);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.cpd-toolbar-r {
display: inline-flex;
gap: 8px;
}
.cpd-tb-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 12px;
border: 1px solid var(--cpd-g200);
background: #fff;
color: var(--cpd-g700);
font-size: 12.5px;
font-weight: 700;
border-radius: 8px;
cursor: pointer;
transition: all 0.15s ease;
fa-icon {
font-size: 12px;
color: var(--cpd-blue);
}
&:hover {
background: var(--cpd-blue-l);
border-color: var(--cpd-blue-tint);
color: var(--cpd-blue-d);
}
&.cpd-tb-x {
width: 34px;
padding: 0;
justify-content: center;
&:hover {
background: var(--cpd-red-l);
color: var(--cpd-red);
border-color: var(--cpd-red-tint);
fa-icon {
color: var(--cpd-red);
}
}
}
}
/* ───────── Certificate page (A4 feel) ───────── */
.cpd-page {
background: #fff;
border: 1px solid var(--cpd-g200);
padding: 38px 48px 24px;
margin: 18px auto;
width: min(720px, 94%);
border-top: 6px solid var(--cpd-blue-d);
font-family: Georgia, 'Times New Roman', Times, serif;
color: var(--cpd-g900);
line-height: 1.6;
overflow: auto;
position: relative;
&.cpd-page--share,
&.cpd-page--amber {
border-top-color: var(--cpd-amber);
}
&.cpd-page--teal {
border-top-color: #0d9488;
}
&.cpd-page--red {
border-top-color: var(--cpd-red);
}
&.cpd-page--green {
border-top-color: var(--cpd-green);
}
/* Subtle decorative border inside the page edge to give the cert a "framed" feel. */
&::before {
content: '';
position: absolute;
inset: 18px;
border: 1px solid var(--cpd-g200);
pointer-events: none;
border-radius: 4px;
}
&.cpd-page--share::before,
&.cpd-page--amber::before {
border-color: var(--cpd-amber-tint);
}
&.cpd-page--teal::before {
border-color: #99f6e4;
}
&.cpd-page--red::before {
border-color: var(--cpd-red-tint);
}
&.cpd-page--green::before {
border-color: #bbf7d0;
}
}
/* ───────── Letterhead ───────── */
.cpd-letterhead {
text-align: center;
padding-bottom: 12px;
margin-bottom: 14px;
border-bottom: 1px solid var(--cpd-g200);
}
.cpd-bank-name {
font-size: 19px;
font-weight: 800;
letter-spacing: 0.02em;
color: var(--cpd-blue-d);
.cpd-page--share &,
.cpd-page--amber & {
color: var(--cpd-amber);
}
.cpd-page--teal & {
color: #0d9488;
}
.cpd-page--red & {
color: var(--cpd-red);
}
.cpd-page--green & {
color: var(--cpd-green);
}
}
.cpd-bank-sub {
font-size: 11.5px;
color: var(--cpd-g500);
margin-top: 2px;
font-family:
ui-sans-serif,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
sans-serif;
}
/* ───────── Title strip ───────── */
.cpd-title-strip {
text-align: center;
margin: 6px 0 14px;
font-family:
ui-sans-serif,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
sans-serif;
}
.cpd-title-l {
display: inline-block;
padding: 5px 18px;
border-radius: 999px;
font-size: 11.5px;
font-weight: 800;
letter-spacing: 0.15em;
text-transform: uppercase;
background: var(--cpd-blue-l);
color: var(--cpd-blue-d);
border: 1px solid var(--cpd-blue-tint);
.cpd-page--share &,
.cpd-page--amber & {
background: var(--cpd-amber-l);
color: var(--cpd-amber);
border-color: var(--cpd-amber-tint);
}
.cpd-page--teal & {
background: #ccfbf1;
color: #0d9488;
border-color: #99f6e4;
}
.cpd-page--red & {
background: var(--cpd-red-l);
color: var(--cpd-red);
border-color: var(--cpd-red-tint);
}
.cpd-page--green & {
background: var(--cpd-green-l);
color: var(--cpd-green);
border-color: #bbf7d0;
}
}
.cpd-stage-pill--teal {
background: #ccfbf1;
color: #0d9488;
}
.cpd-stage-pill--red {
background: var(--cpd-red-l);
color: var(--cpd-red);
}
.cpd-stage-pill--green {
background: var(--cpd-green-l);
color: var(--cpd-green);
}
/* ───────── Meta row ───────── */
.cpd-meta-row {
display: flex;
justify-content: space-between;
gap: 16px;
font-family:
ui-sans-serif,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
sans-serif;
font-size: 12px;
margin-bottom: 16px;
padding-bottom: 14px;
border-bottom: 1px dashed var(--cpd-g200);
> div {
display: flex;
flex-direction: column;
gap: 2px;
}
}
.cpd-meta-l {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--cpd-g500);
}
.cpd-meta-v {
font-size: 12.5px;
color: var(--cpd-g900);
font-weight: 700;
}
.cpd-stage-pill {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 999px;
font-size: 10.5px;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
background: var(--cpd-blue-l);
color: var(--cpd-blue-d);
&--amber {
background: var(--cpd-amber-l);
color: var(--cpd-amber);
}
}
/* ───────── Addressee + body ───────── */
.cpd-block {
display: flex;
gap: 8px;
font-family:
ui-sans-serif,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
sans-serif;
font-size: 13px;
margin-bottom: 14px;
}
.cpd-block-l {
font-weight: 700;
color: var(--cpd-g700);
width: 70px;
flex-shrink: 0;
}
.cpd-block-v {
flex: 1;
color: var(--cpd-g900);
line-height: 1.5;
}
.cpd-para {
margin: 12px 0;
font-size: 13px;
text-align: justify;
color: var(--cpd-g900);
&--centered {
text-align: center;
font-size: 13.5px;
padding: 6px 12px;
background: var(--cpd-amber-l);
border-left: 4px solid var(--cpd-amber);
border-radius: 0 6px 6px 0;
}
}
.cpd-amount {
color: var(--cpd-blue-d);
font-variant-numeric: tabular-nums;
}
/* ───────── Summary table ───────── */
.cpd-summary {
width: 100%;
margin: 14px 0;
border-collapse: collapse;
font-family:
ui-sans-serif,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
sans-serif;
font-size: 12px;
td {
padding: 8px 10px;
border: 1px solid var(--cpd-g200);
vertical-align: top;
color: var(--cpd-g900);
}
td:nth-child(odd) {
background: var(--cpd-g50);
color: var(--cpd-g500);
font-weight: 700;
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: 0.04em;
width: 22%;
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.cpd-summary-total td {
background: var(--cpd-blue-l);
color: var(--cpd-blue-d);
font-weight: 800;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.04em;
&:last-child {
text-align: right;
font-variant-numeric: tabular-nums;
}
}
.cpd-page--share & .cpd-summary-total td {
background: var(--cpd-amber-l);
color: var(--cpd-amber);
}
}
/* ───────── Sign-off + bank seal ───────── */
.cpd-signoff {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 24px;
margin-top: 26px;
}
.cpd-sign-block {
font-family:
ui-sans-serif,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
sans-serif;
}
.cpd-sig-line {
width: 220px;
border-top: 1px solid var(--cpd-g500);
margin-bottom: 5px;
}
.cpd-sig-l {
font-size: 12px;
font-weight: 700;
color: var(--cpd-g900);
}
.cpd-sig-sub {
font-size: 11px;
color: var(--cpd-g500);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
/* Decorative round "common seal" for the share certificate (also looks good on
the interest certificate). Pure CSS, prints fine. */
.cpd-seal-ring {
width: 96px;
height: 96px;
border-radius: 50%;
border: 2px solid var(--cpd-amber);
display: flex;
align-items: center;
justify-content: center;
position: relative;
background: #fff;
.cpd-page--interest & {
border-color: var(--cpd-blue);
}
&::after {
content: '';
position: absolute;
inset: 5px;
border-radius: 50%;
border: 1px dashed var(--cpd-amber);
.cpd-page--interest & {
border-color: var(--cpd-blue);
}
}
}
.cpd-seal-inner {
text-align: center;
position: relative;
z-index: 1;
}
.cpd-seal-bank {
font-size: 12px;
font-weight: 800;
letter-spacing: 0.18em;
color: var(--cpd-amber);
.cpd-page--interest & {
color: var(--cpd-blue);
}
}
.cpd-seal-line {
width: 36px;
border-top: 1px solid currentcolor;
margin: 3px auto;
color: var(--cpd-amber);
.cpd-page--interest & {
color: var(--cpd-blue);
}
}
.cpd-seal-sub {
font-size: 7.5px;
font-weight: 800;
letter-spacing: 0.12em;
color: var(--cpd-g500);
}
/* ───────── Footer ───────── */
.cpd-footer {
margin-top: 24px;
padding-top: 10px;
border-top: 1px dashed var(--cpd-g200);
font-family:
ui-sans-serif,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
sans-serif;
font-size: 10.5px;
color: var(--cpd-g500);
text-align: center;
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
/* ───────── Print — only the page, no toolbar / dialog chrome ───────── */
@media print {
:host {
background: #fff;
}
.no-print {
display: none !important;
}
.cpd-root {
background: #fff;
max-height: none;
overflow: visible;
height: auto;
}
.cpd-page {
border: 0;
margin: 0;
padding: 24px 32px;
width: 100%;
box-shadow: none;
border-top: 4px solid var(--cpd-blue-d);
&.cpd-page--share {
border-top-color: var(--cpd-amber);
}
}
}
@@ -0,0 +1,322 @@
/**
* 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 { FyInterestBreakup, LoanInterestRecord } from '../loan-interest/loan-interest.component';
import { ShareCertificateRecord } from '../share-certificate/share-certificate.component';
import { BalanceCertificateRecord } from '../balance-certificate/balance-certificate.component';
import { Form60Record } from '../form-60/form-60.component';
import { Form15Kind, Form15ghRecord } from '../form-15gh/form-15gh.component';
import { HypothecationRecord } from '../hypothecation/hypothecation.component';
import { NocRecord } from '../noc/noc.component';
import {
downloadBalanceCertificatePdf,
downloadForm15ghPdf,
downloadForm60Pdf,
downloadHypothecationPdf,
downloadLoanInterestPdf,
downloadNocPdf,
downloadShareCertificatePdf
} from '../certificate-pdf';
export interface InterestPreviewPayload {
record: LoanInterestRecord;
fy: FyInterestBreakup;
}
export interface BalancePreviewPayload {
record: BalanceCertificateRecord;
asOnDate: string;
}
export interface Form15ghPreviewPayload {
record: Form15ghRecord;
kind: Form15Kind;
age: number;
}
export type CertificateKind = 'interest' | 'share' | 'balance' | 'form60' | 'form15gh' | 'hypothecation' | 'noc';
export interface CertificatePreviewDialogData {
kind: CertificateKind;
loanInterest?: InterestPreviewPayload;
shareCertificate?: ShareCertificateRecord;
balance?: BalancePreviewPayload;
form60?: Form60Record;
form15gh?: Form15ghPreviewPayload;
hypothecation?: HypothecationRecord;
noc?: NocRecord;
}
@Component({
selector: 'mifosx-certificate-preview-dialog',
templateUrl: './certificate-preview-dialog.component.html',
styleUrls: ['./certificate-preview-dialog.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
CommonModule,
FaIconComponent,
MatTooltip
]
})
export class CertificatePreviewDialogComponent {
private dialogRef = inject(MatDialogRef<CertificatePreviewDialogComponent>);
data: CertificatePreviewDialogData = inject(MAT_DIALOG_DATA);
readonly bankName = 'Nexora Co-operative Bank Ltd.';
readonly bankAddress = 'Head Office, 4th Floor, Nexora Tower, Mumbai 400051';
readonly bankRegNo = 'RBI Lic. No. NX-2018/MH/482';
readonly bankContact = 'info@nexorabank.in · 022 6789 0000';
get isInterest(): boolean {
return this.data.kind === 'interest';
}
get isShare(): boolean {
return this.data.kind === 'share';
}
get isBalance(): boolean {
return this.data.kind === 'balance';
}
get isForm60(): boolean {
return this.data.kind === 'form60';
}
get isForm15gh(): boolean {
return this.data.kind === 'form15gh';
}
get isHypothecation(): boolean {
return this.data.kind === 'hypothecation';
}
get isNoc(): boolean {
return this.data.kind === 'noc';
}
get interest(): InterestPreviewPayload | undefined {
return this.data.loanInterest;
}
get share(): ShareCertificateRecord | undefined {
return this.data.shareCertificate;
}
get balance(): BalancePreviewPayload | undefined {
return this.data.balance;
}
get form60(): Form60Record | undefined {
return this.data.form60;
}
get form15gh(): Form15ghPreviewPayload | undefined {
return this.data.form15gh;
}
get hypothecation(): HypothecationRecord | undefined {
return this.data.hypothecation;
}
get noc(): NocRecord | undefined {
return this.data.noc;
}
/** Tone class on `.cpd-page` — drives the accent stripe + seal + pill colour. */
get pageToneClass(): string {
switch (this.data.kind) {
case 'share':
case 'form60':
return 'cpd-page--amber';
case 'form15gh':
return 'cpd-page--teal';
case 'hypothecation':
return 'cpd-page--red';
case 'noc':
return 'cpd-page--green';
case 'balance':
case 'interest':
default:
return '';
}
}
get title(): string {
switch (this.data.kind) {
case 'interest':
return 'Interest-Paid Certificate';
case 'share':
return 'Share Certificate';
case 'balance':
return 'Balance Certificate';
case 'form60':
return 'Form 60 Declaration';
case 'form15gh':
return this.form15gh?.kind === '15H' ? 'Form 15H Declaration' : 'Form 15G Declaration';
case 'hypothecation':
return 'Hypothecation Certificate';
case 'noc':
return 'No-Objection Certificate';
}
}
get refLine(): string {
if (this.isInterest && this.interest) {
return `${this.interest.record.accountNo} · ${this.interest.fy.fy}`;
}
if (this.isShare && this.share) {
return `${this.share.certificateNo} · Folio ${this.share.folioNo}`;
}
if (this.isBalance && this.balance) {
return `${this.balance.record.accountNo} · as on ${this.balance.asOnDate}`;
}
if (this.isForm60 && this.form60) {
return `${this.form60.customerId} · ${this.form60.transactionType}`;
}
if (this.isForm15gh && this.form15gh) {
return `${this.form15gh.record.customerId} · ${this.form15gh.kind} · ${this.form15gh.record.fy}`;
}
if (this.isHypothecation && this.hypothecation) {
return `${this.hypothecation.certificateNo} · ${this.hypothecation.accountNo}`;
}
if (this.isNoc && this.noc) {
return `${this.noc.nocNo} · ${this.noc.accountNo}`;
}
return '';
}
get issuedOn(): string {
return new Date().toISOString().slice(0, 10);
}
close(): void {
this.dialogRef.close();
}
printCertificate(): void {
window.print();
}
downloadPdf(): void {
if (this.isInterest && this.interest) {
downloadLoanInterestPdf(this.interest.record, this.interest.fy);
} else if (this.isShare && this.share) {
downloadShareCertificatePdf(this.share);
} else if (this.isBalance && this.balance) {
downloadBalanceCertificatePdf(this.balance.record, this.balance.asOnDate);
} else if (this.isForm60 && this.form60) {
downloadForm60Pdf(this.form60);
} else if (this.isForm15gh && this.form15gh) {
downloadForm15ghPdf(this.form15gh.record, this.form15gh.kind);
} else if (this.isHypothecation && this.hypothecation) {
downloadHypothecationPdf(this.hypothecation);
} else if (this.isNoc && this.noc) {
downloadNocPdf(this.noc);
}
}
nocLoanTypeLabel(): string {
const t = this.noc?.loanType;
switch (t) {
case 'AUTO':
return 'Auto Loan';
case 'HOUSING':
return 'Housing Loan';
case 'PERSONAL':
return 'Personal Loan';
case 'EDUCATION':
return 'Education Loan';
case 'GOLD':
return 'Gold Loan';
case 'BUSINESS':
return 'Business Loan';
case 'CC':
return 'Cash Credit';
case 'OD':
return 'Overdraft';
default:
return '';
}
}
nocAssetLabel(): string {
const t = this.noc?.assetType;
switch (t) {
case 'VEHICLE':
return 'Vehicle';
case 'PROPERTY':
return 'Property';
case 'GOLD':
return 'Gold / Bullion';
case 'EQUIPMENT':
return 'Equipment';
case 'STOCK_DEBTORS':
return 'Stock / Debtors';
case 'NONE':
return 'Unsecured';
default:
return '';
}
}
classLabelText(): string {
return this.share?.shareClass === 'PREFERENCE' ? 'Preference' : 'Ordinary';
}
accountTypeLabelText(): string {
const t = this.balance?.record.accountType;
switch (t) {
case 'SAVINGS':
return 'Savings';
case 'CURRENT':
return 'Current';
case 'FIXED_DEPOSIT':
return 'Fixed Deposit';
case 'RD':
return 'Recurring Deposit';
case 'NRE':
return 'NRE Savings';
case 'NRO':
return 'NRO Savings';
default:
return '';
}
}
idProofLabelText(): string {
const t = this.form60?.idProofType;
switch (t) {
case 'AADHAAR':
return 'Aadhaar Card';
case 'VOTER_ID':
return 'Voter Identity Card';
case 'DRIVING_LICENSE':
return 'Driving License';
case 'PASSPORT':
return 'Passport';
case 'RATION':
return 'Ration Card';
default:
return '';
}
}
assetLabelText(): string {
const t = this.hypothecation?.assetType;
switch (t) {
case 'VEHICLE':
return 'Vehicle';
case 'PLANT_MACHINERY':
return 'Plant & Machinery';
case 'STOCK_DEBTORS':
return 'Stock / Debtors';
case 'GOLD':
return 'Gold / Bullion';
case 'EQUIPMENT':
return 'Equipment';
default:
return '';
}
}
}
@@ -0,0 +1,470 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/*
* Shared list-page styling for the Letter-Mgmt certificate pages
* (Loan Interest, Share Certificate, etc.). Mirrors the visual language of
* the notice list (header + filters + table) so the module feels cohesive.
*/
:host {
--cert-blue: #1d4ed8;
--cert-blue-d: #1e40af;
--cert-blue-l: #eff6ff;
--cert-blue-tint: #dbeafe;
--cert-teal: #0d9488;
--cert-teal-l: #ccfbf1;
--cert-green: #15803d;
--cert-green-l: #dcfce7;
--cert-amber: #d97706;
--cert-amber-l: #fef3c7;
--cert-red: #b91c1c;
--cert-red-l: #fee2e2;
--cert-red-tint: #fecaca;
--cert-purple: #6d28d9;
--cert-purple-l: #ede9fe;
--cert-g50: #f8fafc;
--cert-g100: #f1f5f9;
--cert-g200: #e2e8f0;
--cert-g300: #cbd5e1;
--cert-g500: #64748b;
--cert-g700: #334155;
--cert-g900: #0f172a;
display: block;
}
.cert-root {
padding: 20px 24px 32px;
max-width: 1340px;
margin: 0 auto;
}
/* ───────── Header banner ───────── */
.cert-head {
display: flex;
align-items: center;
gap: 16px;
padding: 18px 22px;
border-radius: 14px;
background: linear-gradient(135deg, var(--cert-blue-l) 0%, #fff 65%);
border: 1px solid var(--cert-blue-tint);
margin-bottom: 16px;
}
.cert-head-l {
display: flex;
align-items: center;
gap: 14px;
}
.cert-head-ic {
width: 48px;
height: 48px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 14px;
background: var(--cert-blue-d);
color: #fff;
font-size: 20px;
}
.cert-head-t {
margin: 0 0 3px;
font-size: 17px;
font-weight: 800;
color: var(--cert-g900);
}
.cert-head-s {
font-size: 12.5px;
color: var(--cert-g500);
line-height: 1.5;
}
/* ───────── KPI strip ───────── */
.cert-kpis {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
margin-bottom: 14px;
@media (width <= 900px) {
grid-template-columns: repeat(2, 1fr);
}
}
.cert-kpi {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
border-radius: 12px;
background: #fff;
border: 1px solid var(--cert-g200);
&--blue {
background: var(--cert-blue-l);
border-color: var(--cert-blue-tint);
}
&--green {
background: var(--cert-green-l);
border-color: #bbf7d0;
}
&--teal {
background: var(--cert-teal-l);
border-color: #99f6e4;
}
&--amber {
background: var(--cert-amber-l);
border-color: #fde68a;
}
}
.cert-kpi-ic {
width: 36px;
height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 10px;
background: rgb(255 255 255 / 70%);
font-size: 16px;
.cert-kpi--blue & {
color: var(--cert-blue);
}
.cert-kpi--green & {
color: var(--cert-green);
}
.cert-kpi--teal & {
color: var(--cert-teal);
}
.cert-kpi--amber & {
color: var(--cert-amber);
}
}
.cert-kpi-n {
font-size: 17px;
font-weight: 800;
color: var(--cert-g900);
font-variant-numeric: tabular-nums;
line-height: 1.15;
}
.cert-kpi-l {
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--cert-g500);
margin-top: 2px;
}
/* ───────── Filters ───────── */
.cert-filters {
display: flex;
align-items: flex-end;
gap: 12px;
padding: 14px;
background: #fff;
border: 1px solid var(--cert-g200);
border-radius: 12px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.cert-filter {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1 1 0;
min-width: 220px;
position: relative;
label {
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--cert-g500);
}
input,
select {
box-sizing: border-box;
width: 100%;
height: 36px;
padding: 0 12px;
border: 1px solid var(--cert-g200);
border-radius: 8px;
background: #fff;
font-size: 13px;
color: var(--cert-g900);
outline: none;
transition: border-color 0.15s ease;
appearance: none;
&:focus {
border-color: var(--cert-blue);
}
}
select {
padding-right: 34px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8' fill='none' stroke='%2364748b' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='1 2 6 7 11 2'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
background-size: 12px 8px;
cursor: pointer;
}
}
.cert-input-ic {
position: relative;
width: 100%;
fa-icon {
position: absolute;
left: 11px;
top: 50%;
transform: translateY(-50%);
color: var(--cert-g500);
font-size: 12px;
pointer-events: none;
z-index: 1;
}
input {
padding-left: 32px;
}
}
.cert-clear {
height: 36px;
padding: 0 14px;
border: 1px solid var(--cert-g200);
border-radius: 8px;
background: #fff;
color: var(--cert-g700);
font-size: 12px;
font-weight: 700;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
&:hover {
background: var(--cert-red-l);
color: var(--cert-red);
border-color: var(--cert-red-tint);
}
}
/* ───────── Table ───────── */
.cert-card {
background: #fff;
border: 1px solid var(--cert-g200);
border-radius: 12px;
overflow: hidden;
}
.cert-table-wrap {
overflow-x: auto;
}
.cert-table {
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
th {
text-align: left;
padding: 12px;
background: var(--cert-g50);
color: var(--cert-g500);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 10.5px;
border-bottom: 1px solid var(--cert-g200);
white-space: nowrap;
}
td {
padding: 12px;
border-bottom: 1px solid var(--cert-g100);
vertical-align: top;
color: var(--cert-g900);
}
tr:last-child td {
border-bottom: 0;
}
.num {
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.small {
font-size: 11.5px;
}
.muted {
color: var(--cert-g500);
}
.col-actions {
width: 88px;
text-align: right;
white-space: nowrap;
}
}
.cert-cust-name {
font-weight: 700;
color: var(--cert-g900);
}
.cert-interest {
color: var(--cert-green);
font-weight: 800;
}
/* Pills */
.cert-pill {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.02em;
background: var(--cert-g100);
color: var(--cert-g700);
white-space: nowrap;
&--type {
background: var(--cert-purple-l);
color: var(--cert-purple);
}
&--green {
background: var(--cert-green-l);
color: var(--cert-green);
}
&--blue {
background: var(--cert-blue-l);
color: var(--cert-blue-d);
}
&--amber {
background: var(--cert-amber-l);
color: var(--cert-amber);
}
}
.cert-rate-pill {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
background: var(--cert-blue-l);
color: var(--cert-blue-d);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
/* Row action icon buttons */
.cert-row-actions {
display: inline-flex;
gap: 4px;
}
.cert-icon-btn {
width: 30px;
height: 30px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--cert-g200);
background: #fff;
color: var(--cert-g500);
border-radius: 8px;
cursor: pointer;
font-size: 11.5px;
transition:
background 0.15s,
color 0.15s,
border-color 0.15s;
&:hover:not(:disabled) {
background: var(--cert-blue-l);
color: var(--cert-blue-d);
border-color: var(--cert-blue-tint);
}
&:disabled {
opacity: 0.45;
cursor: not-allowed;
}
&.primary {
background: var(--cert-blue-d);
color: #fff;
border-color: var(--cert-blue-d);
&:hover:not(:disabled) {
background: var(--cert-blue);
border-color: var(--cert-blue);
}
}
}
.cert-empty {
text-align: center;
padding: 28px 16px;
color: var(--cert-g500);
font-size: 13px;
fa-icon {
margin-right: 6px;
color: var(--cert-blue);
}
}
/* Share-certificate specific cells (used by share-certificate.component.html) */
.cert-shares {
font-weight: 800;
color: var(--cert-blue-d);
font-variant-numeric: tabular-nums;
}
.cert-folio {
font-weight: 700;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
color: var(--cert-g900);
}
@@ -0,0 +1,171 @@
<!--
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="cert-root">
<div class="cert-head">
<div class="cert-head-l">
<fa-icon icon="file-signature" class="cert-head-ic"></fa-icon>
<div>
<h2 class="cert-head-t">Form 15G &amp; 15H</h2>
<div class="cert-head-s">
TDS-waiver declarations for FD / RD interest income —
<strong>15G</strong> for individuals below 60 years, <strong>15H</strong> for senior citizens (60+).
</div>
</div>
</div>
</div>
<!-- KPI strip -->
<div class="cert-kpis">
<div class="cert-kpi cert-kpi--blue">
<fa-icon icon="user" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ count15G() | number }}</div>
<div class="cert-kpi-l">Form 15G (under 60)</div>
</div>
</div>
<div class="cert-kpi cert-kpi--amber">
<fa-icon icon="user-tie" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ count15H() | number }}</div>
<div class="cert-kpi-l">Form 15H (60+)</div>
</div>
</div>
<div class="cert-kpi cert-kpi--green">
<fa-icon icon="check-circle" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ countFiled() | number }}</div>
<div class="cert-kpi-l">Filed this FY</div>
</div>
</div>
<div class="cert-kpi cert-kpi--teal">
<fa-icon icon="coins" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">₹{{ totalInterest() | number: '1.0-0' }}</div>
<div class="cert-kpi-l">Projected Interest</div>
</div>
</div>
</div>
<!-- Filters -->
<div class="cert-filters">
<div class="cert-filter">
<label>Customer / Customer ID / PAN</label>
<div class="cert-input-ic">
<fa-icon icon="search"></fa-icon>
<input type="text" [(ngModel)]="search" placeholder="e.g. CUST556677 or customer name" />
</div>
</div>
<div class="cert-filter">
<label>Form</label>
<select [(ngModel)]="kindFilter">
<option value="">Both forms</option>
<option value="15G">15G — Under 60</option>
<option value="15H">15H — Senior citizens (60+)</option>
</select>
</div>
<div class="cert-filter">
<label>Status</label>
<select [(ngModel)]="statusFilter">
<option value="">All statuses</option>
<option value="FILED">Filed</option>
<option value="PENDING">Pending</option>
</select>
</div>
@if (search || kindFilter || statusFilter) {
<button type="button" class="cert-clear" (click)="clearFilters()"><fa-icon icon="times"></fa-icon> Clear</button>
}
</div>
<!-- Table -->
<div class="cert-card">
<div class="cert-table-wrap">
<table class="cert-table">
<thead>
<tr>
<th>Customer · PAN</th>
<th>Form</th>
<th>Age</th>
<th>Branch</th>
<th class="num">Deposit Principal</th>
<th class="num">Est. Interest ({{ records[0]?.fy }})</th>
<th>Status</th>
<th class="col-actions">Actions</th>
</tr>
</thead>
<tbody>
@for (r of filteredRecords; track r.customerId) {
<tr>
<td>
<div class="cert-cust-name">{{ r.customerName }}</div>
<div class="mono small muted">{{ r.customerId }} · PAN {{ r.customerPan }}</div>
<div class="small muted">DOB {{ r.dob | date: 'd MMM y' }}</div>
</td>
<td>
<span
class="cert-pill"
[class.cert-pill--blue]="formKind(r) === '15G'"
[class.cert-pill--amber]="formKind(r) === '15H'"
>
Form {{ formKind(r) }}
</span>
</td>
<td class="small">{{ age(r.dob) }} yrs</td>
<td class="small">{{ r.branch }}</td>
<td class="num">₹{{ r.depositPrincipal | number: '1.0-0' }}</td>
<td class="num">
<strong class="cert-interest">₹{{ r.estimatedInterest | number: '1.0-0' }}</strong>
</td>
<td>
@if (r.status === 'FILED') {
<span class="cert-pill cert-pill--green"> <fa-icon icon="check-circle"></fa-icon> Filed </span>
@if (r.filedOn) {
<div class="mono small muted">{{ r.filedOn | date: 'd MMM y' }}</div>
}
} @else {
<span class="cert-pill cert-pill--amber"> <fa-icon icon="hourglass-half"></fa-icon> Pending </span>
}
</td>
<td class="col-actions">
<div class="cert-row-actions">
<button
type="button"
class="cert-icon-btn primary"
(click)="viewCertificate(r)"
matTooltip="View / Print declaration"
>
<fa-icon icon="eye"></fa-icon>
</button>
<button
type="button"
class="cert-icon-btn"
(click)="downloadCertificate(r)"
matTooltip="Download PDF"
>
<fa-icon icon="download"></fa-icon>
</button>
</div>
</td>
</tr>
}
@if (filteredRecords.length === 0) {
<tr>
<td colspan="8" class="cert-empty">
<fa-icon icon="info-circle"></fa-icon>
No declarations match the current filters.
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
@@ -0,0 +1,241 @@
/**
* 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 { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTooltip } from '@angular/material/tooltip';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import {
CertificatePreviewDialogComponent,
CertificatePreviewDialogData
} from '../certificate-preview-dialog/certificate-preview-dialog.component';
import { downloadForm15ghPdf } from '../certificate-pdf';
export type Form15Kind = '15G' | '15H';
export type Form15Status = 'FILED' | 'PENDING';
export interface Form15ghRecord {
customerId: string;
customerName: string;
customerPan: string;
customerAddress: string;
dob: string;
branch: string;
/** Aggregate interest the bank will pay this FY. */
estimatedInterest: number;
/** Customer's total estimated income for the FY. */
estimatedIncome: number;
/** Sum of FD / RD principals tied to the declaration. */
depositPrincipal: number;
fy: string;
status: Form15Status;
filedOn?: string;
/** "Yes / No" for: has any income tax been assessed for the customer
under the IT Act in the past six years? */
assessed: boolean;
}
@Component({
selector: 'mifosx-form-15gh',
templateUrl: './form-15gh.component.html',
styleUrls: ['../certificates.shared.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
CommonModule,
FormsModule,
FaIconComponent,
MatTooltip
]
})
export class Form15ghComponent implements OnInit {
private dialog = inject(MatDialog);
private snack = inject(MatSnackBar);
// ── Filters ──
search = '';
/** Which form. Empty = both. */
kindFilter: '' | Form15Kind = '';
statusFilter: '' | Form15Status = '';
records: Form15ghRecord[] = [];
ngOnInit(): void {
this.records = seedForm15gh();
}
/** Compute age in years from DoB. */
age(dob: string): number {
if (!dob) return 0;
const today = new Date();
const birth = new Date(dob);
let age = today.getFullYear() - birth.getFullYear();
const m = today.getMonth() - birth.getMonth();
if (m < 0 || (m === 0 && today.getDate() < birth.getDate())) age--;
return age;
}
/** 15H once 60+, 15G otherwise. */
formKind(r: Form15ghRecord): Form15Kind {
return this.age(r.dob) >= 60 ? '15H' : '15G';
}
get filteredRecords(): Form15ghRecord[] {
const q = this.search.trim().toLowerCase();
return this.records.filter((r) => {
if (this.kindFilter && this.formKind(r) !== this.kindFilter) return false;
if (this.statusFilter && r.status !== this.statusFilter) return false;
if (!q) return true;
return (
r.customerName.toLowerCase().includes(q) ||
r.customerId.toLowerCase().includes(q) ||
r.customerPan.toLowerCase().includes(q)
);
});
}
count15G(): number {
return this.records.filter((r) => this.formKind(r) === '15G').length;
}
count15H(): number {
return this.records.filter((r) => this.formKind(r) === '15H').length;
}
countFiled(): number {
return this.records.filter((r) => r.status === 'FILED').length;
}
totalInterest(): number {
return this.filteredRecords.reduce((s, r) => s + r.estimatedInterest, 0);
}
clearFilters(): void {
this.search = '';
this.kindFilter = '';
this.statusFilter = '';
}
viewCertificate(r: Form15ghRecord): void {
const data: CertificatePreviewDialogData = {
kind: 'form15gh',
form15gh: { record: r, kind: this.formKind(r), age: this.age(r.dob) }
};
this.dialog.open(CertificatePreviewDialogComponent, {
width: '880px',
maxWidth: '96vw',
maxHeight: '92vh',
autoFocus: false,
panelClass: 'cert-preview-dialog',
data
});
}
downloadCertificate(r: Form15ghRecord): void {
downloadForm15ghPdf(r, this.formKind(r));
this.snack.open(`Form ${this.formKind(r)} for ${r.customerName} downloaded.`, 'OK', { duration: 2400 });
}
}
// ───────────────────── Mock seed (top-level export) ─────────────────────
export function seedForm15gh(): Form15ghRecord[] {
return [
{
customerId: 'CUST112233',
customerName: 'Rohan S. Mehta',
customerPan: 'ABCPM1234E',
customerAddress: 'Flat 902, Skyline Apt, Andheri (E), Mumbai 400069',
dob: '1985-02-14',
branch: 'Mumbai — Andheri (E)',
estimatedInterest: 38500,
estimatedIncome: 850000,
depositPrincipal: 500000,
fy: 'FY 2025-26',
status: 'FILED',
filedOn: '2025-04-10',
assessed: true
},
{
customerId: 'CUST556677',
customerName: 'Karthik Rao',
customerPan: 'PQRSR2233Z',
customerAddress: '24, MG Road, Bengaluru 560001',
dob: '1972-09-04',
branch: 'Bengaluru — MG Road',
estimatedInterest: 52800,
estimatedIncome: 320000,
depositPrincipal: 700000,
fy: 'FY 2025-26',
status: 'PENDING',
assessed: false
},
{
customerId: 'CUST667788',
customerName: 'Lakshmi Naidu',
customerPan: 'XYZAN7788Q',
customerAddress: 'Plot 14, Road No. 3, Banjara Hills, Hyderabad 500034',
dob: '1958-06-22',
branch: 'Hyderabad — Banjara Hills',
estimatedInterest: 88200,
estimatedIncome: 410000,
depositPrincipal: 1200000,
fy: 'FY 2025-26',
status: 'FILED',
filedOn: '2025-04-15',
assessed: true
},
{
customerId: 'CUST334455',
customerName: 'Anita Verma',
customerPan: 'EFGHV5678A',
customerAddress: 'B-12, Connaught Pl, New Delhi 110001',
dob: '1962-11-30',
branch: 'New Delhi — Connaught Place',
estimatedInterest: 64500,
estimatedIncome: 280000,
depositPrincipal: 900000,
fy: 'FY 2025-26',
status: 'FILED',
filedOn: '2025-04-05',
assessed: false
},
{
customerId: 'CUST990011',
customerName: 'Arjun Banerjee',
customerPan: 'TUVWB4455F',
customerAddress: '42, Park Street, Kolkata 700016',
dob: '1991-07-19',
branch: 'Kolkata — Park Street',
estimatedInterest: 14200,
estimatedIncome: 620000,
depositPrincipal: 200000,
fy: 'FY 2025-26',
status: 'PENDING',
assessed: true
},
{
customerId: 'CUST330044',
customerName: 'Madhavi Singh',
customerPan: 'TUVWS3344K',
customerAddress: 'D-5, Greater Kailash, New Delhi 110048',
dob: '1955-03-08',
branch: 'New Delhi — Connaught Place',
estimatedInterest: 145000,
estimatedIncome: 320000,
depositPrincipal: 2000000,
fy: 'FY 2025-26',
status: 'FILED',
filedOn: '2025-04-02',
assessed: false
}
];
}
@@ -0,0 +1,178 @@
<!--
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="cert-root">
<div class="cert-head">
<div class="cert-head-l">
<fa-icon icon="file-signature" class="cert-head-ic"></fa-icon>
<div>
<h2 class="cert-head-t">Form 60 Declaration</h2>
<div class="cert-head-s">
Income Tax Rule 114B — required for any customer transacting above the threshold who does
<strong>not</strong> have a PAN on file.
</div>
</div>
</div>
</div>
<!-- KPI strip -->
<div class="cert-kpis">
<div class="cert-kpi cert-kpi--blue">
<fa-icon icon="users" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ totalNoPan() | number }}</div>
<div class="cert-kpi-l">Customers without PAN</div>
</div>
</div>
<div class="cert-kpi cert-kpi--green">
<fa-icon icon="check-circle" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ totalFiled() | number }}</div>
<div class="cert-kpi-l">Form 60 Filed</div>
</div>
</div>
<div class="cert-kpi cert-kpi--amber">
<fa-icon icon="hourglass-half" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ totalPending() | number }}</div>
<div class="cert-kpi-l">Filing Pending</div>
</div>
</div>
</div>
<!-- Filters -->
<div class="cert-filters">
<div class="cert-filter">
<label>Customer / Customer ID / ID No.</label>
<div class="cert-input-ic">
<fa-icon icon="search"></fa-icon>
<input type="text" [(ngModel)]="search" placeholder="e.g. CUST220033 or customer name" />
</div>
</div>
<div class="cert-filter">
<label>PAN status</label>
<select [(ngModel)]="panFilter">
<option value="MISSING">Without PAN (Form 60 applicable)</option>
<option value="ON_FILE">With PAN on file</option>
<option value="">All customers</option>
</select>
</div>
<div class="cert-filter">
<label>ID proof used</label>
<select [(ngModel)]="idProofFilter">
<option value="">All ID proofs</option>
@for (o of idProofOptions; track o.value) {
<option [value]="o.value">{{ o.label }}</option>
}
</select>
</div>
@if (search || idProofFilter || panFilter !== 'MISSING') {
<button type="button" class="cert-clear" (click)="clearFilters()"><fa-icon icon="times"></fa-icon> Clear</button>
}
</div>
<!-- Table -->
<div class="cert-card">
<div class="cert-table-wrap">
<table class="cert-table">
<thead>
<tr>
<th>Customer · ID</th>
<th>ID Proof</th>
<th>Branch</th>
<th>Trigger Transaction</th>
<th class="num">Amount</th>
<th>PAN Status</th>
<th>Form 60</th>
<th class="col-actions">Actions</th>
</tr>
</thead>
<tbody>
@for (r of filteredRecords; track r.customerId) {
<tr>
<td>
<div class="cert-cust-name">{{ r.customerName }}</div>
<div class="mono small muted">{{ r.customerId }} · DOB {{ r.dob | date: 'd MMM y' }}</div>
<div class="small muted">S/o, D/o, W/o: {{ r.fatherName }}</div>
</td>
<td>
<span class="cert-pill cert-pill--type">{{ idProofLabel(r.idProofType) }}</span>
<div class="mono small muted">{{ r.idProofNumber }}</div>
</td>
<td class="small">{{ r.branch }}</td>
<td class="small">
{{ r.transactionType }}
@if (r.transactionDate) {
<div class="mono small muted">{{ r.transactionDate | date: 'd MMM y' }}</div>
}
</td>
<td class="num">
@if (r.transactionAmount > 0) {
₹{{ r.transactionAmount | number: '1.0-0' }}
} @else {
<span class="muted">—</span>
}
</td>
<td>
@if (r.hasPan) {
<span class="cert-pill cert-pill--green"> <fa-icon icon="check-circle"></fa-icon> PAN on file </span>
} @else {
<span class="cert-pill cert-pill--amber">
<fa-icon icon="exclamation-triangle"></fa-icon> No PAN
</span>
}
</td>
<td>
@if (r.hasPan) {
<span class="muted small">N/A</span>
} @else if (r.filedOn) {
<span class="cert-pill cert-pill--green">Filed · {{ r.filedOn | date: 'd MMM' }}</span>
} @else {
<span class="cert-pill cert-pill--amber">Pending</span>
}
</td>
<td class="col-actions">
<div class="cert-row-actions">
<button
type="button"
class="cert-icon-btn primary"
[disabled]="r.hasPan"
(click)="viewCertificate(r)"
matTooltip="View / Print Form 60"
>
<fa-icon icon="eye"></fa-icon>
</button>
<button
type="button"
class="cert-icon-btn"
[disabled]="r.hasPan"
(click)="downloadCertificate(r)"
matTooltip="Download PDF"
>
<fa-icon icon="download"></fa-icon>
</button>
</div>
</td>
</tr>
}
@if (filteredRecords.length === 0) {
<tr>
<td colspan="8" class="cert-empty">
<fa-icon icon="info-circle"></fa-icon>
No customers match the current filters.
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
@@ -0,0 +1,236 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, inject, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTooltip } from '@angular/material/tooltip';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import {
CertificatePreviewDialogComponent,
CertificatePreviewDialogData
} from '../certificate-preview-dialog/certificate-preview-dialog.component';
import { downloadForm60Pdf } from '../certificate-pdf';
export type Form60IdProofType = 'AADHAAR' | 'VOTER_ID' | 'DRIVING_LICENSE' | 'PASSPORT' | 'RATION';
export interface Form60Record {
customerId: string;
customerName: string;
customerAddress: string;
fatherName: string;
dob: string;
branch: string;
/** True only when there's no PAN on file — Form 60 is required. */
hasPan: boolean;
/** ID document used in lieu of PAN. */
idProofType: Form60IdProofType;
idProofNumber: string;
/** Transaction reason that triggered Form 60 (cash >50k etc.). */
transactionType: string;
transactionAmount: number;
transactionDate: string;
estimatedIncome: number;
agriIncome: number;
filedOn?: string;
}
@Component({
selector: 'mifosx-form-60',
templateUrl: './form-60.component.html',
styleUrls: ['../certificates.shared.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
CommonModule,
FormsModule,
FaIconComponent,
MatTooltip
]
})
export class Form60Component implements OnInit {
private dialog = inject(MatDialog);
private snack = inject(MatSnackBar);
// ── Filters ──
search = '';
idProofFilter: '' | Form60IdProofType = '';
/** Default: only show customers without PAN (since Form 60 only applies to them). */
panFilter: '' | 'MISSING' | 'ON_FILE' = 'MISSING';
records: Form60Record[] = [];
readonly idProofOptions: { value: Form60IdProofType; label: string }[] = [
{ value: 'AADHAAR', label: 'Aadhaar Card' },
{ value: 'VOTER_ID', label: 'Voter ID' },
{ value: 'DRIVING_LICENSE', label: 'Driving License' },
{ value: 'PASSPORT', label: 'Passport' },
{ value: 'RATION', label: 'Ration Card' }
];
ngOnInit(): void {
this.records = seedForm60();
}
get filteredRecords(): Form60Record[] {
const q = this.search.trim().toLowerCase();
return this.records.filter((r) => {
if (this.panFilter === 'MISSING' && r.hasPan) return false;
if (this.panFilter === 'ON_FILE' && !r.hasPan) return false;
if (this.idProofFilter && r.idProofType !== this.idProofFilter) return false;
if (!q) return true;
return (
r.customerName.toLowerCase().includes(q) ||
r.customerId.toLowerCase().includes(q) ||
r.idProofNumber.toLowerCase().includes(q)
);
});
}
idProofLabel(t: Form60IdProofType): string {
return this.idProofOptions.find((o) => o.value === t)?.label || t;
}
totalNoPan(): number {
return this.records.filter((r) => !r.hasPan).length;
}
totalFiled(): number {
return this.records.filter((r) => !r.hasPan && !!r.filedOn).length;
}
totalPending(): number {
return this.records.filter((r) => !r.hasPan && !r.filedOn).length;
}
clearFilters(): void {
this.search = '';
this.idProofFilter = '';
this.panFilter = 'MISSING';
}
viewCertificate(r: Form60Record): void {
if (r.hasPan) {
this.snack.open(`${r.customerName} already has a PAN on file — Form 60 is not applicable.`, 'OK', {
duration: 3200
});
return;
}
const data: CertificatePreviewDialogData = { kind: 'form60', form60: r };
this.dialog.open(CertificatePreviewDialogComponent, {
width: '880px',
maxWidth: '96vw',
maxHeight: '92vh',
autoFocus: false,
panelClass: 'cert-preview-dialog',
data
});
}
downloadCertificate(r: Form60Record): void {
if (r.hasPan) {
this.snack.open(`${r.customerName} already has a PAN on file — Form 60 is not applicable.`, 'OK', {
duration: 3200
});
return;
}
downloadForm60Pdf(r);
this.snack.open(`Form 60 for ${r.customerName} downloaded.`, 'OK', { duration: 2400 });
}
}
// ───────────────────── Mock seed (top-level export) ─────────────────────
export function seedForm60(): Form60Record[] {
return [
{
customerId: 'CUST220033',
customerName: 'Bluestone Trading Co. (Prop. Harish Iyer)',
customerAddress: '12, Brigade Road, Bengaluru 560025',
fatherName: 'K. R. Iyer',
dob: '1972-03-18',
branch: 'Bengaluru — MG Road',
hasPan: false,
idProofType: 'AADHAAR',
idProofNumber: 'XXXX-XXXX-7891',
transactionType: 'Cash deposit > ₹50,000',
transactionAmount: 75000,
transactionDate: '2025-05-10',
estimatedIncome: 850000,
agriIncome: 0,
filedOn: '2025-05-10'
},
{
customerId: 'CUST120544',
customerName: 'Sundar Pillai',
customerAddress: '14, Pondy Bazaar, T. Nagar, Chennai 600017',
fatherName: 'M. Pillai',
dob: '1968-11-04',
branch: 'Chennai — T. Nagar',
hasPan: false,
idProofType: 'VOTER_ID',
idProofNumber: 'TNV1234567',
transactionType: 'NEFT credit',
transactionAmount: 250000,
transactionDate: '2025-04-22',
estimatedIncome: 320000,
agriIncome: 180000
},
{
customerId: 'CUST998812',
customerName: 'Kamla Devi',
customerAddress: 'Plot 22, Sec 14, Rohini, New Delhi 110085',
fatherName: 'Late Hari Lal',
dob: '1955-06-12',
branch: 'New Delhi — Connaught Place',
hasPan: false,
idProofType: 'AADHAAR',
idProofNumber: 'XXXX-XXXX-4421',
transactionType: 'FD booking > ₹50,000',
transactionAmount: 200000,
transactionDate: '2025-05-02',
estimatedIncome: 145000,
agriIncome: 0,
filedOn: '2025-05-02'
},
{
customerId: 'CUST551177',
customerName: 'Ramesh Yadav',
customerAddress: 'Hno 122, Saket Nagar, Indore 452018',
fatherName: 'Banwari Yadav',
dob: '1980-08-25',
branch: 'Pune — Koregaon Park',
hasPan: false,
idProofType: 'DRIVING_LICENSE',
idProofNumber: 'MP09 20180012345',
transactionType: 'Cash deposit > ₹50,000',
transactionAmount: 85000,
transactionDate: '2025-05-15',
estimatedIncome: 410000,
agriIncome: 0
},
{
customerId: 'CUST112233',
customerName: 'Rohan S. Mehta',
customerAddress: 'Flat 902, Skyline Apt, Andheri (E), Mumbai 400069',
fatherName: 'Suresh Mehta',
dob: '1985-02-14',
branch: 'Mumbai — Andheri (E)',
hasPan: true,
idProofType: 'AADHAAR',
idProofNumber: 'XXXX-XXXX-9090',
transactionType: 'PAN on file — Form 60 not applicable',
transactionAmount: 0,
transactionDate: '',
estimatedIncome: 0,
agriIncome: 0
}
];
}
@@ -0,0 +1,169 @@
<!--
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="cert-root">
<div class="cert-head">
<div class="cert-head-l">
<fa-icon icon="file-alt" class="cert-head-ic"></fa-icon>
<div>
<h2 class="cert-head-t">Hypothecation Certificate</h2>
<div class="cert-head-s">
Lien certificate for any loan where movable assets are hypothecated to the bank — vehicles, plant &amp;
machinery, stock, gold, equipment.
</div>
</div>
</div>
</div>
<!-- KPI strip -->
<div class="cert-kpis">
<div class="cert-kpi cert-kpi--blue">
<fa-icon icon="file-alt" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ countActive() | number }}</div>
<div class="cert-kpi-l">Active Hypothecations</div>
</div>
</div>
<div class="cert-kpi cert-kpi--green">
<fa-icon icon="check-circle" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ countNoc() | number }}</div>
<div class="cert-kpi-l">NOCs Issued</div>
</div>
</div>
<div class="cert-kpi cert-kpi--amber">
<fa-icon icon="coins" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">₹{{ totalOutstanding() | number: '1.0-0' }}</div>
<div class="cert-kpi-l">Outstanding Principal</div>
</div>
</div>
</div>
<!-- Filters -->
<div class="cert-filters">
<div class="cert-filter">
<label>Certificate / A/C / Customer / Asset</label>
<div class="cert-input-ic">
<fa-icon icon="search"></fa-icon>
<input type="text" [(ngModel)]="search" placeholder="e.g. HYP-2024-001023, KA-01-MZ-4421 or customer name" />
</div>
</div>
<div class="cert-filter">
<label>Asset type</label>
<select [(ngModel)]="assetFilter">
<option value="">All assets</option>
@for (o of assetOptions; track o.value) {
<option [value]="o.value">{{ o.label }}</option>
}
</select>
</div>
<div class="cert-filter">
<label>Status</label>
<select [(ngModel)]="statusFilter">
<option value="ACTIVE">Active</option>
<option value="CLOSED">Closed</option>
<option value="NOC_ISSUED">NOC Issued</option>
<option value="">All statuses</option>
</select>
</div>
@if (search || assetFilter || statusFilter !== 'ACTIVE') {
<button type="button" class="cert-clear" (click)="clearFilters()"><fa-icon icon="times"></fa-icon> Clear</button>
}
</div>
<!-- Table -->
<div class="cert-card">
<div class="cert-table-wrap">
<table class="cert-table">
<thead>
<tr>
<th>Customer · Loan A/C</th>
<th>Certificate No.</th>
<th>Asset</th>
<th>Identifier</th>
<th class="num">Sanctioned</th>
<th class="num">Outstanding</th>
<th>Insurance</th>
<th>Status</th>
<th class="col-actions">Actions</th>
</tr>
</thead>
<tbody>
@for (r of filteredRecords; track r.certificateNo) {
<tr>
<td>
<div class="cert-cust-name">{{ r.customerName }}</div>
<div class="mono small muted">{{ r.accountNo }} · {{ r.customerId }} · PAN {{ r.customerPan }}</div>
<div class="small muted">{{ r.loanType }}</div>
</td>
<td class="mono">{{ r.certificateNo }}</td>
<td>
<span class="cert-pill cert-pill--type">{{ assetLabel(r.assetType) }}</span>
<div class="small muted">{{ r.assetDescription }}</div>
</td>
<td class="mono small">{{ r.assetIdentifier }}</td>
<td class="num">₹{{ r.sanctionedAmount | number: '1.0-0' }}</td>
<td class="num">
<strong>₹{{ r.outstandingPrincipal | number: '1.0-0' }}</strong>
</td>
<td class="small">
@if (r.insuredValue > 0) {
₹{{ r.insuredValue | number: '1.0-0' }}
<div class="mono small muted">valid till {{ r.insuranceValidTill | date: 'd MMM y' }}</div>
} @else {
<span class="muted">—</span>
}
</td>
<td>
@if (r.status === 'ACTIVE') {
<span class="cert-pill cert-pill--blue">Active</span>
} @else if (r.status === 'NOC_ISSUED') {
<span class="cert-pill cert-pill--green">NOC Issued</span>
} @else {
<span class="cert-pill">Closed</span>
}
</td>
<td class="col-actions">
<div class="cert-row-actions">
<button
type="button"
class="cert-icon-btn primary"
(click)="viewCertificate(r)"
matTooltip="View / Print certificate"
>
<fa-icon icon="eye"></fa-icon>
</button>
<button
type="button"
class="cert-icon-btn"
(click)="downloadCertificate(r)"
matTooltip="Download PDF"
>
<fa-icon icon="download"></fa-icon>
</button>
</div>
</td>
</tr>
}
@if (filteredRecords.length === 0) {
<tr>
<td colspan="9" class="cert-empty">
<fa-icon icon="info-circle"></fa-icon>
No hypothecation records match the current filters.
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
@@ -0,0 +1,242 @@
/**
* 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 { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTooltip } from '@angular/material/tooltip';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import {
CertificatePreviewDialogComponent,
CertificatePreviewDialogData
} from '../certificate-preview-dialog/certificate-preview-dialog.component';
import { downloadHypothecationPdf } from '../certificate-pdf';
export type HypoAssetType = 'VEHICLE' | 'PLANT_MACHINERY' | 'STOCK_DEBTORS' | 'GOLD' | 'EQUIPMENT';
export type HypoStatus = 'ACTIVE' | 'CLOSED' | 'NOC_ISSUED';
export interface HypothecationRecord {
certificateNo: string;
accountNo: string;
customerId: string;
customerName: string;
customerPan: string;
customerAddress: string;
branch: string;
loanType: string;
sanctionedAmount: number;
outstandingPrincipal: number;
disbursementDate: string;
/** Asset hypothecated to the bank. */
assetType: HypoAssetType;
/** Free-text asset description (Maruti Swift VDi · 2022 · etc.). */
assetDescription: string;
/** Vehicle registration, machine serial, etc. */
assetIdentifier: string;
/** Insured up to ₹ (asset insurance for replacement). */
insuredValue: number;
insuranceValidTill: string;
status: HypoStatus;
}
@Component({
selector: 'mifosx-hypothecation',
templateUrl: './hypothecation.component.html',
styleUrls: ['../certificates.shared.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
CommonModule,
FormsModule,
FaIconComponent,
MatTooltip
]
})
export class HypothecationCertificateComponent implements OnInit {
private dialog = inject(MatDialog);
private snack = inject(MatSnackBar);
search = '';
assetFilter: '' | HypoAssetType = '';
statusFilter: '' | HypoStatus = 'ACTIVE';
records: HypothecationRecord[] = [];
readonly assetOptions: { value: HypoAssetType; label: string }[] = [
{ value: 'VEHICLE', label: 'Vehicle' },
{ value: 'PLANT_MACHINERY', label: 'Plant & Machinery' },
{ value: 'STOCK_DEBTORS', label: 'Stock / Debtors' },
{ value: 'GOLD', label: 'Gold / Bullion' },
{ value: 'EQUIPMENT', label: 'Equipment' }
];
ngOnInit(): void {
this.records = seedHypothecations();
}
get filteredRecords(): HypothecationRecord[] {
const q = this.search.trim().toLowerCase();
return this.records.filter((r) => {
if (this.assetFilter && r.assetType !== this.assetFilter) return false;
if (this.statusFilter && r.status !== this.statusFilter) return false;
if (!q) return true;
return (
r.certificateNo.toLowerCase().includes(q) ||
r.accountNo.toLowerCase().includes(q) ||
r.customerName.toLowerCase().includes(q) ||
r.assetIdentifier.toLowerCase().includes(q) ||
r.assetDescription.toLowerCase().includes(q)
);
});
}
assetLabel(t: HypoAssetType): string {
return this.assetOptions.find((o) => o.value === t)?.label || t;
}
countActive(): number {
return this.records.filter((r) => r.status === 'ACTIVE').length;
}
countNoc(): number {
return this.records.filter((r) => r.status === 'NOC_ISSUED').length;
}
totalOutstanding(): number {
return this.filteredRecords.reduce((s, r) => s + r.outstandingPrincipal, 0);
}
clearFilters(): void {
this.search = '';
this.assetFilter = '';
this.statusFilter = 'ACTIVE';
}
viewCertificate(r: HypothecationRecord): void {
const data: CertificatePreviewDialogData = { kind: 'hypothecation', hypothecation: r };
this.dialog.open(CertificatePreviewDialogComponent, {
width: '880px',
maxWidth: '96vw',
maxHeight: '92vh',
autoFocus: false,
panelClass: 'cert-preview-dialog',
data
});
}
downloadCertificate(r: HypothecationRecord): void {
downloadHypothecationPdf(r);
this.snack.open(`Hypothecation Certificate ${r.certificateNo} downloaded.`, 'OK', {
duration: 2400
});
}
}
// ───────────────────── Mock seed (top-level export) ─────────────────────
export function seedHypothecations(): HypothecationRecord[] {
return [
{
certificateNo: 'HYP-2024-001023',
accountNo: 'LN50200077',
customerId: 'CUST556677',
customerName: 'Karthik Rao',
customerPan: 'PQRSR2233Z',
customerAddress: '24, MG Road, Bengaluru 560001',
branch: 'Bengaluru — MG Road',
loanType: 'Auto Loan',
sanctionedAmount: 850000,
outstandingPrincipal: 480000,
disbursementDate: '2023-01-20',
assetType: 'VEHICLE',
assetDescription: 'Maruti Suzuki Swift VXi · Manual · Petrol',
assetIdentifier: 'KA-01-MZ-4421',
insuredValue: 950000,
insuranceValidTill: '2026-01-19',
status: 'ACTIVE'
},
{
certificateNo: 'HYP-2024-001024',
accountNo: 'LN70010023',
customerId: 'CUST889900',
customerName: 'Vikram Joshi',
customerPan: 'LMNOJ9988X',
customerAddress: 'Office 3B, Koregaon Park Road, Pune 411001',
branch: 'Pune — Koregaon Park',
loanType: 'Business Loan',
sanctionedAmount: 5500000,
outstandingPrincipal: 3950000,
disbursementDate: '2020-11-05',
assetType: 'PLANT_MACHINERY',
assetDescription: 'CNC milling machine · Haas VF-2SS · 2020',
assetIdentifier: 'SN-VF2SS-2020-114782',
insuredValue: 7200000,
insuranceValidTill: '2026-03-30',
status: 'ACTIVE'
},
{
certificateNo: 'HYP-2024-001025',
accountNo: 'LN50200099',
customerId: 'CUST667788',
customerName: 'Lakshmi Naidu',
customerPan: 'XYZAN7788Q',
customerAddress: 'Plot 14, Road No. 3, Banjara Hills, Hyderabad 500034',
branch: 'Hyderabad — Banjara Hills',
loanType: 'Gold Loan',
sanctionedAmount: 450000,
outstandingPrincipal: 180000,
disbursementDate: '2023-08-18',
assetType: 'GOLD',
assetDescription: '22 KT Gold Jewellery · Total 92.5 grams',
assetIdentifier: 'GL-PKT-7891 · Locker S-22',
insuredValue: 580000,
insuranceValidTill: '2026-08-17',
status: 'ACTIVE'
},
{
certificateNo: 'HYP-2024-001026',
accountNo: 'LN20034112',
customerId: 'CUST334455',
customerName: 'Anita Verma',
customerPan: 'EFGHV5678A',
customerAddress: 'B-12, Connaught Pl, New Delhi 110001',
branch: 'New Delhi — Connaught Place',
loanType: 'Personal Loan',
sanctionedAmount: 600000,
outstandingPrincipal: 0,
disbursementDate: '2022-09-10',
assetType: 'STOCK_DEBTORS',
assetDescription: 'Trade receivables — invoices > 60 days',
assetIdentifier: 'POOL-AV-2022-001',
insuredValue: 0,
insuranceValidTill: '',
status: 'NOC_ISSUED'
},
{
certificateNo: 'HYP-2024-001027',
accountNo: 'LN50100445',
customerId: 'CUST110022',
customerName: 'Spectra Components Pvt Ltd',
customerPan: 'AABCS1122F',
customerAddress: 'Plot 9, MIDC, Andheri (E), Mumbai 400093',
branch: 'Mumbai — Andheri (E)',
loanType: 'Cash Credit',
sanctionedAmount: 2500000,
outstandingPrincipal: 1820000,
disbursementDate: '2021-07-12',
assetType: 'STOCK_DEBTORS',
assetDescription: 'Stock-in-trade + book debts (paripassu)',
assetIdentifier: 'POOL-SCPL-2021-A',
insuredValue: 4500000,
insuranceValidTill: '2026-06-30',
status: 'ACTIVE'
}
];
}
@@ -0,0 +1,155 @@
<!--
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="cert-root">
<!-- HEADER -->
<div class="cert-head">
<div class="cert-head-l">
<fa-icon icon="file-invoice" class="cert-head-ic"></fa-icon>
<div>
<h2 class="cert-head-t">Loan Interest Certificate</h2>
<div class="cert-head-s">
Generate and download interest-paid certificates per loan account, per financial year.
</div>
</div>
</div>
</div>
<!-- KPI STRIP -->
<div class="cert-kpis">
<div class="cert-kpi cert-kpi--blue">
<fa-icon icon="file-invoice" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ filteredLoans.length | number }}</div>
<div class="cert-kpi-l">Loan Accounts</div>
</div>
</div>
<div class="cert-kpi cert-kpi--green">
<fa-icon icon="coins" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">₹{{ totalInterestForFy() | number: '1.0-0' }}</div>
<div class="cert-kpi-l">Interest Paid ({{ fyFilter }})</div>
</div>
</div>
<div class="cert-kpi cert-kpi--teal">
<fa-icon icon="check-circle" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">₹{{ totalPrincipalForFy() | number: '1.0-0' }}</div>
<div class="cert-kpi-l">Principal Paid ({{ fyFilter }})</div>
</div>
</div>
</div>
<!-- FILTERS -->
<div class="cert-filters">
<div class="cert-filter">
<label>Account number / Customer / PAN</label>
<div class="cert-input-ic">
<fa-icon icon="search"></fa-icon>
<input type="text" [(ngModel)]="search" placeholder="e.g. LN10029384, ABCPM1234E or customer name" />
</div>
</div>
<div class="cert-filter">
<label>Financial year</label>
<select [(ngModel)]="fyFilter">
@for (f of fyOptions; track f) {
<option [value]="f">{{ f }}</option>
}
</select>
</div>
<div class="cert-filter">
<label>Loan type</label>
<select [(ngModel)]="loanTypeFilter">
<option value="">All types</option>
@for (o of loanTypeOptions; track o.value) {
<option [value]="o.value">{{ o.label }}</option>
}
</select>
</div>
@if (search || loanTypeFilter) {
<button type="button" class="cert-clear" (click)="clearFilters()"><fa-icon icon="times"></fa-icon> Clear</button>
}
</div>
<!-- TABLE -->
<div class="cert-card">
<div class="cert-table-wrap">
<table class="cert-table">
<thead>
<tr>
<th>Customer · Loan A/C</th>
<th>Type</th>
<th>Branch</th>
<th class="num">Sanctioned</th>
<th class="num">Outstanding</th>
<th class="num">Interest ({{ fyFilter }})</th>
<th class="num">Principal ({{ fyFilter }})</th>
<th>Rate</th>
<th class="col-actions">Actions</th>
</tr>
</thead>
<tbody>
@for (l of filteredLoans; track l.accountNo) {
<tr>
<td>
<div class="cert-cust-name">{{ l.customerName }}</div>
<div class="mono small muted">{{ l.accountNo }} · {{ l.customerId }} · PAN {{ l.customerPan }}</div>
</td>
<td>
<span class="cert-pill cert-pill--type">{{ loanTypeLabel(l.loanType) }}</span>
</td>
<td class="small">{{ l.branch }}</td>
<td class="num">₹{{ l.sanctionedAmount | number: '1.0-0' }}</td>
<td class="num">
<strong>₹{{ outstandingPrincipal(l) | number: '1.0-0' }}</strong>
</td>
<td class="num">
<strong class="cert-interest">₹{{ selectedFyData(l)?.interestPaid | number: '1.0-0' }}</strong>
</td>
<td class="num">₹{{ selectedFyData(l)?.principalPaid | number: '1.0-0' }}</td>
<td>
<span class="cert-rate-pill">{{ l.interestRate }}% p.a.</span>
</td>
<td class="col-actions">
<div class="cert-row-actions">
<button
type="button"
class="cert-icon-btn primary"
(click)="viewCertificate(l)"
matTooltip="View / Print certificate"
>
<fa-icon icon="eye"></fa-icon>
</button>
<button
type="button"
class="cert-icon-btn"
(click)="downloadCertificate(l)"
matTooltip="Download PDF"
>
<fa-icon icon="download"></fa-icon>
</button>
</div>
</td>
</tr>
}
@if (filteredLoans.length === 0) {
<tr>
<td colspan="9" class="cert-empty">
<fa-icon icon="info-circle"></fa-icon>
No loan accounts match the current filters.
</td>
</tr>
}
</tbody>
</table>
</div>
</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/.
*/
import { Component, inject, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTooltip } from '@angular/material/tooltip';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import {
CertificatePreviewDialogComponent,
CertificatePreviewDialogData
} from '../certificate-preview-dialog/certificate-preview-dialog.component';
import { downloadLoanInterestPdf } from '../certificate-pdf';
export type LoanProductType = 'HOUSING' | 'PERSONAL' | 'AUTO' | 'EDUCATION' | 'GOLD' | 'BUSINESS';
export interface FyInterestBreakup {
/** Financial year label e.g. "FY 2024-25". */
fy: string;
openingBalance: number;
closingBalance: number;
principalPaid: number;
interestPaid: number;
}
export interface LoanInterestRecord {
accountNo: string;
customerId: string;
customerName: string;
customerPan: string;
customerAddress: string;
branch: string;
loanType: LoanProductType;
sanctionedAmount: number;
disbursementDate: string;
/** Interest rate p.a. (e.g. 8.65) */
interestRate: number;
fy: FyInterestBreakup[];
}
@Component({
selector: 'mifosx-loan-interest-certificate',
templateUrl: './loan-interest.component.html',
styleUrls: ['../certificates.shared.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
CommonModule,
FormsModule,
FaIconComponent,
MatTooltip
]
})
export class LoanInterestCertificateComponent implements OnInit {
private dialog = inject(MatDialog);
private snack = inject(MatSnackBar);
// ── Filters ──
search = '';
fyFilter = '';
loanTypeFilter: '' | LoanProductType = '';
loans: LoanInterestRecord[] = [];
/** All financial years present across the dataset (newest first). */
fyOptions: string[] = [];
readonly loanTypeOptions: { value: LoanProductType; label: string }[] = [
{ value: 'HOUSING', label: 'Housing Loan' },
{ value: 'PERSONAL', label: 'Personal Loan' },
{ value: 'AUTO', label: 'Auto Loan' },
{ value: 'EDUCATION', label: 'Education Loan' },
{ value: 'GOLD', label: 'Gold Loan' },
{ value: 'BUSINESS', label: 'Business Loan' }
];
ngOnInit(): void {
this.loans = seedLoanInterestRecords();
this.fyOptions = Array.from(new Set(this.loans.flatMap((l) => l.fy.map((f) => f.fy))))
.sort()
.reverse();
// Default to most-recent FY
if (this.fyOptions.length) {
this.fyFilter = this.fyOptions[0];
}
}
// ── Derived list ──
get filteredLoans(): LoanInterestRecord[] {
const q = this.search.trim().toLowerCase();
return this.loans.filter((l) => {
if (this.loanTypeFilter && l.loanType !== this.loanTypeFilter) return false;
if (!q) return true;
return (
l.accountNo.toLowerCase().includes(q) ||
l.customerName.toLowerCase().includes(q) ||
l.customerId.toLowerCase().includes(q) ||
l.customerPan.toLowerCase().includes(q)
);
});
}
loanTypeLabel(t: LoanProductType): string {
return this.loanTypeOptions.find((o) => o.value === t)?.label || t;
}
selectedFyData(l: LoanInterestRecord): FyInterestBreakup | undefined {
return l.fy.find((f) => f.fy === this.fyFilter) || l.fy[0];
}
totalInterestForFy(): number {
return this.filteredLoans.reduce((s, l) => s + (this.selectedFyData(l)?.interestPaid || 0), 0);
}
totalPrincipalForFy(): number {
return this.filteredLoans.reduce((s, l) => s + (this.selectedFyData(l)?.principalPaid || 0), 0);
}
outstandingPrincipal(l: LoanInterestRecord): number {
return this.selectedFyData(l)?.closingBalance ?? 0;
}
// ── Actions ──
clearFilters(): void {
this.search = '';
this.loanTypeFilter = '';
if (this.fyOptions.length) this.fyFilter = this.fyOptions[0];
}
viewCertificate(l: LoanInterestRecord): void {
const fy = this.selectedFyData(l);
if (!fy) {
this.snack.open('No interest data on file for the selected financial year.', 'OK', { duration: 2500 });
return;
}
const data: CertificatePreviewDialogData = {
kind: 'interest',
loanInterest: { record: l, fy }
};
this.dialog.open(CertificatePreviewDialogComponent, {
width: '880px',
maxWidth: '96vw',
maxHeight: '92vh',
autoFocus: false,
panelClass: 'cert-preview-dialog',
data
});
}
downloadCertificate(l: LoanInterestRecord): void {
const fy = this.selectedFyData(l);
if (!fy) {
this.snack.open('No interest data on file for the selected financial year.', 'OK', { duration: 2500 });
return;
}
downloadLoanInterestPdf(l, fy);
this.snack.open(`Interest Certificate (${fy.fy}) downloaded.`, 'OK', { duration: 2400 });
}
}
// ───────────────────── Mock seed (top-level export) ─────────────────────
export function seedLoanInterestRecords(): LoanInterestRecord[] {
return [
{
accountNo: 'LN10029384',
customerId: 'CUST112233',
customerName: 'Rohan S. Mehta',
customerPan: 'ABCPM1234E',
customerAddress: 'Flat 902, Skyline Apt, Andheri (E), Mumbai 400069',
branch: 'Mumbai — Andheri (E)',
loanType: 'HOUSING',
sanctionedAmount: 4500000,
disbursementDate: '2021-06-15',
interestRate: 8.65,
fy: [
{
fy: 'FY 2024-25',
openingBalance: 3620000,
closingBalance: 3380000,
principalPaid: 240000,
interestPaid: 312500
},
{
fy: 'FY 2023-24',
openingBalance: 3840000,
closingBalance: 3620000,
principalPaid: 220000,
interestPaid: 328200
},
{
fy: 'FY 2022-23',
openingBalance: 4050000,
closingBalance: 3840000,
principalPaid: 210000,
interestPaid: 342800
}
]
},
{
accountNo: 'LN20034112',
customerId: 'CUST334455',
customerName: 'Anita Verma',
customerPan: 'EFGHV5678A',
customerAddress: 'B-12, Connaught Pl, New Delhi 110001',
branch: 'New Delhi — Connaught Place',
loanType: 'PERSONAL',
sanctionedAmount: 600000,
disbursementDate: '2022-09-10',
interestRate: 12.5,
fy: [
{
fy: 'FY 2024-25',
openingBalance: 380000,
closingBalance: 220000,
principalPaid: 160000,
interestPaid: 41200
},
{ fy: 'FY 2023-24', openingBalance: 520000, closingBalance: 380000, principalPaid: 140000, interestPaid: 56800 }
]
},
{
accountNo: 'LN50200077',
customerId: 'CUST556677',
customerName: 'Karthik Rao',
customerPan: 'PQRSR2233Z',
customerAddress: '24, MG Road, Bengaluru 560001',
branch: 'Bengaluru — MG Road',
loanType: 'AUTO',
sanctionedAmount: 850000,
disbursementDate: '2023-01-20',
interestRate: 9.4,
fy: [
{
fy: 'FY 2024-25',
openingBalance: 620000,
closingBalance: 480000,
principalPaid: 140000,
interestPaid: 58800
},
{ fy: 'FY 2023-24', openingBalance: 780000, closingBalance: 620000, principalPaid: 160000, interestPaid: 71200 }
]
},
{
accountNo: 'LN70010023',
customerId: 'CUST889900',
customerName: 'Vikram Joshi',
customerPan: 'LMNOJ9988X',
customerAddress: 'Office 3B, Koregaon Park Road, Pune 411001',
branch: 'Pune — Koregaon Park',
loanType: 'BUSINESS',
sanctionedAmount: 5500000,
disbursementDate: '2020-11-05',
interestRate: 11.0,
fy: [
{
fy: 'FY 2024-25',
openingBalance: 4200000,
closingBalance: 3950000,
principalPaid: 250000,
interestPaid: 464800
},
{
fy: 'FY 2023-24',
openingBalance: 4480000,
closingBalance: 4200000,
principalPaid: 280000,
interestPaid: 488600
},
{
fy: 'FY 2022-23',
openingBalance: 4760000,
closingBalance: 4480000,
principalPaid: 280000,
interestPaid: 522300
}
]
},
{
accountNo: 'LN88990022',
customerId: 'CUST990011',
customerName: 'Arjun Banerjee',
customerPan: 'TUVWB4455F',
customerAddress: '42, Park Street, Kolkata 700016',
branch: 'Kolkata — Park Street',
loanType: 'EDUCATION',
sanctionedAmount: 1200000,
disbursementDate: '2022-04-22',
interestRate: 9.85,
fy: [
{
fy: 'FY 2024-25',
openingBalance: 920000,
closingBalance: 790000,
principalPaid: 130000,
interestPaid: 93500
},
{
fy: 'FY 2023-24',
openingBalance: 1080000,
closingBalance: 920000,
principalPaid: 160000,
interestPaid: 102200
}
]
},
{
accountNo: 'LN50200099',
customerId: 'CUST667788',
customerName: 'Lakshmi Naidu',
customerPan: 'XYZAN7788Q',
customerAddress: 'Plot 14, Road No. 3, Banjara Hills, Hyderabad 500034',
branch: 'Hyderabad — Banjara Hills',
loanType: 'GOLD',
sanctionedAmount: 450000,
disbursementDate: '2023-08-18',
interestRate: 10.25,
fy: [
{
fy: 'FY 2024-25',
openingBalance: 320000,
closingBalance: 180000,
principalPaid: 140000,
interestPaid: 28400
}
]
}
];
}
@@ -0,0 +1,179 @@
<!--
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="cert-root">
<div class="cert-head">
<div class="cert-head-l">
<fa-icon icon="id-card" class="cert-head-ic"></fa-icon>
<div>
<h2 class="cert-head-t">No-Objection Certificate (NOC)</h2>
<div class="cert-head-s">
Issue NOCs for closed loan accounts — releases the bank's lien on hypothecated assets (vehicles, property,
gold) or confirms unsecured-loan closure.
</div>
</div>
</div>
</div>
<!-- KPI strip -->
<div class="cert-kpis">
<div class="cert-kpi cert-kpi--green">
<fa-icon icon="check-circle" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ countIssued() | number }}</div>
<div class="cert-kpi-l">NOCs Issued</div>
</div>
</div>
<div class="cert-kpi cert-kpi--blue">
<fa-icon icon="paper-plane" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ countReady() | number }}</div>
<div class="cert-kpi-l">Ready to Issue</div>
</div>
</div>
<div class="cert-kpi cert-kpi--amber">
<fa-icon icon="hourglass-half" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ countPending() | number }}</div>
<div class="cert-kpi-l">Pending Closure</div>
</div>
</div>
</div>
<!-- Filters -->
<div class="cert-filters">
<div class="cert-filter">
<label>NOC / A/C / Customer / Asset</label>
<div class="cert-input-ic">
<fa-icon icon="search"></fa-icon>
<input type="text" [(ngModel)]="search" placeholder="e.g. NOC-2024-005012, KA-01-MZ-4421 or customer name" />
</div>
</div>
<div class="cert-filter">
<label>Loan type</label>
<select [(ngModel)]="loanTypeFilter">
<option value="">All types</option>
@for (o of loanTypeOptions; track o.value) {
<option [value]="o.value">{{ o.label }}</option>
}
</select>
</div>
<div class="cert-filter">
<label>Status</label>
<select [(ngModel)]="statusFilter">
<option value="">All statuses</option>
<option value="ISSUED">Issued</option>
<option value="READY_TO_ISSUE">Ready to issue</option>
<option value="PENDING">Pending closure</option>
</select>
</div>
@if (search || loanTypeFilter || statusFilter) {
<button type="button" class="cert-clear" (click)="clearFilters()"><fa-icon icon="times"></fa-icon> Clear</button>
}
</div>
<!-- Table -->
<div class="cert-card">
<div class="cert-table-wrap">
<table class="cert-table">
<thead>
<tr>
<th>Customer · Loan A/C</th>
<th>NOC No.</th>
<th>Loan Type</th>
<th>Asset / Identifier</th>
<th class="num">Total Paid</th>
<th>Closure</th>
<th>Status</th>
<th class="col-actions">Actions</th>
</tr>
</thead>
<tbody>
@for (r of filteredRecords; track r.nocNo) {
<tr>
<td>
<div class="cert-cust-name">{{ r.customerName }}</div>
<div class="mono small muted">{{ r.accountNo }} · {{ r.customerId }} · PAN {{ r.customerPan }}</div>
</td>
<td class="mono">{{ r.nocNo }}</td>
<td>
<span class="cert-pill cert-pill--type">{{ loanTypeLabel(r.loanType) }}</span>
<div class="small muted">Sanctioned ₹{{ r.sanctionedAmount | number: '1.0-0' }}</div>
</td>
<td>
@if (r.assetType !== 'NONE') {
<div>{{ r.assetDescription }}</div>
<div class="mono small muted">{{ r.assetIdentifier }}</div>
} @else {
<span class="muted small">Unsecured · no asset</span>
}
</td>
<td class="num">
<strong>₹{{ r.totalPaid | number: '1.0-0' }}</strong>
</td>
<td class="small">
@if (r.closureDate) {
{{ r.closureDate | date: 'd MMM y' }}
} @else {
<span class="muted">—</span>
}
</td>
<td>
@if (r.status === 'ISSUED') {
<span class="cert-pill cert-pill--green"> <fa-icon icon="check-circle"></fa-icon> Issued </span>
@if (r.issuedOn) {
<div class="mono small muted">{{ r.issuedOn | date: 'd MMM y' }}</div>
}
} @else if (r.status === 'READY_TO_ISSUE') {
<span class="cert-pill cert-pill--blue"> <fa-icon icon="paper-plane"></fa-icon> Ready to issue </span>
} @else {
<span class="cert-pill cert-pill--amber">
<fa-icon icon="hourglass-half"></fa-icon> Pending closure
</span>
}
</td>
<td class="col-actions">
<div class="cert-row-actions">
<button
type="button"
class="cert-icon-btn primary"
[disabled]="r.status === 'PENDING'"
(click)="viewCertificate(r)"
matTooltip="View / Print NOC"
>
<fa-icon icon="eye"></fa-icon>
</button>
<button
type="button"
class="cert-icon-btn"
[disabled]="r.status === 'PENDING'"
(click)="downloadCertificate(r)"
matTooltip="Download PDF"
>
<fa-icon icon="download"></fa-icon>
</button>
</div>
</td>
</tr>
}
@if (filteredRecords.length === 0) {
<tr>
<td colspan="8" class="cert-empty">
<fa-icon icon="info-circle"></fa-icon>
No NOCs match the current filters.
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
@@ -0,0 +1,306 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, inject, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTooltip } from '@angular/material/tooltip';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import {
CertificatePreviewDialogComponent,
CertificatePreviewDialogData
} from '../certificate-preview-dialog/certificate-preview-dialog.component';
import { downloadNocPdf } from '../certificate-pdf';
export type NocLoanType = 'AUTO' | 'HOUSING' | 'PERSONAL' | 'EDUCATION' | 'GOLD' | 'BUSINESS' | 'CC' | 'OD';
export type NocStatus = 'ISSUED' | 'PENDING' | 'READY_TO_ISSUE';
export type NocAssetType = 'VEHICLE' | 'PROPERTY' | 'GOLD' | 'EQUIPMENT' | 'STOCK_DEBTORS' | 'NONE';
export interface NocRecord {
nocNo: string;
accountNo: string;
customerId: string;
customerName: string;
customerPan: string;
customerAddress: string;
branch: string;
loanType: NocLoanType;
sanctionedAmount: number;
disbursementDate: string;
/** Date the loan was fully closed / settled. */
closureDate: string;
totalPaid: number;
/** Asset whose lien is being released. */
assetType: NocAssetType;
assetDescription: string;
assetIdentifier: string;
/** Where to forward the NOC (RTO Mumbai, Sub-Registrar etc.) */
forwardTo?: string;
status: NocStatus;
issuedOn?: string;
issuedBy?: string;
}
@Component({
selector: 'mifosx-noc',
templateUrl: './noc.component.html',
styleUrls: ['../certificates.shared.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
CommonModule,
FormsModule,
FaIconComponent,
MatTooltip
]
})
export class NocCertificateComponent implements OnInit {
private dialog = inject(MatDialog);
private snack = inject(MatSnackBar);
// ── Filters ──
search = '';
loanTypeFilter: '' | NocLoanType = '';
statusFilter: '' | NocStatus = '';
records: NocRecord[] = [];
readonly loanTypeOptions: { value: NocLoanType; label: string }[] = [
{ value: 'AUTO', label: 'Auto Loan' },
{ value: 'HOUSING', label: 'Housing Loan' },
{ value: 'PERSONAL', label: 'Personal Loan' },
{ value: 'EDUCATION', label: 'Education Loan' },
{ value: 'GOLD', label: 'Gold Loan' },
{ value: 'BUSINESS', label: 'Business Loan' },
{ value: 'CC', label: 'Cash Credit (CC)' },
{ value: 'OD', label: 'Overdraft (OD)' }
];
ngOnInit(): void {
this.records = seedNocRecords();
}
// ── Derived list ──
get filteredRecords(): NocRecord[] {
const q = this.search.trim().toLowerCase();
return this.records.filter((r) => {
if (this.loanTypeFilter && r.loanType !== this.loanTypeFilter) return false;
if (this.statusFilter && r.status !== this.statusFilter) return false;
if (!q) return true;
return (
r.nocNo.toLowerCase().includes(q) ||
r.accountNo.toLowerCase().includes(q) ||
r.customerName.toLowerCase().includes(q) ||
r.customerId.toLowerCase().includes(q) ||
r.assetIdentifier.toLowerCase().includes(q)
);
});
}
loanTypeLabel(t: NocLoanType): string {
return this.loanTypeOptions.find((o) => o.value === t)?.label || t;
}
countIssued(): number {
return this.records.filter((r) => r.status === 'ISSUED').length;
}
countReady(): number {
return this.records.filter((r) => r.status === 'READY_TO_ISSUE').length;
}
countPending(): number {
return this.records.filter((r) => r.status === 'PENDING').length;
}
clearFilters(): void {
this.search = '';
this.loanTypeFilter = '';
this.statusFilter = '';
}
viewCertificate(r: NocRecord): void {
if (r.status === 'PENDING') {
this.snack.open(
`${r.customerName}'s loan ${r.accountNo} is still pending closure — NOC cannot be previewed yet.`,
'OK',
{ duration: 3500 }
);
return;
}
const data: CertificatePreviewDialogData = { kind: 'noc', noc: r };
this.dialog.open(CertificatePreviewDialogComponent, {
width: '880px',
maxWidth: '96vw',
maxHeight: '92vh',
autoFocus: false,
panelClass: 'cert-preview-dialog',
data
});
}
downloadCertificate(r: NocRecord): void {
if (r.status === 'PENDING') {
this.snack.open(
`${r.customerName}'s loan ${r.accountNo} is still pending closure — NOC cannot be downloaded yet.`,
'OK',
{ duration: 3500 }
);
return;
}
downloadNocPdf(r);
this.snack.open(`NOC ${r.nocNo} downloaded.`, 'OK', { duration: 2400 });
}
}
// ───────────────────── Mock seed (top-level export) ─────────────────────
export function seedNocRecords(): NocRecord[] {
return [
{
nocNo: 'NOC-2024-005012',
accountNo: 'LN50200077',
customerId: 'CUST556677',
customerName: 'Karthik Rao',
customerPan: 'PQRSR2233Z',
customerAddress: '24, MG Road, Bengaluru 560001',
branch: 'Bengaluru — MG Road',
loanType: 'AUTO',
sanctionedAmount: 850000,
disbursementDate: '2023-01-20',
closureDate: '2026-01-15',
totalPaid: 988600,
assetType: 'VEHICLE',
assetDescription: 'Maruti Suzuki Swift VXi · Manual · Petrol',
assetIdentifier: 'KA-01-MZ-4421',
forwardTo: 'RTO Bengaluru, Koramangala',
status: 'ISSUED',
issuedOn: '2026-01-18',
issuedBy: 'Anita Desai'
},
{
nocNo: 'NOC-2024-005028',
accountNo: 'LN20034112',
customerId: 'CUST334455',
customerName: 'Anita Verma',
customerPan: 'EFGHV5678A',
customerAddress: 'B-12, Connaught Pl, New Delhi 110001',
branch: 'New Delhi — Connaught Place',
loanType: 'PERSONAL',
sanctionedAmount: 600000,
disbursementDate: '2022-09-10',
closureDate: '2025-09-09',
totalPaid: 698000,
assetType: 'NONE',
assetDescription: 'Unsecured personal loan',
assetIdentifier: '—',
status: 'ISSUED',
issuedOn: '2025-09-12',
issuedBy: 'Sushma Iyer'
},
{
nocNo: 'NOC-2024-005034',
accountNo: 'LN50200099',
customerId: 'CUST667788',
customerName: 'Lakshmi Naidu',
customerPan: 'XYZAN7788Q',
customerAddress: 'Plot 14, Road No. 3, Banjara Hills, Hyderabad 500034',
branch: 'Hyderabad — Banjara Hills',
loanType: 'GOLD',
sanctionedAmount: 450000,
disbursementDate: '2023-08-18',
closureDate: '2026-02-28',
totalPaid: 502800,
assetType: 'GOLD',
assetDescription: '22 KT Gold Jewellery · Total 92.5 grams',
assetIdentifier: 'GL-PKT-7891 · Locker S-22',
forwardTo: 'Branch Locker — return of pledged ornaments',
status: 'READY_TO_ISSUE'
},
{
nocNo: 'NOC-2024-005041',
accountNo: 'LN88990022',
customerId: 'CUST990011',
customerName: 'Arjun Banerjee',
customerPan: 'TUVWB4455F',
customerAddress: '42, Park Street, Kolkata 700016',
branch: 'Kolkata — Park Street',
loanType: 'EDUCATION',
sanctionedAmount: 1200000,
disbursementDate: '2022-04-22',
closureDate: '2026-03-10',
totalPaid: 1389500,
assetType: 'NONE',
assetDescription: 'Education loan (no asset hypothecated)',
assetIdentifier: '—',
status: 'READY_TO_ISSUE'
},
{
nocNo: 'NOC-2024-005055',
accountNo: 'LN10029384',
customerId: 'CUST112233',
customerName: 'Rohan S. Mehta',
customerPan: 'ABCPM1234E',
customerAddress: 'Flat 902, Skyline Apt, Andheri (E), Mumbai 400069',
branch: 'Mumbai — Andheri (E)',
loanType: 'HOUSING',
sanctionedAmount: 4500000,
disbursementDate: '2021-06-15',
closureDate: '',
totalPaid: 1310000,
assetType: 'PROPERTY',
assetDescription: 'Flat 902, Skyline Apt, Andheri (E), Mumbai 400069',
assetIdentifier: 'Reg No. AND/2021/00482',
forwardTo: 'Sub-Registrar, Andheri',
status: 'PENDING'
},
{
nocNo: 'NOC-2024-005062',
accountNo: 'LN70010023',
customerId: 'CUST889900',
customerName: 'Vikram Joshi',
customerPan: 'LMNOJ9988X',
customerAddress: 'Office 3B, Koregaon Park Road, Pune 411001',
branch: 'Pune — Koregaon Park',
loanType: 'BUSINESS',
sanctionedAmount: 5500000,
disbursementDate: '2020-11-05',
closureDate: '',
totalPaid: 1820000,
assetType: 'EQUIPMENT',
assetDescription: 'CNC milling machine · Haas VF-2SS · 2020',
assetIdentifier: 'SN-VF2SS-2020-114782',
status: 'PENDING'
},
{
nocNo: 'NOC-2024-005078',
accountNo: 'LN30049827',
customerId: 'CUST771199',
customerName: 'Suresh Pillai',
customerPan: 'RSTUP4400J',
customerAddress: '17, Pondy Bazaar, T. Nagar, Chennai 600017',
branch: 'Chennai — T. Nagar',
loanType: 'AUTO',
sanctionedAmount: 1250000,
disbursementDate: '2022-06-30',
closureDate: '2025-12-20',
totalPaid: 1480200,
assetType: 'VEHICLE',
assetDescription: 'Honda City ZX · CVT · Petrol',
assetIdentifier: 'TN-09-CB-8821',
forwardTo: 'RTO Chennai Central',
status: 'ISSUED',
issuedOn: '2025-12-24',
issuedBy: 'Ramesh Krishnan'
}
];
}
@@ -0,0 +1,164 @@
<!--
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="cert-root">
<!-- HEADER -->
<div class="cert-head">
<div class="cert-head-l">
<fa-icon icon="certificate" class="cert-head-ic"></fa-icon>
<div>
<h2 class="cert-head-t">Share Certificate</h2>
<div class="cert-head-s">
Issue, preview and download member share certificates with folio + share-number range.
</div>
</div>
</div>
</div>
<!-- KPI STRIP -->
<div class="cert-kpis">
<div class="cert-kpi cert-kpi--blue">
<fa-icon icon="certificate" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ filteredRecords.length | number }}</div>
<div class="cert-kpi-l">Certificates</div>
</div>
</div>
<div class="cert-kpi cert-kpi--teal">
<fa-icon icon="cube" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">{{ totalShares() | number }}</div>
<div class="cert-kpi-l">Shares Held</div>
</div>
</div>
<div class="cert-kpi cert-kpi--green">
<fa-icon icon="coins" class="cert-kpi-ic"></fa-icon>
<div>
<div class="cert-kpi-n">₹{{ totalShareValue() | number: '1.0-0' }}</div>
<div class="cert-kpi-l">Total Face Value</div>
</div>
</div>
</div>
<!-- FILTERS -->
<div class="cert-filters">
<div class="cert-filter">
<label>Certificate / Folio / Member / PAN</label>
<div class="cert-input-ic">
<fa-icon icon="search"></fa-icon>
<input type="text" [(ngModel)]="search" placeholder="e.g. SC-2024-000123 or member name" />
</div>
</div>
<div class="cert-filter">
<label>Share class</label>
<select [(ngModel)]="classFilter">
<option value="">All classes</option>
@for (o of classOptions; track o.value) {
<option [value]="o.value">{{ o.label }}</option>
}
</select>
</div>
<div class="cert-filter">
<label>Branch</label>
<select [(ngModel)]="branchFilter">
<option value="">All branches</option>
@for (b of branchOptions; track b) {
<option [value]="b">{{ b }}</option>
}
</select>
</div>
@if (search || classFilter || branchFilter) {
<button type="button" class="cert-clear" (click)="clearFilters()"><fa-icon icon="times"></fa-icon> Clear</button>
}
</div>
<!-- TABLE -->
<div class="cert-card">
<div class="cert-table-wrap">
<table class="cert-table">
<thead>
<tr>
<th>Member · Folio</th>
<th>Certificate No.</th>
<th>Class</th>
<th>Branch</th>
<th class="num">Shares</th>
<th class="num">Face Value</th>
<th class="num">Total</th>
<th>Share Nos.</th>
<th>Issued</th>
<th class="col-actions">Actions</th>
</tr>
</thead>
<tbody>
@for (r of filteredRecords; track r.certificateNo) {
<tr>
<td>
<div class="cert-cust-name">{{ r.memberName }}</div>
<div class="mono small muted">
{{ r.memberId }} · Folio <span class="cert-folio">{{ r.folioNo }}</span> · PAN {{ r.memberPan }}
</div>
</td>
<td class="mono">{{ r.certificateNo }}</td>
<td>
<span
class="cert-pill"
[class.cert-pill--blue]="r.shareClass === 'ORDINARY'"
[class.cert-pill--amber]="r.shareClass === 'PREFERENCE'"
>
{{ classLabel(r.shareClass) }}
</span>
</td>
<td class="small">{{ r.branch }}</td>
<td class="num">
<strong class="cert-shares">{{ r.shareCount | number }}</strong>
</td>
<td class="num">₹{{ r.faceValue | number: '1.0-0' }}</td>
<td class="num">
<strong>₹{{ r.shareCount * r.faceValue | number: '1.0-0' }}</strong>
</td>
<td class="mono small">{{ r.shareNoFrom }}&ndash;{{ r.shareNoTo }}</td>
<td class="small">{{ r.issueDate | date: 'd MMM y' }}</td>
<td class="col-actions">
<div class="cert-row-actions">
<button
type="button"
class="cert-icon-btn primary"
(click)="viewCertificate(r)"
matTooltip="View / Print certificate"
>
<fa-icon icon="eye"></fa-icon>
</button>
<button
type="button"
class="cert-icon-btn"
(click)="downloadCertificate(r)"
matTooltip="Download PDF"
>
<fa-icon icon="download"></fa-icon>
</button>
</div>
</td>
</tr>
}
@if (filteredRecords.length === 0) {
<tr>
<td colspan="10" class="cert-empty">
<fa-icon icon="info-circle"></fa-icon>
No share certificates match the current filters.
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
@@ -0,0 +1,263 @@
/**
* 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 { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTooltip } from '@angular/material/tooltip';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import {
CertificatePreviewDialogComponent,
CertificatePreviewDialogData
} from '../certificate-preview-dialog/certificate-preview-dialog.component';
import { downloadShareCertificatePdf } from '../certificate-pdf';
export type ShareClass = 'ORDINARY' | 'PREFERENCE';
export interface ShareCertificateRecord {
certificateNo: string;
folioNo: string;
memberId: string;
memberName: string;
memberPan: string;
memberAddress: string;
branch: string;
joiningDate: string;
shareClass: ShareClass;
shareCount: number;
faceValue: number;
issueDate: string;
/** Distinct numeric range of share numbers held (e.g. 1001–1100). */
shareNoFrom: number;
shareNoTo: number;
/** Total dividend received since joining. */
dividendYtd: number;
}
@Component({
selector: 'mifosx-share-certificate',
templateUrl: './share-certificate.component.html',
styleUrls: ['../certificates.shared.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
CommonModule,
FormsModule,
FaIconComponent,
MatTooltip
]
})
export class ShareCertificateComponent implements OnInit {
private dialog = inject(MatDialog);
private snack = inject(MatSnackBar);
// ── Filters ──
search = '';
classFilter: '' | ShareClass = '';
branchFilter = '';
records: ShareCertificateRecord[] = [];
readonly classOptions: { value: ShareClass; label: string }[] = [
{ value: 'ORDINARY', label: 'Ordinary' },
{ value: 'PREFERENCE', label: 'Preference' }
];
ngOnInit(): void {
this.records = seedShareCertificates();
}
// ── Derived list ──
get filteredRecords(): ShareCertificateRecord[] {
const q = this.search.trim().toLowerCase();
const branch = this.branchFilter.trim().toLowerCase();
return this.records.filter((r) => {
if (this.classFilter && r.shareClass !== this.classFilter) return false;
if (branch && !r.branch.toLowerCase().includes(branch)) return false;
if (!q) return true;
return (
r.certificateNo.toLowerCase().includes(q) ||
r.folioNo.toLowerCase().includes(q) ||
r.memberId.toLowerCase().includes(q) ||
r.memberName.toLowerCase().includes(q) ||
r.memberPan.toLowerCase().includes(q)
);
});
}
get branchOptions(): string[] {
return Array.from(new Set(this.records.map((r) => r.branch))).sort();
}
classLabel(c: ShareClass): string {
return this.classOptions.find((o) => o.value === c)?.label || c;
}
totalShares(): number {
return this.filteredRecords.reduce((s, r) => s + r.shareCount, 0);
}
totalShareValue(): number {
return this.filteredRecords.reduce((s, r) => s + r.shareCount * r.faceValue, 0);
}
// ── Actions ──
clearFilters(): void {
this.search = '';
this.classFilter = '';
this.branchFilter = '';
}
viewCertificate(r: ShareCertificateRecord): void {
const data: CertificatePreviewDialogData = {
kind: 'share',
shareCertificate: r
};
this.dialog.open(CertificatePreviewDialogComponent, {
width: '880px',
maxWidth: '96vw',
maxHeight: '92vh',
autoFocus: false,
panelClass: 'cert-preview-dialog',
data
});
}
downloadCertificate(r: ShareCertificateRecord): void {
downloadShareCertificatePdf(r);
this.snack.open(`Share Certificate ${r.certificateNo} downloaded.`, 'OK', { duration: 2400 });
}
}
// ───────────────────── Mock seed (top-level export) ─────────────────────
export function seedShareCertificates(): ShareCertificateRecord[] {
return [
{
certificateNo: 'SC-2024-000123',
folioNo: 'F-00482',
memberId: 'MBR000482',
memberName: 'Rohan S. Mehta',
memberPan: 'ABCPM1234E',
memberAddress: 'Flat 902, Skyline Apt, Andheri (E), Mumbai 400069',
branch: 'Mumbai — Andheri (E)',
joiningDate: '2018-04-22',
shareClass: 'ORDINARY',
shareCount: 100,
faceValue: 100,
issueDate: '2018-04-22',
shareNoFrom: 48201,
shareNoTo: 48300,
dividendYtd: 7200
},
{
certificateNo: 'SC-2024-000124',
folioNo: 'F-00713',
memberId: 'MBR000713',
memberName: 'Anita Verma',
memberPan: 'EFGHV5678A',
memberAddress: 'B-12, Connaught Pl, New Delhi 110001',
branch: 'New Delhi — Connaught Place',
joiningDate: '2019-08-11',
shareClass: 'ORDINARY',
shareCount: 50,
faceValue: 100,
issueDate: '2019-08-11',
shareNoFrom: 71301,
shareNoTo: 71350,
dividendYtd: 3000
},
{
certificateNo: 'SC-2024-000125',
folioNo: 'F-01102',
memberId: 'MBR001102',
memberName: 'Karthik Rao',
memberPan: 'PQRSR2233Z',
memberAddress: '24, MG Road, Bengaluru 560001',
branch: 'Bengaluru — MG Road',
joiningDate: '2020-02-04',
shareClass: 'ORDINARY',
shareCount: 200,
faceValue: 100,
issueDate: '2020-02-04',
shareNoFrom: 110201,
shareNoTo: 110400,
dividendYtd: 14400
},
{
certificateNo: 'SC-2024-000126',
folioNo: 'F-01277',
memberId: 'MBR001277',
memberName: 'Madhavi Singh',
memberPan: 'TUVWS3344K',
memberAddress: 'D-5, Greater Kailash, New Delhi 110048',
branch: 'New Delhi — Connaught Place',
joiningDate: '2017-11-19',
shareClass: 'PREFERENCE',
shareCount: 75,
faceValue: 1000,
issueDate: '2017-11-19',
shareNoFrom: 12721,
shareNoTo: 12795,
dividendYtd: 67500
},
{
certificateNo: 'SC-2024-000127',
folioNo: 'F-01498',
memberId: 'MBR001498',
memberName: 'Vikram Joshi',
memberPan: 'LMNOJ9988X',
memberAddress: 'Office 3B, Koregaon Park Road, Pune 411001',
branch: 'Pune — Koregaon Park',
joiningDate: '2016-06-30',
shareClass: 'PREFERENCE',
shareCount: 250,
faceValue: 1000,
issueDate: '2016-06-30',
shareNoFrom: 14981,
shareNoTo: 15230,
dividendYtd: 225000
},
{
certificateNo: 'SC-2024-000128',
folioNo: 'F-01902',
memberId: 'MBR001902',
memberName: 'Lakshmi Naidu',
memberPan: 'XYZAN7788Q',
memberAddress: 'Plot 14, Road No. 3, Banjara Hills, Hyderabad 500034',
branch: 'Hyderabad — Banjara Hills',
joiningDate: '2021-03-15',
shareClass: 'ORDINARY',
shareCount: 30,
faceValue: 100,
issueDate: '2021-03-15',
shareNoFrom: 190201,
shareNoTo: 190230,
dividendYtd: 1800
},
{
certificateNo: 'SC-2024-000129',
folioNo: 'F-02011',
memberId: 'MBR002011',
memberName: 'Arjun Banerjee',
memberPan: 'TUVWB4455F',
memberAddress: '42, Park Street, Kolkata 700016',
branch: 'Kolkata — Park Street',
joiningDate: '2021-09-08',
shareClass: 'ORDINARY',
shareCount: 40,
faceValue: 100,
issueDate: '2021-09-08',
shareNoFrom: 201101,
shareNoTo: 201140,
dividendYtd: 2400
}
];
}
@@ -0,0 +1,71 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { Route } from '../core/route/route.service';
import { NoticeComponent } from './notice/notice.component';
import { LoanInterestCertificateComponent } from './certificates/loan-interest/loan-interest.component';
import { ShareCertificateComponent } from './certificates/share-certificate/share-certificate.component';
import { BalanceCertificateComponent } from './certificates/balance-certificate/balance-certificate.component';
import { Form60Component } from './certificates/form-60/form-60.component';
import { Form15ghComponent } from './certificates/form-15gh/form-15gh.component';
import { HypothecationCertificateComponent } from './certificates/hypothecation/hypothecation.component';
import { NocCertificateComponent } from './certificates/noc/noc.component';
const routes: Routes = [
Route.withShell([
{
path: 'letter-mgmt/notice',
component: NoticeComponent,
data: { title: 'Loan Overdue Notices', breadcrumb: 'Notice' }
},
{
path: 'letter-mgmt/loan-interest-certificate',
component: LoanInterestCertificateComponent,
data: { title: 'Loan Interest Certificate', breadcrumb: 'Loan Interest Certificate' }
},
{
path: 'letter-mgmt/share-certificate',
component: ShareCertificateComponent,
data: { title: 'Share Certificate', breadcrumb: 'Share Certificate' }
},
{
path: 'letter-mgmt/balance-certificate',
component: BalanceCertificateComponent,
data: { title: 'Balance Certificate', breadcrumb: 'Balance Certificate' }
},
{
path: 'letter-mgmt/form-60',
component: Form60Component,
data: { title: 'Form 60', breadcrumb: 'Form 60' }
},
{
path: 'letter-mgmt/form-15gh',
component: Form15ghComponent,
data: { title: 'Form 15G & 15H', breadcrumb: 'Form 15G/H' }
},
{
path: 'letter-mgmt/hypothecation-certificate',
component: HypothecationCertificateComponent,
data: { title: 'Hypothecation Certificate', breadcrumb: 'Hypothecation Certificate' }
},
{
path: 'letter-mgmt/noc',
component: NocCertificateComponent,
data: { title: 'No-Objection Certificate (NOC)', breadcrumb: 'NOC' }
}
])
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class LetterMgmtRoutingModule {}
+46
View File
@@ -0,0 +1,46 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { NgModule } from '@angular/core';
import { SharedModule } from '../shared/shared.module';
import { LetterMgmtRoutingModule } from './letter-mgmt-routing.module';
import { NoticeComponent } from './notice/notice.component';
import { NoticeHistoryDialogComponent } from './notice/notice-history-dialog/notice-history-dialog.component';
import { NoticePreviewDialogComponent } from './notice/notice-preview-dialog/notice-preview-dialog.component';
import { CallContactDialogComponent } from './notice/call-contact-dialog/call-contact-dialog.component';
import { SpeedPostDialogComponent } from './notice/speed-post-dialog/speed-post-dialog.component';
import { LoanInterestCertificateComponent } from './certificates/loan-interest/loan-interest.component';
import { ShareCertificateComponent } from './certificates/share-certificate/share-certificate.component';
import { BalanceCertificateComponent } from './certificates/balance-certificate/balance-certificate.component';
import { Form60Component } from './certificates/form-60/form-60.component';
import { Form15ghComponent } from './certificates/form-15gh/form-15gh.component';
import { HypothecationCertificateComponent } from './certificates/hypothecation/hypothecation.component';
import { NocCertificateComponent } from './certificates/noc/noc.component';
import { CertificatePreviewDialogComponent } from './certificates/certificate-preview-dialog/certificate-preview-dialog.component';
@NgModule({
imports: [
SharedModule,
LetterMgmtRoutingModule,
NoticeComponent,
NoticeHistoryDialogComponent,
NoticePreviewDialogComponent,
CallContactDialogComponent,
SpeedPostDialogComponent,
LoanInterestCertificateComponent,
ShareCertificateComponent,
BalanceCertificateComponent,
Form60Component,
Form15ghComponent,
HypothecationCertificateComponent,
NocCertificateComponent,
CertificatePreviewDialogComponent
]
})
export class LetterMgmtModule {}
@@ -0,0 +1,209 @@
<!--
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="ccd-root">
<!-- HEADER -->
<div class="ccd-head">
<div class="ccd-head-l">
<fa-icon icon="phone" class="ccd-head-ic"></fa-icon>
<div>
<h2 class="ccd-head-t">Reach {{ data.loan.customerName }}</h2>
<div class="ccd-head-s">
<span class="mono">{{ data.loan.accountNo }}</span> · <span class="mono">{{ data.loan.customerId }}</span> ·
{{ data.loan.branch }}
</div>
</div>
</div>
<button type="button" class="ccd-x" (click)="close()" matTooltip="Close">
<fa-icon icon="times"></fa-icon>
</button>
</div>
<!-- CONTACT LIST -->
<div class="ccd-body">
@for (c of contacts; track c.kind) {
<div class="ccd-row-group">
<div class="ccd-row" [ngClass]="'accent-' + c.accent" [class.is-logging]="isLogging(c)">
<span class="ccd-row-strip"></span>
<div class="ccd-row-icon">
<fa-icon [icon]="c.icon"></fa-icon>
</div>
<div class="ccd-row-text">
<div class="ccd-row-label">{{ c.label }}</div>
@if (c.name) {
<div class="ccd-row-name">{{ c.name }}</div>
}
<div class="ccd-row-mobile mono">+91 {{ formatMobile(c.mobile) }}</div>
@if (latestLog(c); as log) {
<div class="ccd-row-lastlog" [ngClass]="'tone-' + outcomeTone(log.outcome)">
<fa-icon [icon]="outcomeIcon(log.outcome)"></fa-icon>
<span class="ccd-lastlog-l">{{ outcomeLabel(log.outcome) }}</span>
<span class="ccd-lastlog-m">· {{ log.calledAt.slice(0, 16) }}</span>
@if (log.promiseAmount && log.promiseDate) {
<span class="ccd-lastlog-ptp">
PTP ₹{{ log.promiseAmount | number: '1.0-0' }} by {{ log.promiseDate | date: 'd MMM' }}
</span>
}
</div>
}
</div>
<div class="ccd-row-actions">
<button
type="button"
class="ccd-act ccd-act--primary"
(click)="call(c)"
matTooltip="Call now"
aria-label="Call"
>
<fa-icon icon="phone"></fa-icon>
<span>Call</span>
</button>
<button
type="button"
class="ccd-act ccd-act--log"
[class.is-on]="isLogging(c)"
(click)="isLogging(c) ? cancelLogging() : startLogging(c)"
matTooltip="Log call outcome"
aria-label="Log call outcome"
>
<fa-icon icon="clipboard-check"></fa-icon>
</button>
<button
type="button"
class="ccd-act"
(click)="whatsapp(c)"
matTooltip="Send a WhatsApp message"
aria-label="WhatsApp"
>
<fa-icon icon="comment-dots"></fa-icon>
</button>
<button type="button" class="ccd-act" (click)="copy(c)" matTooltip="Copy number" aria-label="Copy number">
<fa-icon icon="copy"></fa-icon>
</button>
</div>
</div>
<!-- Inline disposition picker -->
@if (isLogging(c)) {
<div class="ccd-log">
<div class="ccd-log-h">
<fa-icon icon="clipboard-check" class="ccd-log-h-ic"></fa-icon>
<span>Log call outcome</span>
<span class="ccd-log-h-sub">{{ c.name || c.label }} · +91 {{ formatMobile(c.mobile) }}</span>
</div>
<div class="ccd-log-l">Pick an outcome</div>
<div class="ccd-chips">
@for (o of outcomeOptions; track o.value) {
<button
type="button"
class="ccd-chip"
[ngClass]="'tone-' + o.tone"
[class.is-on]="isOutcomePicked(o.value)"
(click)="pickOutcome(o.value)"
>
<fa-icon [icon]="o.icon"></fa-icon>
<span>{{ o.label }}</span>
</button>
}
</div>
@if (isPromiseToPay) {
<div class="ccd-ptp">
<div class="ccd-field">
<label>Promise amount (₹)</label>
<input type="number" min="1" step="1" [(ngModel)]="draft.promiseAmount" placeholder="e.g. 50000" />
</div>
<div class="ccd-field">
<label>Promise date</label>
<input type="date" [(ngModel)]="draft.promiseDate" />
</div>
</div>
}
<div class="ccd-field">
<label>Notes (optional)</label>
<textarea
rows="2"
maxlength="240"
[(ngModel)]="draft.notes"
placeholder="Anything the next agent should know…"
></textarea>
</div>
<div class="ccd-log-actions">
<button type="button" class="ccd-act" (click)="cancelLogging()">
<span>Cancel</span>
</button>
<button type="button" class="ccd-act ccd-act--save" [disabled]="!canSaveLog" (click)="saveLog(c)">
<fa-icon icon="check-circle"></fa-icon>
<span>Save call log</span>
</button>
</div>
</div>
}
</div>
}
@if (contacts.length === 0) {
<div class="ccd-empty">
<fa-icon icon="info-circle"></fa-icon>
<div>
<strong>No contact numbers on file.</strong>
<p>Update the customer profile to add mobile, family or nominee contacts.</p>
</div>
</div>
}
<!-- Recent calls audit list -->
@if (allLogs.length > 0) {
<div class="ccd-history">
<div class="ccd-history-h">
<fa-icon icon="history"></fa-icon>
<span>Recent calls</span>
<span class="ccd-history-c">{{ allLogs.length }}</span>
</div>
<ul class="ccd-history-list">
@for (log of allLogs; track log.id) {
<li class="ccd-history-item" [ngClass]="'tone-' + outcomeTone(log.outcome)">
<div class="ccd-history-row">
<span class="ccd-history-dot"></span>
<span class="ccd-history-outcome">
<fa-icon [icon]="outcomeIcon(log.outcome)"></fa-icon>
{{ outcomeLabel(log.outcome) }}
</span>
<span class="ccd-history-kind">
{{ contactKindLabel(log.contactKind) }}
@if (log.contactName) {
· {{ log.contactName }}
}
</span>
<span class="ccd-history-num mono">+91 {{ formatMobile(log.contactNumber) }}</span>
<span class="ccd-history-meta">{{ log.calledAt.slice(0, 16) }} · {{ log.calledBy }}</span>
</div>
@if (log.promiseAmount && log.promiseDate) {
<div class="ccd-history-ptp">
<fa-icon icon="check-circle"></fa-icon>
Promise ₹{{ log.promiseAmount | number: '1.0-0' }} by
{{ log.promiseDate | date: 'd MMM y' }}
</div>
}
@if (log.notes) {
<div class="ccd-history-notes">"{{ log.notes }}"</div>
}
</li>
}
</ul>
</div>
}
</div>
</div>
@@ -0,0 +1,765 @@
/**
* 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 {
--ccd-blue: #1d4ed8;
--ccd-blue-d: #1e40af;
--ccd-blue-l: #eff6ff;
--ccd-blue-tint: #dbeafe;
--ccd-teal: #0d9488;
--ccd-teal-l: #ccfbf1;
--ccd-amber: #d97706;
--ccd-amber-l: #fef3c7;
--ccd-red: #b91c1c;
--ccd-red-l: #fee2e2;
--ccd-red-tint: #fecaca;
--ccd-green: #15803d;
--ccd-green-l: #dcfce7;
--ccd-purple: #6d28d9;
--ccd-purple-l: #ede9fe;
--ccd-g50: #f8fafc;
--ccd-g100: #f1f5f9;
--ccd-g200: #e2e8f0;
--ccd-g300: #cbd5e1;
--ccd-g500: #64748b;
--ccd-g700: #334155;
--ccd-g900: #0f172a;
display: block;
}
.ccd-root {
background: #fff;
padding: 14px 18px 18px;
width: 100%;
max-height: 86vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ───────── Header ───────── */
.ccd-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding-bottom: 12px;
margin-bottom: 12px;
border-bottom: 1px solid var(--ccd-g200);
}
.ccd-head-l {
display: flex;
align-items: center;
gap: 12px;
}
.ccd-head-ic {
width: 38px;
height: 38px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 12px;
background: var(--ccd-green);
color: #fff;
font-size: 15px;
}
.ccd-head-t {
margin: 0;
font-size: 16px;
font-weight: 800;
color: var(--ccd-g900);
line-height: 1.2;
}
.ccd-head-s {
margin-top: 3px;
font-size: 11.5px;
color: var(--ccd-g500);
}
.ccd-x {
width: 32px;
height: 32px;
border-radius: 999px;
border: 1px solid var(--ccd-g200);
background: #fff;
color: var(--ccd-g500);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 13px;
flex-shrink: 0;
transition: all 0.12s ease;
&:hover {
background: var(--ccd-red-l);
color: var(--ccd-red);
border-color: var(--ccd-red-tint);
}
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
/* ───────── Body ───────── */
.ccd-body {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
overflow-y: auto;
padding-right: 2px;
}
/* ───────── Contact row ───────── */
.ccd-row {
position: relative;
display: grid;
grid-template-columns: 44px 1fr auto;
align-items: center;
gap: 12px;
padding: 12px 14px 12px 18px;
background: #fff;
border: 1px solid var(--ccd-g200);
border-radius: 12px;
transition:
border-color 0.15s ease,
box-shadow 0.15s ease;
overflow: hidden;
&:hover {
border-color: var(--ccd-blue-tint);
box-shadow: 0 4px 14px -8px rgb(15 23 42 / 18%);
}
}
.ccd-row-strip {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
.accent-blue & {
background: var(--ccd-blue);
}
.accent-teal & {
background: var(--ccd-teal);
}
.accent-amber & {
background: var(--ccd-amber);
}
.accent-purple & {
background: var(--ccd-purple);
}
}
.ccd-row-icon {
width: 38px;
height: 38px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 14px;
flex-shrink: 0;
.accent-blue & {
background: var(--ccd-blue-l);
color: var(--ccd-blue-d);
}
.accent-teal & {
background: var(--ccd-teal-l);
color: var(--ccd-teal);
}
.accent-amber & {
background: var(--ccd-amber-l);
color: var(--ccd-amber);
}
.accent-purple & {
background: var(--ccd-purple-l);
color: var(--ccd-purple);
}
}
.ccd-row-text {
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
line-height: 1.25;
}
.ccd-row-label {
font-size: 9.5px;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--ccd-g500);
}
.ccd-row-name {
font-size: 13px;
font-weight: 700;
color: var(--ccd-g900);
}
.ccd-row-mobile {
font-size: 12.5px;
font-weight: 600;
color: var(--ccd-g700);
font-variant-numeric: tabular-nums;
letter-spacing: 0.02em;
}
/* ───────── Row actions ───────── */
.ccd-row-actions {
display: inline-flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.ccd-act {
display: inline-flex;
align-items: center;
gap: 6px;
height: 34px;
padding: 0 11px;
border-radius: 8px;
border: 1px solid var(--ccd-g200);
background: #fff;
color: var(--ccd-g700);
font-size: 12.5px;
font-weight: 700;
cursor: pointer;
transition: all 0.12s ease;
fa-icon {
font-size: 12px;
color: var(--ccd-g500);
}
span {
letter-spacing: 0.02em;
}
&:hover {
background: var(--ccd-blue-l);
border-color: var(--ccd-blue-tint);
color: var(--ccd-blue-d);
fa-icon {
color: var(--ccd-blue-d);
}
}
&:focus-visible {
outline: 2px solid var(--ccd-blue);
outline-offset: 2px;
}
}
/* Primary "Call" button → solid green so it pops as the recommended action. */
.ccd-act.ccd-act--primary {
background: var(--ccd-green);
color: #fff;
border-color: var(--ccd-green);
fa-icon {
color: #fff;
}
&:hover {
background: #14532d;
border-color: #14532d;
color: #fff;
fa-icon {
color: #fff;
}
}
}
/* "Log call outcome" toggle button. */
.ccd-act.ccd-act--log {
width: 34px;
padding: 0;
justify-content: center;
&.is-on {
background: var(--ccd-blue-d);
border-color: var(--ccd-blue-d);
color: #fff;
fa-icon {
color: #fff;
}
}
}
/* "Save call log" — primary blue when enabled. */
.ccd-act.ccd-act--save {
background: var(--ccd-blue-d);
color: #fff;
border-color: var(--ccd-blue-d);
fa-icon {
color: #fff;
}
&:hover:not(:disabled) {
background: var(--ccd-blue);
border-color: var(--ccd-blue);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
/* ───────── Latest-log strip on the contact row ───────── */
.ccd-row-group {
display: flex;
flex-direction: column;
gap: 0;
}
.ccd-row.is-logging {
border-color: var(--ccd-blue-tint);
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
.ccd-row-lastlog {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 4px;
padding: 3px 9px;
border-radius: 999px;
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.02em;
align-self: flex-start;
fa-icon {
font-size: 10px;
}
&.tone-green {
background: var(--ccd-green-l);
color: var(--ccd-green);
}
&.tone-blue {
background: var(--ccd-blue-l);
color: var(--ccd-blue-d);
}
&.tone-amber {
background: var(--ccd-amber-l);
color: var(--ccd-amber);
}
&.tone-red {
background: var(--ccd-red-l);
color: var(--ccd-red);
}
}
.ccd-lastlog-m {
font-weight: 500;
color: inherit;
opacity: 0.85;
}
.ccd-lastlog-ptp {
margin-left: 6px;
padding-left: 6px;
border-left: 1px solid currentcolor;
opacity: 0.85;
}
/* ───────── Inline disposition picker ───────── */
.ccd-log {
border: 1px solid var(--ccd-blue-tint);
border-top: 0;
border-bottom-left-radius: 12px;
border-bottom-right-radius: 12px;
background: linear-gradient(180deg, #f8fafc 0%, #fff 100%);
padding: 12px 14px 14px;
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 8px;
}
.ccd-log-h {
display: flex;
align-items: center;
gap: 8px;
font-size: 12.5px;
font-weight: 800;
color: var(--ccd-g900);
padding-bottom: 8px;
border-bottom: 1px dashed var(--ccd-g200);
}
.ccd-log-h-ic {
width: 22px;
height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 7px;
background: var(--ccd-blue-d);
color: #fff;
font-size: 11px;
}
.ccd-log-h-sub {
margin-left: auto;
font-size: 11px;
font-weight: 600;
color: var(--ccd-g500);
letter-spacing: 0.01em;
}
.ccd-log-l {
font-size: 10px;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--ccd-g500);
}
/* Outcome chip group */
.ccd-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.ccd-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 11px;
border-radius: 999px;
border: 1px solid var(--ccd-g200);
background: #fff;
color: var(--ccd-g700);
font-size: 11.5px;
font-weight: 700;
letter-spacing: 0.01em;
cursor: pointer;
transition: all 0.12s ease;
fa-icon {
font-size: 11px;
color: var(--ccd-g500);
}
&:hover:not(.is-on) {
background: var(--ccd-g50);
border-color: var(--ccd-g300);
}
&.is-on {
color: #fff;
fa-icon {
color: #fff;
}
&.tone-green {
background: var(--ccd-green);
border-color: var(--ccd-green);
}
&.tone-blue {
background: var(--ccd-blue-d);
border-color: var(--ccd-blue-d);
}
&.tone-amber {
background: var(--ccd-amber);
border-color: var(--ccd-amber);
}
&.tone-red {
background: var(--ccd-red);
border-color: var(--ccd-red);
}
}
}
/* Promise-to-pay fields */
.ccd-ptp {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
padding: 10px 12px;
background: var(--ccd-green-l);
border: 1px solid #bbf7d0;
border-radius: 10px;
}
.ccd-field {
display: flex;
flex-direction: column;
gap: 4px;
label {
font-size: 10px;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--ccd-g500);
}
input,
textarea {
box-sizing: border-box;
width: 100%;
border: 1px solid var(--ccd-g200);
border-radius: 8px;
padding: 8px 11px;
background: #fff;
font-size: 12.5px;
color: var(--ccd-g900);
font-family: inherit;
outline: none;
transition: border-color 0.15s ease;
resize: vertical;
&:focus {
border-color: var(--ccd-blue);
}
}
textarea {
min-height: 50px;
}
}
.ccd-log-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 4px;
}
/* ───────── Recent-calls history list ───────── */
.ccd-history {
margin-top: 14px;
padding-top: 14px;
border-top: 1px dashed var(--ccd-g200);
}
.ccd-history-h {
display: flex;
align-items: center;
gap: 8px;
font-size: 10px;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--ccd-g500);
margin-bottom: 8px;
fa-icon {
font-size: 11px;
color: var(--ccd-blue);
}
}
.ccd-history-c {
margin-left: 4px;
padding: 2px 8px;
border-radius: 999px;
background: var(--ccd-g100);
color: var(--ccd-g500);
font-size: 9.5px;
font-weight: 800;
}
.ccd-history-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.ccd-history-item {
padding: 8px 10px 8px 14px;
border: 1px solid var(--ccd-g200);
border-radius: 9px;
background: #fff;
position: relative;
&.tone-green {
border-left: 4px solid var(--ccd-green);
}
&.tone-blue {
border-left: 4px solid var(--ccd-blue);
}
&.tone-amber {
border-left: 4px solid var(--ccd-amber);
}
&.tone-red {
border-left: 4px solid var(--ccd-red);
}
}
.ccd-history-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
font-size: 11.5px;
}
.ccd-history-outcome {
display: inline-flex;
align-items: center;
gap: 5px;
font-weight: 800;
color: var(--ccd-g900);
fa-icon {
font-size: 10.5px;
}
.tone-green & fa-icon {
color: var(--ccd-green);
}
.tone-blue & fa-icon {
color: var(--ccd-blue-d);
}
.tone-amber & fa-icon {
color: var(--ccd-amber);
}
.tone-red & fa-icon {
color: var(--ccd-red);
}
}
.ccd-history-kind {
font-size: 10.5px;
font-weight: 600;
color: var(--ccd-g500);
}
.ccd-history-num {
font-size: 11.5px;
font-weight: 600;
color: var(--ccd-g700);
font-variant-numeric: tabular-nums;
}
.ccd-history-meta {
margin-left: auto;
font-size: 11px;
color: var(--ccd-g500);
}
.ccd-history-ptp {
margin-top: 6px;
padding: 4px 9px;
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 700;
color: var(--ccd-green);
background: var(--ccd-green-l);
border-radius: 8px;
fa-icon {
font-size: 10px;
}
}
.ccd-history-notes {
margin-top: 6px;
font-size: 11.5px;
font-style: italic;
color: var(--ccd-g500);
line-height: 1.45;
}
/* ───────── Empty state ───────── */
.ccd-empty {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 18px 16px;
border: 1px dashed var(--ccd-g300);
border-radius: 10px;
background: var(--ccd-g50);
color: var(--ccd-g700);
fa-icon {
color: var(--ccd-blue);
font-size: 18px;
margin-top: 2px;
}
p {
margin: 4px 0 0;
font-size: 12.5px;
color: var(--ccd-g500);
}
}
@media (width <= 540px) {
.ccd-row {
grid-template-columns: 38px 1fr;
grid-template-areas:
'icon text'
'actions actions';
row-gap: 8px;
}
.ccd-row-icon {
grid-area: icon;
}
.ccd-row-text {
grid-area: text;
}
.ccd-row-actions {
grid-area: actions;
justify-content: flex-end;
}
}
@@ -0,0 +1,306 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatTooltip } from '@angular/material/tooltip';
import { MatSnackBar } from '@angular/material/snack-bar';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { CallContactKind, CallLog, CallOutcome, OverdueLoan } from '../notice.component';
export interface CallContactDialogData {
loan: OverdueLoan;
}
export type ContactKind = CallContactKind;
export interface ContactEntry {
kind: ContactKind;
/** Display label e.g. "Customer", "Alternative", "Family — Spouse", "Nominee" */
label: string;
/** Person's name, when applicable (customer / family / nominee). */
name?: string;
/** Just the 10-digit India mobile (no +91). */
mobile: string;
/** Icon name (already registered) */
icon: string;
/** Color accent for the row strip. */
accent: 'blue' | 'teal' | 'purple' | 'amber';
}
interface OutcomeOption {
value: CallOutcome;
label: string;
/** Pill color theme — green = good, amber = neutral, red = bad. */
tone: 'green' | 'blue' | 'amber' | 'red';
icon: string;
}
interface LogDraft {
outcome: CallOutcome | null;
notes: string;
promiseAmount: number | null;
promiseDate: string;
}
@Component({
selector: 'mifosx-call-contact-dialog',
templateUrl: './call-contact-dialog.component.html',
styleUrls: ['./call-contact-dialog.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
CommonModule,
FormsModule,
FaIconComponent,
MatTooltip
]
})
export class CallContactDialogComponent {
private dialogRef = inject(MatDialogRef<CallContactDialogComponent>);
private snack = inject(MatSnackBar);
data: CallContactDialogData = inject(MAT_DIALOG_DATA);
/** Tracks which row is currently in disposition-logging mode (one at a time). */
loggingKind: ContactKind | null = null;
/** Per-session draft state for the inline disposition picker. */
draft: LogDraft = this.emptyDraft();
readonly outcomeOptions: OutcomeOption[] = [
{ value: 'PROMISE_TO_PAY', label: 'Promise to Pay', tone: 'green', icon: 'check-circle' },
{ value: 'SETTLED', label: 'Settled / Paid', tone: 'green', icon: 'check-double' },
{ value: 'CALLBACK_REQUESTED', label: 'Callback Requested', tone: 'blue', icon: 'history' },
{ value: 'NOT_REACHABLE', label: 'Not Reachable', tone: 'amber', icon: 'phone' },
{ value: 'SWITCHED_OFF', label: 'Switched Off', tone: 'amber', icon: 'mobile-alt' },
{ value: 'RNR', label: 'Ring No Response', tone: 'amber', icon: 'bell' },
{ value: 'BUSY', label: 'Busy', tone: 'amber', icon: 'hourglass-half' },
{ value: 'WRONG_NUMBER', label: 'Wrong Number', tone: 'red', icon: 'exclamation-triangle' },
{ value: 'REFUSED', label: 'Refused / Abusive', tone: 'red', icon: 'ban' }
];
/** All reachable contacts for the loan — only the ones that actually have a number. */
get contacts(): ContactEntry[] {
const l = this.data.loan;
const out: ContactEntry[] = [];
out.push({
kind: 'PRIMARY',
label: 'Customer (primary)',
name: l.customerName,
mobile: l.mobile,
icon: 'user',
accent: 'blue'
});
if (l.mobileAlt) {
out.push({
kind: 'ALTERNATIVE',
label: 'Alternative number',
name: l.customerName,
mobile: l.mobileAlt,
icon: 'mobile-alt',
accent: 'teal'
});
}
if (l.familyMobile) {
out.push({
kind: 'FAMILY',
label: l.familyRelation ? `Family — ${l.familyRelation}` : 'Family',
name: l.familyName,
mobile: l.familyMobile,
icon: 'users',
accent: 'amber'
});
}
if (l.nomineeMobile) {
out.push({
kind: 'NOMINEE',
label: 'Nominee',
name: l.nomineeName,
mobile: l.nomineeMobile,
icon: 'shield-alt',
accent: 'purple'
});
}
return out;
}
/** "9818877665" → "98188 77665" — easier to read in the row. */
formatMobile(m: string): string {
if (!m) return '';
const trimmed = m.replace(/\D/g, '').slice(-10);
return trimmed.length === 10 ? `${trimmed.slice(0, 5)} ${trimmed.slice(5)}` : m;
}
call(c: ContactEntry): void {
window.location.href = `tel:+91${c.mobile}`;
this.snack.open(`Calling ${c.name || 'contact'} · +91 ${this.formatMobile(c.mobile)}…`, 'OK', {
duration: 2500
});
}
whatsapp(c: ContactEntry): void {
const msg = `Dear ${c.name || 'Sir/Madam'}, regarding your loan account ${this.data.loan.accountNo} with our bank — kindly contact our Recovery & Collections team.`;
const url = `https://wa.me/91${c.mobile}?text=${encodeURIComponent(msg)}`;
window.open(url, '_blank', 'noopener');
this.snack.open(`Opening WhatsApp for ${c.name || 'contact'}…`, 'OK', { duration: 2000 });
}
copy(c: ContactEntry): void {
const number = `+91 ${this.formatMobile(c.mobile)}`;
const fallback = (): void => {
const ta = document.createElement('textarea');
ta.value = number;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
try {
document.execCommand('copy');
} catch {
/* ignore */
}
document.body.removeChild(ta);
};
if (navigator?.clipboard?.writeText) {
navigator.clipboard.writeText(number).catch(fallback);
} else {
fallback();
}
this.snack.open(`Copied ${number} to clipboard.`, 'OK', { duration: 2000 });
}
// ───────── Disposition logging ─────────
isLogging(c: ContactEntry): boolean {
return this.loggingKind === c.kind;
}
startLogging(c: ContactEntry): void {
this.loggingKind = c.kind;
this.draft = this.emptyDraft();
}
cancelLogging(): void {
this.loggingKind = null;
this.draft = this.emptyDraft();
}
pickOutcome(o: CallOutcome): void {
this.draft.outcome = this.draft.outcome === o ? null : o;
}
isOutcomePicked(o: CallOutcome): boolean {
return this.draft.outcome === o;
}
get isPromiseToPay(): boolean {
return this.draft.outcome === 'PROMISE_TO_PAY';
}
/** True once an outcome is picked and (for PTP) the promise fields are filled. */
get canSaveLog(): boolean {
if (!this.draft.outcome) return false;
if (this.isPromiseToPay) {
if (!this.draft.promiseAmount || this.draft.promiseAmount <= 0) return false;
if (!this.draft.promiseDate) return false;
}
return true;
}
saveLog(c: ContactEntry): void {
if (!this.canSaveLog || !this.draft.outcome) return;
const log: CallLog = {
id: 'CL' + Date.now().toString().slice(-9),
contactKind: c.kind,
contactNumber: c.mobile,
contactName: c.name,
calledAt: this.nowStr(),
calledBy: 'Current User',
outcome: this.draft.outcome,
promiseAmount: this.isPromiseToPay ? Number(this.draft.promiseAmount) : undefined,
promiseDate: this.isPromiseToPay ? this.draft.promiseDate : undefined,
notes: (this.draft.notes || '').trim() || undefined
};
this.data.loan.callLogs = [
log,
...(this.data.loan.callLogs || [])
];
const label = this.outcomeLabel(log.outcome);
this.snack.open(`Call disposition saved · ${label}`, 'OK', { duration: 2400 });
this.cancelLogging();
}
// ───────── Display helpers ─────────
/** Most-recent call disposition for a particular contact number (or null). */
latestLog(c: ContactEntry): CallLog | null {
const logs = this.data.loan.callLogs || [];
return logs.find((l) => l.contactNumber === c.mobile) || null;
}
/** All logs sorted newest first — drives the "Recent calls" footer list. */
get allLogs(): CallLog[] {
return this.data.loan.callLogs || [];
}
outcomeLabel(o: CallOutcome): string {
return this.outcomeOptions.find((x) => x.value === o)?.label || o;
}
outcomeTone(o: CallOutcome): 'green' | 'blue' | 'amber' | 'red' {
return this.outcomeOptions.find((x) => x.value === o)?.tone || 'amber';
}
outcomeIcon(o: CallOutcome): string {
return this.outcomeOptions.find((x) => x.value === o)?.icon || 'phone';
}
contactKindLabel(k: ContactKind): string {
switch (k) {
case 'PRIMARY':
return 'Primary';
case 'ALTERNATIVE':
return 'Alternative';
case 'FAMILY':
return 'Family';
case 'NOMINEE':
return 'Nominee';
}
}
close(): void {
this.dialogRef.close();
}
private emptyDraft(): LogDraft {
return { outcome: null, notes: '', promiseAmount: null, promiseDate: '' };
}
private nowStr(): string {
const d = new Date();
const pad = (n: number) => String(n).padStart(2, '0');
return (
d.getFullYear() +
'-' +
pad(d.getMonth() + 1) +
'-' +
pad(d.getDate()) +
' ' +
pad(d.getHours()) +
':' +
pad(d.getMinutes()) +
':' +
pad(d.getSeconds())
);
}
}
@@ -0,0 +1,148 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="nhd-root">
<!-- HEADER -->
<div class="nhd-head">
<div class="nhd-head-l">
<fa-icon icon="history" class="nhd-head-ic"></fa-icon>
<div>
<h2 class="nhd-head-t">
{{ data.loan.customerName }} <small>· {{ data.loan.accountNo }}</small>
</h2>
<div class="nhd-head-s">
{{ data.loan.notices.length }} notice(s) generated · Overdue
<strong>₹{{ data.loan.overdueTotal | number: '1.0-0' }}</strong> · DPD {{ data.loan.daysOverdue }}
</div>
</div>
</div>
<button type="button" class="nhd-x" (click)="close()" matTooltip="Close">
<fa-icon icon="times"></fa-icon>
</button>
</div>
<!-- TIMELINE -->
<div class="nhd-body">
@if (data.loan.notices.length === 0) {
<div class="nhd-empty">
<fa-icon icon="info-circle"></fa-icon>
<div>
<strong>No notices have been generated yet.</strong>
<p>Click the Generate icon on the row to issue a first-stage notice.</p>
</div>
</div>
} @else {
<ol class="nhd-timeline">
@for (n of data.loan.notices; track n.id) {
<li class="nhd-tl-item" [ngClass]="'accent-' + stageColor(n.stage)">
<span class="nhd-tl-dot">
<fa-icon icon="file-signature"></fa-icon>
</span>
<div class="nhd-tl-card">
<div class="nhd-tl-head">
<span class="nhd-stage-pill" [ngClass]="'nhd-stage-pill--' + stageColor(n.stage)">
{{ stageLabel(n.stage) }}
</span>
<span class="nhd-tl-meta">
Generated <strong>{{ n.generatedAt }}</strong> by {{ n.generatedBy }}
</span>
<span class="nhd-tl-id mono">{{ n.id }}</span>
<span class="nhd-tl-actions">
<button
type="button"
class="nhd-action-btn"
(click)="viewNotice(n)"
matTooltip="View / print this notice"
>
<fa-icon icon="eye"></fa-icon>
</button>
<button type="button" class="nhd-action-btn" (click)="downloadNotice(n)" matTooltip="Download PDF">
<fa-icon icon="download"></fa-icon>
</button>
</span>
</div>
@if (n.sends.length === 0) {
<div class="nhd-no-sends">
<fa-icon icon="hourglass-half"></fa-icon>
Generated but not yet dispatched.
</div>
} @else {
<table class="nhd-send-table">
<thead>
<tr>
<th>Channel</th>
<th>Destination</th>
<th>Sent</th>
<th>By</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (s of n.sends; track s.sentAt + s.channel) {
<tr>
<td>
<span class="nhd-channel">
<fa-icon [icon]="channelIcon(s.channel)"></fa-icon>
{{ channelLabel(s.channel) }}
</span>
</td>
<td class="small mono">{{ s.destination }}</td>
<td class="small mono">{{ s.sentAt }}</td>
<td class="small">{{ s.sentBy }}</td>
<td>
<span class="nhd-status-pill" [ngClass]="'nhd-status-pill--' + statusColor(s.status)">
{{ s.status }}
</span>
</td>
</tr>
@if (s.channel === 'POST' && (s.consignmentNo || s.postOffice)) {
<tr class="nhd-post-row">
<td colspan="5">
<div class="nhd-post-meta">
@if (s.consignmentNo) {
<span class="nhd-post-chip">
<fa-icon icon="truck-fast"></fa-icon>
<span class="nhd-post-l">Consignment</span>
<span class="nhd-post-v mono">{{ s.consignmentNo }}</span>
</span>
}
@if (s.postOffice) {
<span class="nhd-post-chip">
<fa-icon icon="map-marker-alt"></fa-icon>
<span class="nhd-post-l">Post office</span>
<span class="nhd-post-v">{{ s.postOffice }}</span>
</span>
}
@if (s.bookedDate) {
<span class="nhd-post-chip">
<span class="nhd-post-l">Booked</span>
<span class="nhd-post-v">{{ s.bookedDate | date: 'd MMM y' }}</span>
</span>
}
@if (s.postage) {
<span class="nhd-post-chip">
<span class="nhd-post-l">Postage</span>
<span class="nhd-post-v">₹{{ s.postage | number: '1.0-0' }}</span>
</span>
}
</div>
</td>
</tr>
}
}
</tbody>
</table>
}
</div>
</li>
}
</ol>
}
</div>
</div>
@@ -0,0 +1,435 @@
/**
* 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 {
--nhd-blue: #1d4ed8;
--nhd-blue-d: #1e40af;
--nhd-blue-l: #eff6ff;
--nhd-blue-tint: #dbeafe;
--nhd-green: #15803d;
--nhd-green-l: #dcfce7;
--nhd-amber: #d97706;
--nhd-amber-l: #fef3c7;
--nhd-red: #b91c1c;
--nhd-red-l: #fee2e2;
--nhd-red-tint: #fecaca;
--nhd-purple: #6d28d9;
--nhd-purple-l: #ede9fe;
--nhd-purple-tint: #ddd6fe;
--nhd-g50: #f8fafc;
--nhd-g100: #f1f5f9;
--nhd-g200: #e2e8f0;
--nhd-g300: #cbd5e1;
--nhd-g500: #64748b;
--nhd-g700: #334155;
--nhd-g900: #0f172a;
display: block;
}
.nhd-root {
background: #fff;
padding: 16px 20px 22px;
width: 100%;
max-height: 84vh;
display: flex;
flex-direction: column;
}
/* ───────── Header ───────── */
.nhd-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
padding-bottom: 12px;
margin-bottom: 14px;
border-bottom: 1px solid var(--nhd-g200);
}
.nhd-head-l {
display: flex;
align-items: center;
gap: 12px;
}
.nhd-head-ic {
width: 40px;
height: 40px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 12px;
background: var(--nhd-blue-d);
color: #fff;
font-size: 17px;
}
.nhd-head-t {
margin: 0;
font-size: 16px;
font-weight: 800;
color: var(--nhd-g900);
small {
font-weight: 500;
color: var(--nhd-g500);
font-size: 12.5px;
margin-left: 4px;
}
}
.nhd-head-s {
margin-top: 3px;
font-size: 12px;
color: var(--nhd-g500);
strong {
color: var(--nhd-g900);
}
}
.nhd-x {
width: 32px;
height: 32px;
border-radius: 999px;
border: 1px solid var(--nhd-g200);
background: #fff;
color: var(--nhd-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(--nhd-red-l);
color: var(--nhd-red);
border-color: var(--nhd-red-tint);
}
}
/* ───────── Body / Empty ───────── */
.nhd-body {
flex: 1;
overflow: auto;
}
.nhd-empty {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 18px 16px;
border: 1px dashed var(--nhd-g300);
border-radius: 10px;
background: var(--nhd-g50);
color: var(--nhd-g700);
fa-icon {
color: var(--nhd-blue);
font-size: 18px;
margin-top: 2px;
}
p {
margin: 4px 0 0;
font-size: 12.5px;
color: var(--nhd-g500);
}
}
/* ───────── Timeline ───────── */
.nhd-timeline {
list-style: none;
margin: 0;
padding: 6px 0 0 28px;
position: relative;
&::before {
content: '';
position: absolute;
left: 11px;
top: 0;
bottom: 8px;
width: 2px;
background: var(--nhd-g200);
}
}
.nhd-tl-item {
position: relative;
padding-bottom: 16px;
}
.nhd-tl-dot {
position: absolute;
left: -28px;
top: 4px;
width: 24px;
height: 24px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 11px;
color: #fff;
background: var(--nhd-blue-d);
border: 3px solid #fff;
box-shadow: 0 0 0 2px var(--nhd-blue-tint);
.accent-amber & {
background: var(--nhd-amber);
box-shadow: 0 0 0 2px #fde68a;
}
.accent-red & {
background: var(--nhd-red);
box-shadow: 0 0 0 2px var(--nhd-red-tint);
}
.accent-purple & {
background: var(--nhd-purple);
box-shadow: 0 0 0 2px var(--nhd-purple-tint);
}
}
.nhd-tl-card {
border: 1px solid var(--nhd-g200);
border-radius: 12px;
background: #fff;
padding: 12px 14px;
}
.nhd-tl-head {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 10px;
padding-bottom: 10px;
border-bottom: 1px dashed var(--nhd-g200);
}
.nhd-stage-pill {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.02em;
text-transform: uppercase;
&--blue {
background: var(--nhd-blue-l);
color: var(--nhd-blue-d);
}
&--amber {
background: var(--nhd-amber-l);
color: var(--nhd-amber);
}
&--red {
background: var(--nhd-red-l);
color: var(--nhd-red);
}
&--purple {
background: var(--nhd-purple-l);
color: var(--nhd-purple);
}
}
.nhd-tl-meta {
font-size: 11.5px;
color: var(--nhd-g500);
strong {
color: var(--nhd-g900);
}
}
.nhd-tl-id {
margin-left: auto;
font-size: 11px;
color: var(--nhd-g500);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.nhd-tl-actions {
display: inline-flex;
gap: 4px;
}
.nhd-action-btn {
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--nhd-g200);
background: #fff;
color: var(--nhd-g500);
border-radius: 7px;
cursor: pointer;
font-size: 11px;
transition:
background 0.15s,
color 0.15s,
border-color 0.15s;
&:hover {
background: var(--nhd-blue-l);
color: var(--nhd-blue-d);
border-color: var(--nhd-blue-tint);
}
}
.nhd-no-sends {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 12.5px;
color: var(--nhd-amber);
fa-icon {
font-size: 12px;
}
}
/* ───────── Send table ───────── */
.nhd-send-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
th {
text-align: left;
padding: 6px 10px;
background: var(--nhd-g50);
color: var(--nhd-g500);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 10px;
border-bottom: 1px solid var(--nhd-g200);
}
td {
padding: 8px 10px;
border-bottom: 1px solid var(--nhd-g100);
color: var(--nhd-g900);
vertical-align: top;
}
tr:last-child td {
border-bottom: 0;
}
.small {
font-size: 11.5px;
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
color: var(--nhd-g700);
}
}
.nhd-channel {
display: inline-flex;
align-items: center;
gap: 6px;
font-weight: 700;
color: var(--nhd-g900);
fa-icon {
color: var(--nhd-blue);
font-size: 12px;
}
}
.nhd-status-pill {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 999px;
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.04em;
&--green {
background: var(--nhd-green-l);
color: var(--nhd-green);
}
&--amber {
background: var(--nhd-amber-l);
color: var(--nhd-amber);
}
&--red {
background: var(--nhd-red-l);
color: var(--nhd-red);
}
}
/* ───────── Postal-dispatch detail strip (under POST send rows) ───────── */
.nhd-post-row td {
padding: 6px 10px 10px;
background: #fff7ed;
border-bottom: 1px solid var(--nhd-g100);
}
.nhd-post-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.nhd-post-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 9px;
border-radius: 999px;
background: #fff;
border: 1px solid #fed7aa;
font-size: 11px;
color: var(--nhd-g900);
fa-icon {
font-size: 10px;
color: #c2410c;
}
}
.nhd-post-l {
font-size: 9.5px;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--nhd-g500);
}
.nhd-post-v {
font-size: 11.5px;
font-weight: 700;
color: var(--nhd-g900);
&.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
}
@@ -0,0 +1,133 @@
/**
* 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, MatDialog, MatDialogRef } from '@angular/material/dialog';
import { MatTooltip } from '@angular/material/tooltip';
import { MatSnackBar } from '@angular/material/snack-bar';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import {
downloadNoticePdf,
NoticeChannel,
NoticeEvent,
NoticeStage,
OverdueLoan,
SendStatus
} from '../notice.component';
import {
NoticePreviewDialogComponent,
NoticePreviewDialogData
} from '../notice-preview-dialog/notice-preview-dialog.component';
export interface NoticeHistoryDialogData {
loan: OverdueLoan;
}
@Component({
selector: 'mifosx-notice-history-dialog',
templateUrl: './notice-history-dialog.component.html',
styleUrls: ['./notice-history-dialog.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
CommonModule,
FaIconComponent,
MatTooltip
]
})
export class NoticeHistoryDialogComponent {
private dialogRef = inject(MatDialogRef<NoticeHistoryDialogComponent>);
private dialog = inject(MatDialog);
private snack = inject(MatSnackBar);
data: NoticeHistoryDialogData = inject(MAT_DIALOG_DATA);
close(): void {
this.dialogRef.close();
}
/** Opens the printable preview for a specific notice in the timeline. */
viewNotice(notice: NoticeEvent): void {
const data: NoticePreviewDialogData = { loan: this.data.loan, notice };
this.dialog.open(NoticePreviewDialogComponent, {
width: '880px',
maxWidth: '96vw',
maxHeight: '92vh',
autoFocus: false,
panelClass: 'ntc-preview-dialog',
data
});
}
/** Builds and downloads the PDF version of a specific historical notice. */
downloadNotice(notice: NoticeEvent): void {
downloadNoticePdf(this.data.loan, notice);
this.snack.open(`Notice ${notice.id} downloaded as PDF.`, 'OK', { duration: 2200 });
}
stageLabel(s: NoticeStage): string {
return s === 'FIRST'
? '1st Notice'
: s === 'SECOND'
? '2nd Notice'
: s === 'FINAL'
? 'Final Notice'
: 'Legal Notice';
}
stageColor(s: NoticeStage): string {
return s === 'FIRST' ? 'blue' : s === 'SECOND' ? 'amber' : s === 'FINAL' ? 'red' : 'purple';
}
channelLabel(c: NoticeChannel): string {
switch (c) {
case 'EMAIL':
return 'Email';
case 'POST':
return 'Speed Post';
case 'COURIER':
return 'Courier';
case 'SMS':
return 'SMS';
case 'WHATSAPP':
return 'WhatsApp';
case 'IN_PERSON':
return 'In-Person';
}
}
channelIcon(c: NoticeChannel): string {
switch (c) {
case 'EMAIL':
return 'envelope';
case 'POST':
return 'stamp';
case 'COURIER':
return 'paper-plane';
case 'SMS':
return 'mobile-alt';
case 'WHATSAPP':
return 'comment-alt';
case 'IN_PERSON':
return 'user-tie';
}
}
statusColor(s: SendStatus): string {
switch (s) {
case 'DELIVERED':
return 'green';
case 'PENDING':
return 'amber';
case 'FAILED':
case 'BOUNCED':
return 'red';
}
}
}
@@ -0,0 +1,150 @@
<!--
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="npd-root">
<!-- DIALOG TOOLBAR (hidden when printing) -->
<div class="npd-toolbar no-print">
<div class="npd-toolbar-l">
<fa-icon icon="file-signature" class="npd-toolbar-ic"></fa-icon>
<div>
<div class="npd-toolbar-t">{{ stageLabel(data.notice.stage) }}</div>
<div class="npd-toolbar-s">{{ data.notice.id }} · {{ data.notice.generatedAt }}</div>
</div>
</div>
<div class="npd-toolbar-r">
<button class="npd-tb-btn" (click)="printNotice()" matTooltip="Print or Save as PDF">
<fa-icon icon="print"></fa-icon> Print / PDF
</button>
<button class="npd-tb-btn" (click)="downloadPdf()" matTooltip="Download as PDF">
<fa-icon icon="download"></fa-icon> Download PDF
</button>
<button class="npd-tb-btn npd-tb-x" (click)="close()" matTooltip="Close">
<fa-icon icon="times"></fa-icon>
</button>
</div>
</div>
<!-- LETTER PAGE -->
<div class="npd-page" [ngClass]="'tone-' + letter.tone">
<!-- Bank letterhead -->
<div class="npd-letterhead">
<div class="npd-bank-name">{{ bankName }}</div>
<div class="npd-bank-sub">{{ bankAddress }}</div>
<div class="npd-bank-sub">{{ bankRegNo }} &middot; {{ bankContact }}</div>
</div>
<!-- Notice meta -->
<div class="npd-meta-row">
<div>
<div class="npd-meta-l">Notice Reference</div>
<div class="npd-meta-v mono">{{ data.notice.id }}</div>
</div>
<div>
<div class="npd-meta-l">Date of Issue</div>
<div class="npd-meta-v">{{ data.notice.generatedAt.slice(0, 10) | date: 'd MMMM y' }}</div>
</div>
<div>
<div class="npd-meta-l">Stage</div>
<div class="npd-meta-v">
<span class="npd-stage-pill" [ngClass]="'npd-stage-pill--' + letter.tone">
{{ stageLabel(data.notice.stage) }}
</span>
</div>
</div>
</div>
<!-- Addressee -->
<div class="npd-block">
<div class="npd-block-l">To,</div>
<div class="npd-block-v">
<div>
<strong>{{ data.loan.customerName }}</strong>
</div>
<div>
Customer ID: <span class="mono">{{ data.loan.customerId }}</span>
</div>
<div>{{ data.loan.address }}</div>
<div>Mob: +91 {{ data.loan.mobile }} &middot; {{ data.loan.email }}</div>
</div>
</div>
<!-- Subject -->
<div class="npd-subject">
<span class="npd-subject-l">Subject:</span>
<span class="npd-subject-v">{{ letter.subject }}</span>
</div>
<!-- Body -->
<p class="npd-salutation">{{ letter.salutation }}</p>
<p class="npd-para">{{ letter.intro }}</p>
@for (p of letter.body; track p) {
<p class="npd-para">{{ p }}</p>
}
<!-- Loan summary table -->
<table class="npd-summary">
<tbody>
<tr>
<td>Loan Account No.</td>
<td class="mono">
<strong>{{ data.loan.accountNo }}</strong>
</td>
<td>Branch</td>
<td>{{ data.loan.branch }}</td>
</tr>
<tr>
<td>Days Past Due</td>
<td>
<strong>{{ data.loan.daysOverdue }}</strong>
</td>
<td>Last Paid Date</td>
<td>{{ data.loan.lastPaidDate | date: 'd MMM y' }}</td>
</tr>
<tr>
<td>Overdue Principal</td>
<td>₹{{ data.loan.overduePrincipal | number: '1.2-2' }}</td>
<td>Overdue Interest</td>
<td>₹{{ data.loan.overdueInterest | number: '1.2-2' }}</td>
</tr>
<tr class="npd-summary-total">
<td colspan="3">TOTAL DUES OUTSTANDING</td>
<td>
<strong>₹{{ data.loan.overdueTotal | number: '1.2-2' }}</strong>
</td>
</tr>
</tbody>
</table>
<!-- Call to action -->
<p class="npd-para npd-cta">{{ letter.callToAction }}</p>
<!-- Consequences -->
<p class="npd-para npd-consequences">{{ letter.consequences }}</p>
<!-- Sign-off -->
<p class="npd-signoff">{{ letter.signOff }}</p>
<div class="npd-signature">
<div>
<div class="npd-sig-line"></div>
<div class="npd-sig-name">
For <strong>{{ bankName }}</strong>
</div>
<div class="npd-sig-role">Authorised Signatory &mdash; Recovery & Collections</div>
<div class="npd-sig-role mono">{{ data.loan.branch }}</div>
</div>
</div>
<!-- Footer -->
<div class="npd-footer">
<span
>Notice generated by <strong>{{ data.notice.generatedBy }}</strong> on {{ data.notice.generatedAt }}.</span
>
<span class="mono">{{ data.notice.id }}</span>
</div>
</div>
</div>
@@ -0,0 +1,501 @@
/**
* 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 {
--npd-blue: #1d4ed8;
--npd-blue-d: #1e40af;
--npd-blue-l: #eff6ff;
--npd-blue-tint: #dbeafe;
--npd-amber: #d97706;
--npd-amber-l: #fef3c7;
--npd-red: #b91c1c;
--npd-red-l: #fee2e2;
--npd-red-tint: #fecaca;
--npd-purple: #6d28d9;
--npd-purple-l: #ede9fe;
--npd-g50: #f8fafc;
--npd-g100: #f1f5f9;
--npd-g200: #e2e8f0;
--npd-g300: #cbd5e1;
--npd-g500: #64748b;
--npd-g700: #334155;
--npd-g900: #0f172a;
display: block;
}
.npd-root {
background: var(--npd-g100);
width: 100%;
max-height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ───────── Toolbar (hidden on print) ───────── */
.npd-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding: 12px 16px;
background: #fff;
border-bottom: 1px solid var(--npd-g200);
flex-shrink: 0;
}
.npd-toolbar-l {
display: flex;
align-items: center;
gap: 12px;
}
.npd-toolbar-ic {
width: 36px;
height: 36px;
border-radius: 10px;
background: var(--npd-blue-d);
color: #fff;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 15px;
}
.npd-toolbar-t {
font-size: 14px;
font-weight: 800;
color: var(--npd-g900);
}
.npd-toolbar-s {
font-size: 11.5px;
color: var(--npd-g500);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.npd-toolbar-r {
display: inline-flex;
gap: 8px;
}
.npd-tb-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 12px;
border: 1px solid var(--npd-g200);
background: #fff;
color: var(--npd-g700);
font-size: 12.5px;
font-weight: 700;
border-radius: 8px;
cursor: pointer;
transition:
background 0.15s,
color 0.15s,
border-color 0.15s;
fa-icon {
font-size: 12px;
color: var(--npd-blue);
}
&:hover {
background: var(--npd-blue-l);
border-color: var(--npd-blue-tint);
color: var(--npd-blue-d);
}
&.npd-tb-x {
width: 34px;
padding: 0;
justify-content: center;
&:hover {
background: var(--npd-red-l);
color: var(--npd-red);
border-color: var(--npd-red-tint);
fa-icon {
color: var(--npd-red);
}
}
}
}
/* ───────── Letter page (A4 feel) ───────── */
.npd-page {
background: #fff;
border: 1px solid var(--npd-g200);
padding: 38px 48px 32px;
margin: 18px auto;
width: min(720px, 94%);
border-top: 6px solid var(--npd-blue-d);
font-family: Georgia, 'Times New Roman', Times, serif;
color: var(--npd-g900);
line-height: 1.6;
overflow: auto;
&.tone-firm {
border-top-color: var(--npd-amber);
}
&.tone-urgent {
border-top-color: var(--npd-red);
}
&.tone-legal {
border-top-color: var(--npd-purple);
}
}
/* ───────── Letterhead ───────── */
.npd-letterhead {
text-align: center;
padding-bottom: 14px;
margin-bottom: 18px;
border-bottom: 1px solid var(--npd-g200);
}
.npd-bank-name {
font-size: 19px;
font-weight: 800;
letter-spacing: 0.02em;
color: var(--npd-blue-d);
.tone-firm & {
color: var(--npd-amber);
}
.tone-urgent & {
color: var(--npd-red);
}
.tone-legal & {
color: var(--npd-purple);
}
}
.npd-bank-sub {
font-size: 11.5px;
color: var(--npd-g500);
margin-top: 2px;
font-family:
ui-sans-serif,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
sans-serif;
}
/* ───────── Meta row ───────── */
.npd-meta-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
font-family:
ui-sans-serif,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
sans-serif;
font-size: 12px;
margin-bottom: 16px;
padding-bottom: 14px;
border-bottom: 1px dashed var(--npd-g200);
> div {
display: flex;
flex-direction: column;
gap: 2px;
}
}
.npd-meta-l {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--npd-g500);
}
.npd-meta-v {
font-size: 12.5px;
color: var(--npd-g900);
font-weight: 700;
}
.npd-stage-pill {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 999px;
font-size: 10.5px;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
&--polite {
background: var(--npd-blue-l);
color: var(--npd-blue-d);
}
&--firm {
background: var(--npd-amber-l);
color: var(--npd-amber);
}
&--urgent {
background: var(--npd-red-l);
color: var(--npd-red);
}
&--legal {
background: var(--npd-purple-l);
color: var(--npd-purple);
}
}
/* ───────── Addressee + subject ───────── */
.npd-block {
display: flex;
gap: 8px;
font-family:
ui-sans-serif,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
sans-serif;
font-size: 13px;
margin-bottom: 14px;
}
.npd-block-l {
font-weight: 700;
color: var(--npd-g700);
width: 36px;
flex-shrink: 0;
}
.npd-block-v {
flex: 1;
color: var(--npd-g900);
line-height: 1.5;
}
.npd-subject {
margin: 14px 0;
padding: 8px 0;
border-top: 1px solid var(--npd-g200);
border-bottom: 1px solid var(--npd-g200);
font-size: 13px;
.npd-subject-l {
font-weight: 800;
color: var(--npd-g700);
margin-right: 6px;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 11px;
}
.npd-subject-v {
font-weight: 700;
color: var(--npd-g900);
}
}
/* ───────── Body paragraphs ───────── */
.npd-salutation {
font-size: 13.5px;
margin: 16px 0 8px;
font-weight: 700;
color: var(--npd-g900);
}
.npd-para {
margin: 10px 0;
font-size: 13px;
text-align: justify;
color: var(--npd-g900);
}
.npd-cta {
background: var(--npd-blue-l);
border-left: 4px solid var(--npd-blue-d);
padding: 10px 14px;
font-weight: 600;
border-radius: 0 6px 6px 0;
.tone-firm & {
background: var(--npd-amber-l);
border-left-color: var(--npd-amber);
}
.tone-urgent & {
background: var(--npd-red-l);
border-left-color: var(--npd-red);
}
.tone-legal & {
background: var(--npd-purple-l);
border-left-color: var(--npd-purple);
}
}
.npd-consequences {
font-size: 12px;
color: var(--npd-g700);
font-style: italic;
}
/* ───────── Summary table ───────── */
.npd-summary {
width: 100%;
margin: 16px 0;
border-collapse: collapse;
font-family:
ui-sans-serif,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
sans-serif;
font-size: 12px;
td {
padding: 8px 10px;
border: 1px solid var(--npd-g200);
vertical-align: top;
color: var(--npd-g900);
}
td:nth-child(odd) {
background: var(--npd-g50);
color: var(--npd-g500);
font-weight: 700;
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: 0.04em;
width: 22%;
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.npd-summary-total td {
background: var(--npd-blue-l);
color: var(--npd-blue-d);
font-weight: 800;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.04em;
&:last-child {
text-align: right;
font-variant-numeric: tabular-nums;
}
}
.tone-urgent &,
.tone-legal & {
.npd-summary-total td {
background: var(--npd-red-l);
color: var(--npd-red);
}
}
}
/* ───────── Sign-off ───────── */
.npd-signoff {
margin-top: 24px;
font-size: 13px;
color: var(--npd-g900);
}
.npd-signature {
display: flex;
margin-top: 38px;
}
.npd-sig-line {
width: 220px;
border-top: 1px solid var(--npd-g500);
margin-bottom: 6px;
}
.npd-sig-name {
font-size: 12.5px;
color: var(--npd-g900);
}
.npd-sig-role {
font-size: 11px;
color: var(--npd-g500);
font-family:
ui-sans-serif,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
sans-serif;
}
.npd-footer {
margin-top: 30px;
padding-top: 12px;
border-top: 1px dashed var(--npd-g200);
display: flex;
justify-content: space-between;
align-items: center;
font-family:
ui-sans-serif,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
sans-serif;
font-size: 10.5px;
color: var(--npd-g500);
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
/* ───────── Print: show only the letter, no toolbar, no dialog chrome ───────── */
@media print {
:host {
background: #fff;
}
.no-print {
display: none !important;
}
.npd-root {
background: #fff;
max-height: none;
overflow: visible;
height: auto;
}
.npd-page {
border: 0;
border-top: 4px solid var(--npd-blue-d);
margin: 0;
padding: 24px 32px;
width: 100%;
box-shadow: none;
}
}
@@ -0,0 +1,159 @@
/**
* 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 { downloadNoticePdf, NoticeEvent, NoticeStage, OverdueLoan } from '../notice.component';
export interface NoticePreviewDialogData {
loan: OverdueLoan;
notice: NoticeEvent;
}
interface LetterCopy {
subject: string;
salutation: string;
intro: string;
body: string[];
callToAction: string;
consequences: string;
signOff: string;
tone: 'polite' | 'firm' | 'urgent' | 'legal';
}
@Component({
selector: 'mifosx-notice-preview-dialog',
templateUrl: './notice-preview-dialog.component.html',
styleUrls: ['./notice-preview-dialog.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
CommonModule,
FaIconComponent,
MatTooltip
]
})
export class NoticePreviewDialogComponent {
private dialogRef = inject(MatDialogRef<NoticePreviewDialogComponent>);
data: NoticePreviewDialogData = inject(MAT_DIALOG_DATA);
readonly bankName = 'Nexora Co-operative Bank Ltd.';
readonly bankAddress = 'Head Office, 4th Floor, Nexora Tower, Mumbai 400051';
readonly bankRegNo = 'RBI Lic. No. NX-2018/MH/482';
readonly bankContact = 'recovery@nexorabank.in · 022 6789 0000';
/** The letter body adapts to the notice stage — polite reminder up to legal demand. */
get letter(): LetterCopy {
return buildLetterCopy(this.data.loan, this.data.notice);
}
close(): void {
this.dialogRef.close();
}
printNotice(): void {
window.print();
}
/** Builds and saves the notice as a PDF using the shared jsPDF generator. */
downloadPdf(): void {
downloadNoticePdf(this.data.loan, this.data.notice);
}
stageLabel = stageLabel;
}
function stageLabel(s: NoticeStage): string {
return s === 'FIRST'
? '1st Reminder Notice'
: s === 'SECOND'
? '2nd Reminder Notice'
: s === 'FINAL'
? 'Final Demand Notice'
: 'Legal Notice (Pre-Litigation)';
}
function buildLetterCopy(loan: OverdueLoan, notice: NoticeEvent): LetterCopy {
const overdue = `Rs. ${loan.overdueTotal.toLocaleString('en-IN')}`;
const lastPaid = loan.lastPaidDate;
switch (notice.stage) {
case 'FIRST':
return {
tone: 'polite',
subject: `Reminder — Overdue dues on Loan A/c ${loan.accountNo}`,
salutation: `Dear ${loan.customerName},`,
intro:
'We refer to your captioned loan account with our bank. Our records indicate that your account has fallen into arrears as detailed below.',
body: [
`This is a gentle reminder requesting you to regularise the account at the earliest. The overdue amount of ${overdue} (as on date) has remained unpaid since your last payment on ${lastPaid}.`,
'Maintaining a healthy repayment record helps preserve your credit score and avoids additional penal interest. If you have already made the payment in the last 24 hours, kindly disregard this notice.'
],
callToAction: `Action Requested: Please remit the total dues of ${overdue} within 7 days from the date of this notice through any of our accepted channels — branch deposit, NEFT/RTGS, IMPS, or UPI.`,
consequences:
'Failure to clear the dues may attract additional penal interest charges in accordance with the sanction terms and may lead to escalation to the next stage of recovery action.',
signOff: 'Yours sincerely,'
};
case 'SECOND':
return {
tone: 'firm',
subject: `Second Reminder — Overdue dues on Loan A/c ${loan.accountNo}`,
salutation: `Dear ${loan.customerName},`,
intro:
'Despite our earlier reminder, our records show that the dues in your captioned loan account remain unsettled.',
body: [
`The overdue amount of ${overdue} continues to remain outstanding. Your last payment was received on ${lastPaid}, after which no further repayment has been credited to the account.`,
'We urge you to treat this matter with utmost urgency and clear the dues immediately to avoid further action on the account.'
],
callToAction: `Action Required: Kindly regularise the account by paying ${overdue} within 7 days from this notice. If you have already made the payment, please share the UTR / transaction reference with our recovery team so we can update the records.`,
consequences:
'Continued non-payment may result in your account being classified as a Special Mention Account (SMA) per RBI guidelines, reported to credit bureaus (CIBIL/Experian/Equifax/CRIF), and escalated for further recovery action including issuance of a final demand notice.',
signOff: 'Regards,'
};
case 'FINAL':
return {
tone: 'urgent',
subject: `FINAL DEMAND NOTICE — Loan A/c ${loan.accountNo}`,
salutation: `Dear ${loan.customerName},`,
intro:
'This communication serves as the FINAL DEMAND NOTICE in respect of your captioned loan account, which continues to remain in default despite our prior reminders.',
body: [
`As on date, an amount of ${overdue} is overdue and payable. Your last payment was received on ${lastPaid}. No payments have been credited since then.`,
'You are hereby called upon to clear the entire overdue amount along with applicable penal interest within 15 days from the date of this notice.'
],
callToAction: `Action Required: Payment of ${overdue} (plus applicable penal interest accrued up to the date of payment) must reach the bank within 15 days from receipt of this notice.`,
consequences:
'Failure to comply within the stipulated period shall constrain the bank, without further reference to you, to (i) classify your account as a Non-Performing Asset (NPA) per RBI norms, (ii) report the same to all credit information companies, and (iii) initiate appropriate legal and recovery proceedings, including action under the SARFAESI Act, 2002 / RDDB Act, 1993 where applicable, with all costs and consequences thereof being recoverable from you.',
signOff: 'For and on behalf of the bank,'
};
case 'LEGAL':
default:
return {
tone: 'legal',
subject: `LEGAL NOTICE — Loan A/c ${loan.accountNo} — Demand for Payment`,
salutation: `To ${loan.customerName},`,
intro:
'Take notice that the bank, through its undersigned authorised officer, hereby serves upon you this Legal Notice in respect of the captioned loan account.',
body: [
`That you availed credit facilities from the bank vide Loan Account No. ${loan.accountNo}. That as on the date of this notice, a sum of ${overdue} is outstanding and payable by you to the bank, having remained unpaid since ${lastPaid}, in spite of repeated reminders and demands made by the bank.`,
'That your continued default constitutes a breach of the sanction terms and loan agreement entered into between you and the bank, and the entire outstanding has become recallable forthwith.'
],
callToAction: `You are hereby called upon to pay to the bank a sum of ${overdue} together with further interest, penal charges, and costs, within 15 days from receipt of this notice, failing which the bank shall be constrained to initiate appropriate legal proceedings against you, including but not limited to proceedings under the SARFAESI Act, 2002, the Negotiable Instruments Act, 1881, and / or recovery proceedings before the Debts Recovery Tribunal (DRT), entirely at your risk, cost, and consequences.`,
consequences:
'Please treat this notice with the seriousness it deserves. The bank reserves the right to take any and all such steps as may be advised in law, without any further reference to you, in the event of your continued default. A copy of this notice is being retained for legal record.',
signOff: 'Authorised Officer,'
};
}
}
@@ -0,0 +1,371 @@
<!--
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="ntc-root">
<!-- HEADER -->
<div class="ntc-head">
<div class="ntc-head-l">
<fa-icon icon="file-alt" class="ntc-head-ic"></fa-icon>
<div>
<h2 class="ntc-head-t">Loan Overdue Notices</h2>
<div class="ntc-head-s">
Generate, dispatch, and audit notices for accounts with overdue dues. Bulk operations supported.
</div>
</div>
</div>
</div>
<!-- KPI STRIP -->
<div class="ntc-kpis">
<div class="ntc-kpi ntc-kpi--blue">
<fa-icon icon="exclamation-triangle" class="ntc-kpi-ic"></fa-icon>
<div>
<div class="ntc-kpi-n">{{ totalOverdueAccounts | number }}</div>
<div class="ntc-kpi-l">Overdue Accounts</div>
</div>
</div>
<div class="ntc-kpi ntc-kpi--red">
<fa-icon icon="coins" class="ntc-kpi-ic"></fa-icon>
<div>
<div class="ntc-kpi-n">₹{{ totalOverdueAmount | number: '1.0-0' }}</div>
<div class="ntc-kpi-l">Total Overdue Value</div>
</div>
</div>
<div class="ntc-kpi ntc-kpi--green">
<fa-icon icon="paper-plane" class="ntc-kpi-ic"></fa-icon>
<div>
<div class="ntc-kpi-n">{{ noticesGeneratedCount | number }}</div>
<div class="ntc-kpi-l">Notices Generated</div>
</div>
</div>
<div class="ntc-kpi ntc-kpi--amber">
<fa-icon icon="hourglass-half" class="ntc-kpi-ic"></fa-icon>
<div>
<div class="ntc-kpi-n">{{ noticesPendingGenCount | number }}</div>
<div class="ntc-kpi-l">Pending Generation</div>
</div>
</div>
</div>
<!-- FILTERS -->
<div class="ntc-filters">
<div class="ntc-filter">
<label>Account number / Customer</label>
<div class="ntc-input-ic">
<fa-icon icon="search"></fa-icon>
<input type="text" [(ngModel)]="search" placeholder="e.g. LN10029384 or customer name" />
</div>
</div>
<div class="ntc-filter">
<label>Account type</label>
<select [(ngModel)]="accountTypeFilter">
<option value="">All types</option>
@for (o of accountTypeOptions; track o.value) {
<option [value]="o.value">{{ o.label }}</option>
}
</select>
</div>
<div class="ntc-filter ntc-filter--date">
<div class="ntc-date-header">
<label>{{ filterMode === 'range' ? 'Date range' : 'As-on date' }}</label>
<div class="ntc-date-toggle">
<button type="button" [class.is-on]="filterMode === 'range'" (click)="setFilterMode('range')">Range</button>
<button type="button" [class.is-on]="filterMode === 'ason'" (click)="setFilterMode('ason')">As-on</button>
</div>
</div>
@if (filterMode === 'range') {
<div class="ntc-date-row">
<input type="date" [(ngModel)]="startDate" aria-label="From" title="From" />
<input type="date" [(ngModel)]="endDate" aria-label="To" title="To" />
</div>
} @else {
<div class="ntc-date-row">
<input type="date" [(ngModel)]="asOnDate" aria-label="As on" title="As on" />
</div>
}
</div>
@if (search || accountTypeFilter || startDate || endDate || asOnDate) {
<button type="button" class="ntc-clear" (click)="clearFilters()"><fa-icon icon="times"></fa-icon> Clear</button>
}
</div>
<!-- BULK ACTION BAR -->
@if (selectedCount > 0) {
<div class="ntc-bulk-bar">
<div class="ntc-bulk-l">
<fa-icon icon="check-square"></fa-icon>
<strong>{{ selectedCount }}</strong> selected
</div>
<div class="ntc-bulk-r">
<button
class="ntc-btn ntc-btn-primary"
(click)="bulkGenerate()"
matTooltip="Generate next-stage notice for all selected"
>
<fa-icon icon="file-signature"></fa-icon> Generate notices
</button>
<button
class="ntc-btn ntc-btn-secondary"
(click)="bulkSend(['EMAIL'])"
matTooltip="Email the most-recent notice for each"
>
<fa-icon icon="envelope"></fa-icon> Bulk email
</button>
<button
class="ntc-btn ntc-btn-secondary"
(click)="bulkSend(['POST'])"
matTooltip="Speed-post the most-recent notice for each"
>
<fa-icon icon="stamp"></fa-icon> Bulk post
</button>
<button
class="ntc-btn ntc-btn-secondary"
(click)="bulkSend(['EMAIL', 'POST'])"
matTooltip="Send via Email + Speed Post"
>
<fa-icon icon="paper-plane"></fa-icon> Email + Post
</button>
</div>
</div>
}
<!-- Shared "More actions" menu — single mat-menu reused for every row -->
<mat-menu #rowActionsMenu="matMenu" xPosition="before" class="ntc-actions-menu">
<ng-template matMenuContent let-loan="loan">
<!-- Context header: shows whose row this menu belongs to. Click-trapped
so tapping inside doesn't close the menu. -->
<div class="nmh" (click)="$event.stopPropagation()" tabindex="-1">
<!-- Identity row -->
<div class="nmh-id">
<div class="nmh-avatar" [ngClass]="'nmh-avatar--' + dpdBucket(loan.daysOverdue)">
{{ loan.customerName.charAt(0).toUpperCase() }}
</div>
<div class="nmh-id-text">
<div class="nmh-name">{{ loan.customerName }}</div>
<div class="nmh-acc mono">{{ loan.accountNo }}</div>
</div>
<span class="nmh-type">{{ accountTypeLabel(loan.accountType) }}</span>
</div>
<!-- Secondary facts -->
<div class="nmh-facts">
<div class="nmh-fact">
<span class="nmh-fact-l">Customer ID</span>
<span class="nmh-fact-v mono">{{ loan.customerId }}</span>
</div>
<div class="nmh-fact">
<span class="nmh-fact-l">Branch</span>
<span class="nmh-fact-v">{{ loan.branch }}</span>
</div>
</div>
<!-- Hero strip: Overdue + DPD -->
<div class="nmh-hero" [ngClass]="'nmh-hero--' + dpdBucket(loan.daysOverdue)">
<div class="nmh-overdue">
<span class="nmh-overdue-l">Overdue dues</span>
<span class="nmh-overdue-v">₹{{ loan.overdueTotal | number: '1.0-0' }}</span>
</div>
<div class="nmh-dpd">
<span class="nmh-dpd-v">{{ loan.daysOverdue }}</span>
<span class="nmh-dpd-l">days past due</span>
</div>
</div>
</div>
<!-- Action grid — 2 columns, grouped into 3 sections -->
<div class="nma-section">
<div class="nma-section-l">Notice document</div>
<div class="nma-grid">
<button type="button" class="nma-tile" [disabled]="!latestNotice(loan)" (click)="viewLatestNotice(loan)">
<fa-icon icon="eye" class="nma-tile-ic"></fa-icon>
<span class="nma-tile-l">View / Print</span>
</button>
<button type="button" class="nma-tile" [disabled]="!latestNotice(loan)" (click)="downloadLatestNotice(loan)">
<fa-icon icon="download" class="nma-tile-ic"></fa-icon>
<span class="nma-tile-l">Download PDF</span>
</button>
</div>
</div>
<div class="nma-section">
<div class="nma-section-l">Send to customer</div>
<div class="nma-grid">
<button type="button" class="nma-tile" [disabled]="!latestNotice(loan)" (click)="sendNotice(loan, 'EMAIL')">
<fa-icon icon="envelope" class="nma-tile-ic"></fa-icon>
<span class="nma-tile-l">Email</span>
</button>
<button type="button" class="nma-tile" [disabled]="!latestNotice(loan)" (click)="sendNotice(loan, 'POST')">
<fa-icon icon="truck-fast" class="nma-tile-ic"></fa-icon>
<span class="nma-tile-l">Speed Post</span>
</button>
<button type="button" class="nma-tile" [disabled]="!latestNotice(loan)" (click)="sendNotice(loan, 'SMS')">
<fa-icon icon="mobile-alt" class="nma-tile-ic"></fa-icon>
<span class="nma-tile-l">SMS</span>
</button>
<button
type="button"
class="nma-tile"
[disabled]="!latestNotice(loan)"
(click)="sendNotice(loan, 'WHATSAPP')"
>
<fa-icon icon="comment-dots" class="nma-tile-ic"></fa-icon>
<span class="nma-tile-l">WhatsApp</span>
</button>
</div>
</div>
<div class="nma-section">
<div class="nma-section-l">Customer</div>
<div class="nma-grid">
<button type="button" class="nma-tile" (click)="callCustomer(loan)">
<fa-icon icon="phone" class="nma-tile-ic"></fa-icon>
<span class="nma-tile-l">Call customer</span>
</button>
<button type="button" class="nma-tile" (click)="copyAccountNo(loan)">
<fa-icon icon="copy" class="nma-tile-ic"></fa-icon>
<span class="nma-tile-l">Copy A/C No.</span>
</button>
<button type="button" class="nma-tile" [disabled]="loan.notices.length === 0" (click)="openHistory(loan)">
<fa-icon icon="history" class="nma-tile-ic"></fa-icon>
<span class="nma-tile-l">Notice history</span>
</button>
<button type="button" class="nma-tile" (click)="openLoanProfile(loan)">
<fa-icon icon="external-link-alt" class="nma-tile-ic"></fa-icon>
<span class="nma-tile-l">Open loan</span>
</button>
</div>
</div>
</ng-template>
</mat-menu>
<!-- TABLE -->
<div class="ntc-card">
<div class="ntc-table-wrap">
<table class="ntc-table">
<thead>
<tr>
<th class="col-sel">
<input
type="checkbox"
[checked]="allOnPageSelected"
[indeterminate]="someOnPageSelected"
(change)="toggleSelectAll($any($event.target).checked)"
aria-label="Select all on page"
/>
</th>
<th>Customer · A/C</th>
<th>Type</th>
<th>Branch</th>
<th class="num">Overdue</th>
<th>DPD</th>
<th>Last Paid</th>
<th>Notice Status</th>
<th>Last Sent</th>
<th class="col-actions">Actions</th>
</tr>
</thead>
<tbody>
@for (loan of filteredLoans; track loan.accountNo) {
<tr [class.is-selected]="isSelected(loan.accountNo)">
<td class="col-sel">
<input
type="checkbox"
[checked]="isSelected(loan.accountNo)"
(change)="toggleSelect(loan.accountNo, $any($event.target).checked)"
[attr.aria-label]="'Select ' + loan.accountNo"
/>
</td>
<td>
<div class="ntc-cust-name">{{ loan.customerName }}</div>
<div class="mono small muted">{{ loan.accountNo }} · {{ loan.customerId }}</div>
</td>
<td>
<span class="ntc-pill ntc-pill--type">{{ accountTypeLabel(loan.accountType) }}</span>
</td>
<td class="small">{{ loan.branch }}</td>
<td class="num">
<div class="ntc-amount">₹{{ loan.overdueTotal | number: '1.0-0' }}</div>
<div class="small muted">
P ₹{{ loan.overduePrincipal | number: '1.0-0' }} · I ₹{{ loan.overdueInterest | number: '1.0-0' }}
</div>
</td>
<td>
<span class="ntc-dpd-pill" [ngClass]="'ntc-dpd-pill--' + dpdBucket(loan.daysOverdue)">
{{ loan.daysOverdue }} d
</span>
</td>
<td class="small">{{ loan.lastPaidDate | date: 'd MMM y' }}</td>
<td>
@switch (noticeStatus(loan)) {
@case ('NONE') {
<span class="ntc-pill ntc-pill--amber">
<fa-icon icon="exclamation-circle"></fa-icon> Not Generated
</span>
}
@case ('GENERATED') {
<span class="ntc-pill ntc-pill--blue">
<fa-icon icon="file-signature"></fa-icon>
{{ stageLabel(latestNotice(loan)!.stage) }} · Awaiting Send
</span>
}
@case ('SENT') {
<span class="ntc-pill ntc-pill--green">
<fa-icon icon="check-circle"></fa-icon>
{{ stageLabel(latestNotice(loan)!.stage) }} · Sent ({{ totalSendCount(loan) }})
</span>
}
}
</td>
<td class="small">
@if (latestNotice(loan) && latestNotice(loan)!.sends.length > 0) {
<div class="ntc-last-send">
<fa-icon [icon]="channelIcon(latestNotice(loan)!.sends[0].channel)"></fa-icon>
{{ channelLabel(latestNotice(loan)!.sends[0].channel) }}
</div>
<div class="mono small muted">{{ latestNotice(loan)!.sends[0].sentAt }}</div>
} @else {
<span class="muted">—</span>
}
</td>
<td class="col-actions">
<div class="ntc-row-actions">
<button
class="ntc-icon-btn primary"
(click)="generateNotice(loan)"
matTooltip="Generate next-stage notice"
>
<fa-icon icon="file-signature"></fa-icon>
</button>
<button
class="ntc-icon-btn ntc-more-btn"
[matMenuTriggerFor]="rowActionsMenu"
[matMenuTriggerData]="{ loan: loan }"
matTooltip="More actions"
aria-label="More actions"
>
<fa-icon icon="ellipsis-v"></fa-icon>
</button>
</div>
</td>
</tr>
}
@if (filteredLoans.length === 0) {
<tr>
<td colspan="10" class="ntc-empty">
<fa-icon icon="info-circle"></fa-icon>
No overdue accounts match the current filters.
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,97 @@
<!--
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="spd-root">
<!-- HEADER -->
<div class="spd-head">
<div class="spd-head-l">
<fa-icon icon="truck-fast" class="spd-head-ic"></fa-icon>
<div>
<h2 class="spd-head-t">Record Speed Post dispatch</h2>
<div class="spd-head-s">
{{ data.loan.customerName }} · <span class="mono">{{ data.loan.accountNo }}</span> · Notice
<span class="mono">{{ data.notice.id }}</span>
</div>
</div>
</div>
<button type="button" class="spd-x" (click)="cancel()" matTooltip="Cancel">
<fa-icon icon="times"></fa-icon>
</button>
</div>
<!-- DELIVERY ADDRESS SUMMARY -->
<div class="spd-addr">
<fa-icon icon="map-marker-alt" class="spd-addr-ic"></fa-icon>
<div>
<div class="spd-addr-l">Dispatch address</div>
<div class="spd-addr-v">{{ data.loan.address }}</div>
</div>
</div>
<!-- FORM -->
<div class="spd-form">
<div class="spd-field">
<label> Consignment / Article No. <span class="spd-req">*</span> </label>
<input
type="text"
[ngModel]="consignmentNo"
(ngModelChange)="upperCaseConsignment($event)"
placeholder="e.g. EM123456789IN"
maxlength="20"
autocomplete="off"
/>
<small class="spd-hint">
India Post Speed Post 13-char article ID, or any private courier tracking number.
</small>
@if (consignmentNo && !isConsignmentValid) {
<small class="spd-err">
<fa-icon icon="exclamation-triangle"></fa-icon>
Use 6–20 alphanumeric characters (e.g. EM123456789IN).
</small>
}
</div>
<div class="spd-row">
<div class="spd-field">
<label> Post office / Counter <span class="spd-req">*</span> </label>
<input
type="text"
[(ngModel)]="postOffice"
placeholder="e.g. New Delhi GPO"
maxlength="80"
autocomplete="off"
/>
</div>
<div class="spd-field">
<label> Booking date <span class="spd-req">*</span> </label>
<input type="date" [(ngModel)]="bookedDate" />
</div>
</div>
<div class="spd-row">
<div class="spd-field">
<label>Postage paid (₹)</label>
<input type="number" min="0" step="1" [(ngModel)]="postage" placeholder="e.g. 45" />
<small class="spd-hint">Speed Post tariff payable at booking. Leave blank if unknown.</small>
</div>
<div class="spd-field spd-field--empty"></div>
</div>
</div>
<!-- ACTIONS -->
<div class="spd-actions">
<button type="button" class="spd-btn" (click)="cancel()">
<span>Cancel</span>
</button>
<button type="button" class="spd-btn spd-btn--primary" [disabled]="!isFormValid" (click)="confirm()">
<fa-icon icon="truck-fast"></fa-icon>
<span>Confirm dispatch</span>
</button>
</div>
</div>
@@ -0,0 +1,282 @@
/**
* 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 {
--spd-blue: #1d4ed8;
--spd-blue-d: #1e40af;
--spd-blue-l: #eff6ff;
--spd-blue-tint: #dbeafe;
--spd-amber: #d97706;
--spd-amber-l: #fef3c7;
--spd-red: #b91c1c;
--spd-red-l: #fee2e2;
--spd-red-tint: #fecaca;
--spd-green: #15803d;
--spd-green-l: #dcfce7;
--spd-orange: #c2410c;
--spd-orange-l: #ffedd5;
--spd-g50: #f8fafc;
--spd-g100: #f1f5f9;
--spd-g200: #e2e8f0;
--spd-g300: #cbd5e1;
--spd-g500: #64748b;
--spd-g700: #334155;
--spd-g900: #0f172a;
display: block;
}
.spd-root {
background: #fff;
padding: 16px 20px 18px;
width: 100%;
max-height: 90vh;
display: flex;
flex-direction: column;
gap: 14px;
overflow: hidden;
}
/* ───────── Header ───────── */
.spd-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding-bottom: 12px;
border-bottom: 1px solid var(--spd-g200);
}
.spd-head-l {
display: flex;
align-items: center;
gap: 12px;
}
.spd-head-ic {
width: 40px;
height: 40px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 12px;
background: var(--spd-orange);
color: #fff;
font-size: 16px;
}
.spd-head-t {
margin: 0;
font-size: 16px;
font-weight: 800;
color: var(--spd-g900);
line-height: 1.2;
}
.spd-head-s {
margin-top: 3px;
font-size: 11.5px;
color: var(--spd-g500);
}
.spd-x {
width: 32px;
height: 32px;
border-radius: 999px;
border: 1px solid var(--spd-g200);
background: #fff;
color: var(--spd-g500);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 13px;
flex-shrink: 0;
transition: all 0.12s ease;
&:hover {
background: var(--spd-red-l);
color: var(--spd-red);
border-color: var(--spd-red-tint);
}
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
/* ───────── Delivery address card ───────── */
.spd-addr {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 14px;
background: var(--spd-orange-l);
border: 1px solid #fed7aa;
border-radius: 10px;
}
.spd-addr-ic {
color: var(--spd-orange);
font-size: 14px;
margin-top: 2px;
flex-shrink: 0;
}
.spd-addr-l {
font-size: 9.5px;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--spd-g500);
}
.spd-addr-v {
font-size: 12.5px;
font-weight: 600;
color: var(--spd-g900);
line-height: 1.4;
margin-top: 2px;
}
/* ───────── Form ───────── */
.spd-form {
display: flex;
flex-direction: column;
gap: 12px;
}
.spd-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.spd-field {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
label {
font-size: 10px;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--spd-g500);
}
input,
textarea {
box-sizing: border-box;
width: 100%;
height: 38px;
border: 1px solid var(--spd-g200);
border-radius: 8px;
padding: 0 11px;
background: #fff;
font-size: 13px;
color: var(--spd-g900);
font-family: inherit;
outline: none;
transition: border-color 0.15s ease;
&:focus {
border-color: var(--spd-blue);
}
&::placeholder {
color: var(--spd-g300);
}
}
&--empty {
visibility: hidden;
}
}
.spd-req {
color: var(--spd-red);
font-weight: 800;
}
.spd-hint {
font-size: 11px;
font-weight: 500;
color: var(--spd-g500);
line-height: 1.4;
}
.spd-err {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 11px;
font-weight: 700;
color: var(--spd-red);
fa-icon {
font-size: 10.5px;
}
}
/* ───────── Footer actions ───────── */
.spd-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 8px;
border-top: 1px solid var(--spd-g200);
}
.spd-btn {
display: inline-flex;
align-items: center;
gap: 7px;
height: 38px;
padding: 0 16px;
border-radius: 9px;
border: 1px solid var(--spd-g200);
background: #fff;
color: var(--spd-g700);
font-size: 13px;
font-weight: 700;
cursor: pointer;
transition: all 0.12s ease;
fa-icon {
font-size: 12px;
}
&:hover {
background: var(--spd-g50);
border-color: var(--spd-g300);
}
&--primary {
background: var(--spd-orange);
border-color: var(--spd-orange);
color: #fff;
fa-icon {
color: #fff;
}
&:hover:not(:disabled) {
background: #9a3412;
border-color: #9a3412;
color: #fff;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}

Some files were not shown because too many files have changed in this diff Show More