person
-
{{ r.displayName }}
+
+ {{ r.displayName }}
+ @if (isClientAlreadyLinked(r.id)) {
+ · already linked
+ }
+
ID: {{ r.id }} · {{ r.officeName }}
@if (r.dateOfBirth) {
@@ -306,7 +321,14 @@
}
- Father, Mother and Spouse can only be added once.
+
+ info_outline
+
@if (entryMode === 'manual') {
diff --git a/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.scss b/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.scss
index 4805baf5d..ea15dd11d 100644
--- a/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.scss
+++ b/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.scss
@@ -36,6 +36,60 @@
color: #94a3b8;
}
+/* Small info icon in the Relationship field's suffix — hover reveals the
+ "only once" hint without consuming layout space below the field. */
+.fm-rel-hint-icon {
+ color: #94a3b8;
+ font-size: 18px;
+ width: 18px;
+ height: 18px;
+ cursor: help;
+ margin-right: 4px;
+
+ &:hover {
+ color: #1074b9;
+ }
+}
+
+/* Inline error shown above the linked-client search when a duplicate is picked. */
+.fm-linked-error {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin: 8px 0;
+ padding: 8px 12px;
+ background: #fef2f2;
+ border: 1px solid #fecaca;
+ border-radius: 6px;
+ color: #b91c1c;
+ font-size: 12.5px;
+
+ mat-icon {
+ font-size: 18px;
+ width: 18px;
+ height: 18px;
+ }
+}
+
+/* Greyed-out search result for clients already linked as family members. */
+.fm-search-item.is-disabled {
+ opacity: 0.55;
+ cursor: not-allowed;
+ background: #f8fafc;
+
+ &:hover {
+ background: #f8fafc;
+ }
+}
+
+.fm-search-item__taken {
+ margin-left: 6px;
+ font-size: 11px;
+ font-style: italic;
+ color: #dc2626;
+ font-weight: 600;
+}
+
.fm-mode-btn {
display: inline-flex;
align-items: center;
diff --git a/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.ts b/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.ts
index 7412f6876..e04a33843 100644
--- a/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.ts
+++ b/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.ts
@@ -24,6 +24,7 @@ import { ClientsService } from 'app/clients/clients.service';
import { MatCheckbox } from '@angular/material/checkbox';
import { MatIcon } from '@angular/material/icon';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
+import { MatTooltip } from '@angular/material/tooltip';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { DateFormatPipe } from '../../../../pipes/date-format.pipe';
@@ -41,6 +42,7 @@ import { DateFormatPipe } from '../../../../pipes/date-format.pipe';
MatDialogClose,
MatIcon,
MatProgressSpinner,
+ MatTooltip,
FaIconComponent,
DateFormatPipe
]
@@ -245,6 +247,7 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
/** Search existing clients */
onSearch(text: string): void {
this.searchText = text;
+ this.linkedSelectError = '';
clearTimeout(this.searchTimer);
if (!text || text.length < 2) {
this.searchResults = [];
@@ -252,7 +255,9 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
}
this.searching = true;
this.searchTimer = setTimeout(() => {
- this.clientsService.searchByText(text, 0, 8, '', '', true).subscribe({
+ // Family-member link picker: only Individual clients can be linked as a relative —
+ // non-individuals (companies / trusts) have no person-to-person relationship to model here.
+ this.clientsService.searchByText(text, 0, 8, '', '', true, 'INDIVIDUAL').subscribe({
next: (data: any) => {
this.searchResults = data?.content || [];
this.searching = false;
@@ -266,8 +271,38 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
}, 400);
}
+ /**
+ * Inline error shown above the linked-client search box. Currently used
+ * to surface "this client is already linked" when the user tries to
+ * select a duplicate.
+ */
+ linkedSelectError = '';
+
+ /**
+ * True if the given client id is already linked as a family member of
+ * this client. The member currently being edited is excluded so its own
+ * linked client stays selectable.
+ */
+ isClientAlreadyLinked(clientId: number | string): boolean {
+ if (clientId == null) return false;
+ const existing = this.data?.existingMembers || [];
+ if (!existing.length) return false;
+ const editingId = this.data?.context === 'Edit' ? this.data?.member?.id : undefined;
+ const target = String(clientId);
+ return existing.some((m: any) => {
+ if (editingId && m.id === editingId) return false;
+ const linked = m.linkedClientId ?? m._linkedClient?.id;
+ return linked != null && String(linked) === target;
+ });
+ }
+
/** Select a client from search results and fetch full details */
selectClient(client: any): void {
+ if (this.isClientAlreadyLinked(client?.id)) {
+ this.linkedSelectError = `${client?.displayName || 'This client'} is already linked as a family member.`;
+ return;
+ }
+ this.linkedSelectError = '';
this.selectedClient = client;
this.searchResults = [];
this.searchText = '';
diff --git a/src/app/clients/client-stepper/client-family-members-step/client-family-members-step.component.ts b/src/app/clients/client-stepper/client-family-members-step/client-family-members-step.component.ts
index ed40bd081..61a168804 100644
--- a/src/app/clients/client-stepper/client-family-members-step/client-family-members-step.component.ts
+++ b/src/app/clients/client-stepper/client-family-members-step/client-family-members-step.component.ts
@@ -255,7 +255,8 @@ export class ClientFamilyMembersStepComponent implements OnInit {
}
this.nomineeSearching = true;
this.nomineeSearchTimer = setTimeout(() => {
- this.clientsService.searchByText(text, 0, 10, '', '', true).subscribe({
+ // Nominee picker: a nominee must be a natural person, never a corporate entity.
+ this.clientsService.searchByText(text, 0, 10, '', '', true, 'INDIVIDUAL').subscribe({
next: (data: any) => {
this.nomineeSearchResults = data?.content || [];
this.nomineeSearching = false;
@@ -351,7 +352,9 @@ export class ClientFamilyMembersStepComponent implements OnInit {
}
this.guardianSearching = true;
this.guardianSearchTimer = setTimeout(() => {
- this.clientsService.searchByText(text, 0, 10, '', '', true).subscribe({
+ // Guardian picker: a guardian is by definition a natural person responsible for a minor /
+ // dependent — non-individuals can never play that role.
+ this.clientsService.searchByText(text, 0, 10, '', '', true, 'INDIVIDUAL').subscribe({
next: (data: any) => {
this.guardianSearchResults = data?.content || [];
this.guardianSearching = false;
diff --git a/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts b/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts
index bfda03ca4..3eaa4cde0 100644
--- a/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts
+++ b/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts
@@ -987,7 +987,8 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
this.guardianSearching = true;
this.guardianSearchTimer = setTimeout(() => {
- this.clientService.searchByText(text, 0, 10, '', '', true).subscribe({
+ // Guardian picker: only natural persons can be a guardian.
+ this.clientService.searchByText(text, 0, 10, '', '', true, 'INDIVIDUAL').subscribe({
next: (data: any) => {
this.guardianSearchResults = data?.content || [];
this.guardianSearching = false;
diff --git a/src/app/clients/clients-routing.module.ts b/src/app/clients/clients-routing.module.ts
index 68de3a360..8f1c0f3d3 100644
--- a/src/app/clients/clients-routing.module.ts
+++ b/src/app/clients/clients-routing.module.ts
@@ -150,7 +150,7 @@ const routes: Routes = [
{
path: 'edit',
component: EditFamilyMemberComponent,
- data: { title: 'Nominee View', routeParamBreadcrumb: 'familyMemberId' },
+ data: { title: 'Family Member View', routeParamBreadcrumb: 'familyMemberId' },
resolve: {
clientTemplate: ClientTemplateResolver,
editFamilyMember: ClientFamilyMemberResolver
diff --git a/src/app/clients/clients-view/family-members-tab/add-family-member/add-family-member.component.ts b/src/app/clients/clients-view/family-members-tab/add-family-member/add-family-member.component.ts
index a1a175d25..bcdf7d6f5 100644
--- a/src/app/clients/clients-view/family-members-tab/add-family-member/add-family-member.component.ts
+++ b/src/app/clients/clients-view/family-members-tab/add-family-member/add-family-member.component.ts
@@ -51,7 +51,14 @@ export class AddFamilyMemberComponent implements OnInit {
this.route.data.subscribe((data: { clientTemplate: any }) => {
this.addFamilyMemberTemplate = data.clientTemplate.familyMemberOptions;
});
- this.clientId = this.route.parent.parent.snapshot.params['clientId'];
+ // Walk up the route tree until we find a clientId param. Hard-coding parent.parent breaks
+ // when Angular flattens componentless intermediate routes (family-members has no component),
+ // which makes the depth unstable — and produces /clients/undefined/familymembers URLs.
+ let r: ActivatedRoute | null = this.route;
+ while (r && !r.snapshot.params['clientId']) {
+ r = r.parent;
+ }
+ this.clientId = r?.snapshot.params['clientId'];
}
ngOnInit() {
diff --git a/src/app/clients/clients-view/family-members-tab/family-members-tab.component.ts b/src/app/clients/clients-view/family-members-tab/family-members-tab.component.ts
index 285ca163d..3741280cc 100644
--- a/src/app/clients/clients-view/family-members-tab/family-members-tab.component.ts
+++ b/src/app/clients/clients-view/family-members-tab/family-members-tab.component.ts
@@ -14,6 +14,7 @@ import { MatDialog } from '@angular/material/dialog';
/** Custom Components */
import { DeleteDialogComponent } from '../../../shared/delete-dialog/delete-dialog.component';
import { NomineeDialogComponent } from './nominee-dialog/nominee-dialog.component';
+import { ClientFamilyMemberDialogComponent } from '../../client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component';
/** Custom Services */
import { ClientsService } from '../../clients.service';
@@ -75,7 +76,10 @@ export class FamilyMembersTabComponent {
this.route.data.subscribe((data: { clientFamilyMembers: any }) => {
this.clientFamilyMembers = data.clientFamilyMembers || [];
});
- this.clientId = this.route.parent.snapshot.params['clientId'];
+ // Route hierarchy is :clientId → family-members (wrapper) → this component,
+ // so the clientId param lives two levels up, not one.
+ this.clientId =
+ this.route.parent?.parent?.snapshot.params['clientId'] || this.route.parent?.snapshot.params['clientId'];
}
/** Family members (non-nominee entries) */
@@ -101,52 +105,78 @@ export class FamilyMembersTabComponent {
// ═══ Family Member Actions ═══
+ /**
+ * Strip UI-only helper fields (`_linkedClient`, `_marriageCertFile`, `id`)
+ * from the dialog payload so the backend receives a clean DTO. Mirrors the
+ * pattern used in client-family-members-step.editFamilyMember.
+ */
+ private toApiPayload(formValue: any): any {
+ const { id, _linkedClient, _marriageCertFile, ...payload } = formValue || {};
+ return payload;
+ }
+
addFamilyMember() {
this.ensureTemplateLoaded().then(() => {
- const dialogRef = this.dialog.open(NomineeDialogComponent, {
- width: '700px',
+ const dialogRef = this.dialog.open(ClientFamilyMemberDialogComponent, {
+ width: '640px',
+ maxWidth: '95vw',
+ panelClass: 'fm-dialog-panel',
data: {
- title: 'Add Family Member',
- familyMemberOptions: this.familyMemberOptions,
- isFamilyMember: true
+ context: 'Add',
+ options: this.familyMemberOptions,
+ existingMembers: this.familyMembers
}
});
- dialogRef.afterClosed().subscribe((result: any) => {
- if (result) {
- this.clientsService.addFamilyMember(this.clientId, result).subscribe((res: any) => {
- this.clientsService.getClientFamilyMember(this.clientId, res.resourceId).subscribe((member: any) => {
- this.clientFamilyMembers.push(member);
- });
- });
- }
+ dialogRef.afterClosed().subscribe((response: any) => {
+ if (!response?.member) return;
+ const payload = this.toApiPayload(response.member);
+ this.clientsService.addFamilyMember(this.clientId, payload).subscribe({
+ next: () => this.refreshFamilyMembers(),
+ error: (err) => console.error('[FamilyMembers] add failed', err)
+ });
});
});
}
- editFamilyMember(member: any, index: number) {
- const realIndex = this.clientFamilyMembers.indexOf(member);
+ editFamilyMember(member: any, _index: number) {
this.ensureTemplateLoaded().then(() => {
- const dialogRef = this.dialog.open(NomineeDialogComponent, {
- width: '700px',
+ const dialogRef = this.dialog.open(ClientFamilyMemberDialogComponent, {
+ width: '640px',
+ maxWidth: '95vw',
+ panelClass: 'fm-dialog-panel',
data: {
- title: 'Edit Family Member',
- familyMemberOptions: this.familyMemberOptions,
+ context: 'Edit',
+ options: this.familyMemberOptions,
member,
- isFamilyMember: true
+ existingMembers: this.familyMembers
}
});
- dialogRef.afterClosed().subscribe((result: any) => {
- if (result) {
- this.clientsService.editFamilyMember(member.clientId, member.id, result).subscribe(() => {
- this.clientsService.getClientFamilyMember(this.clientId, member.id).subscribe((updated: any) => {
- this.clientFamilyMembers[realIndex] = updated;
- });
- });
- }
+ dialogRef.afterClosed().subscribe((response: any) => {
+ if (!response?.member) return;
+ const payload = this.toApiPayload(response.member);
+ this.clientsService.editFamilyMember(member.clientId || this.clientId, member.id, payload).subscribe({
+ next: () => this.refreshFamilyMembers(),
+ error: (err) => console.error('[FamilyMembers] edit failed', err)
+ });
});
});
}
+ /**
+ * Re-pull the full family-members list from the backend after any
+ * mutation. More resilient than fetching a single resource by id —
+ * sidesteps cases where the POST response doesn't carry the new id in
+ * the expected `resourceId` field.
+ */
+ private refreshFamilyMembers(): void {
+ this.clientsService.getClientFamilyMembers(this.clientId).subscribe({
+ next: (data: any) => {
+ this.clientFamilyMembers = Array.isArray(data) ? data : [];
+ },
+ error: (err) => console.error('[FamilyMembers] refresh failed', err)
+ });
+ }
+
// ═══ Nominee Actions ═══
addNominee() {
diff --git a/src/app/clients/clients-view/nominee-tab/nominee-tab.component.html b/src/app/clients/clients-view/nominee-tab/nominee-tab.component.html
index c5f4498d7..525862d10 100644
--- a/src/app/clients/clients-view/nominee-tab/nominee-tab.component.html
+++ b/src/app/clients/clients-view/nominee-tab/nominee-tab.component.html
@@ -285,7 +285,7 @@
}
@if (selectedClient) {
-
+
@if (linkedProfileImage) {
@@ -295,15 +295,24 @@
(click)="zoomImage(linkedProfileImage, 'Photo')"
/>
} @else {
-
person
+
+ person
+
}
{{ selectedClient.displayName }}
- ID: {{ selectedClient.id }}
+
+ badgeID {{ selectedClient.id }}
+ @if (selectedClient.dateOfBirth) {
+ cake{{ selectedClient.dateOfBirth | dateFormat }}
+ }
@if (selectedClient.mobileNo) {
- · {{ selectedClient.mobileNo }}
+
+ phone{{ selectedClient.mobileNo }}
+
}
@@ -315,9 +324,18 @@
class="zoomable"
(click)="zoomImage(linkedSignatureImage, 'Signature')"
/>
+
Signature
}
-
+
@if (fetchingDetails) {
@@ -327,34 +345,35 @@
}
@if (!fetchingDetails) {
-
- @if (selectedClient.dateOfBirth || selectedClient.panNumber || selectedClient.aadhaarMasked) {
+
+ @if (selectedClient.panNumber || selectedClient.aadhaarMasked) {
- @if (selectedClient.dateOfBirth) {
- DOB {{ selectedClient.dateOfBirth | dateFormat }}
- }
@if (selectedClient.panNumber) {
- PAN {{ selectedClient.panNumber }}
+
+ PAN
+ {{ selectedClient.panNumber }}
@if (linkedKycStatus?.panVerified) {
- check_circle
+ check_circle
}
}
@if (selectedClient.aadhaarMasked) {
- Aadhaar {{ selectedClient.aadhaarMasked }}
+
+ Aadhaar
+ {{ selectedClient.aadhaarMasked }}
@if (linkedKycStatus?.aadhaarVerified) {
- check_circle
+ check_circle
}
}
}
-
+
+
+ edit_note
+ Nomination Details
+
Nomination Type
diff --git a/src/app/clients/clients-view/nominee-tab/nominee-tab.component.scss b/src/app/clients/clients-view/nominee-tab/nominee-tab.component.scss
index c5980ff99..e2c4b6d47 100644
--- a/src/app/clients/clients-view/nominee-tab/nominee-tab.component.scss
+++ b/src/app/clients/clients-view/nominee-tab/nominee-tab.component.scss
@@ -520,12 +520,13 @@ $primary-light: #e8f0fe;
.nom-linked-card {
display: flex;
align-items: center;
- gap: 8px;
- padding: 8px 12px;
- background: #f8fafc;
- border: 1px solid #e2e8f0;
- border-radius: 6px;
- margin-bottom: 8px;
+ gap: 14px;
+ padding: 12px 14px;
+ background: linear-gradient(135deg, #f0f7ff 0%, #fff 60%);
+ border: 1px solid #c7dbf2;
+ border-radius: 10px;
+ margin-bottom: 10px;
+ box-shadow: 0 1px 3px rgb(15 23 42 / 4%);
&__left {
flex-shrink: 0;
@@ -536,44 +537,127 @@ $primary-light: #e8f0fe;
}
&__photo {
- width: 36px;
- height: 36px;
+ width: 48px;
+ height: 48px;
border-radius: 50%;
object-fit: cover;
- border: 2px solid #e2e8f0;
+ border: 2px solid #fff;
+ box-shadow: 0 0 0 1px #c7dbf2;
+
+ &--placeholder {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ background: #e0e7ff;
+ color: #4f46e5;
+
+ mat-icon {
+ font-size: 24px;
+ width: 24px;
+ height: 24px;
+ }
+ }
}
&__info {
flex: 1;
display: flex;
flex-direction: column;
+ gap: 4px;
+ min-width: 0;
}
&__name {
- font-size: 13px;
- font-weight: 600;
- color: #1e293b;
+ font-size: 14px;
+ font-weight: 700;
+ color: #0f172a;
}
&__meta {
- font-size: 11px;
- color: #64748b;
- font-family: 'Roboto Mono', monospace;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px 12px;
+ font-size: 11.5px;
+ color: #475569;
+ }
+
+ &__meta-item {
+ display: inline-flex;
+ align-items: center;
+ gap: 3px;
+
+ mat-icon {
+ font-size: 13px;
+ width: 13px;
+ height: 13px;
+ color: #94a3b8;
+ }
}
&__sig {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 2px;
flex-shrink: 0;
+ padding: 4px 6px;
+ background: #fff;
+ border: 1px solid #e2e8f0;
+ border-radius: 6px;
img {
- height: 24px;
- max-width: 60px;
+ height: 28px;
+ max-width: 72px;
object-fit: contain;
- border: 1px solid #e2e8f0;
- border-radius: 3px;
- background: #fff;
- padding: 1px 3px;
+ cursor: pointer;
}
}
+
+ &__sig-label {
+ font-size: 9px;
+ font-weight: 600;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: #94a3b8;
+ }
+
+ &__close {
+ flex-shrink: 0;
+ width: 32px !important;
+ height: 32px !important;
+ line-height: 32px !important;
+ color: #64748b;
+
+ mat-icon {
+ font-size: 18px;
+ width: 18px;
+ height: 18px;
+ }
+
+ &:hover {
+ color: #dc2626;
+ }
+ }
+}
+
+/* Section heading between linked card and nominee fields */
+.nom-section-heading {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin: 6px 0 8px;
+ font-size: 12px;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ color: #475569;
+
+ mat-icon {
+ font-size: 16px;
+ width: 16px;
+ height: 16px;
+ color: #94a3b8;
+ }
}
.nom-fetching-hint {
@@ -585,32 +669,38 @@ $primary-light: #e8f0fe;
margin: -4px 0 8px;
}
-/* Read-only KYC row */
+/* Read-only KYC chips */
.nom-readonly-row {
display: flex;
flex-wrap: wrap;
- gap: 16px;
- padding: 8px 12px;
- background: #f8fafc;
- border: 1px solid #e2e8f0;
- border-radius: 6px;
- margin-bottom: 10px;
+ gap: 8px;
+ margin-bottom: 12px;
}
.nom-ro-item {
- font-size: 12px;
- color: #1e293b;
display: inline-flex;
align-items: center;
- gap: 4px;
+ gap: 6px;
+ padding: 4px 10px;
+ background: #f1f5f9;
+ border: 1px solid #e2e8f0;
+ border-radius: 999px;
+ font-size: 12px;
+ color: #1e293b;
}
.nom-ro-label {
font-size: 10px;
- font-weight: 600;
- color: #94a3b8;
+ font-weight: 700;
+ color: #64748b;
text-transform: uppercase;
- margin-right: 2px;
+ letter-spacing: 0.04em;
+}
+
+.nom-ro-value {
+ font-family: 'SF Mono', 'Roboto Mono', Consolas, monospace;
+ font-size: 11.5px;
+ font-weight: 600;
}
.nom-ro-ok {
@@ -654,25 +744,34 @@ $primary-light: #e8f0fe;
margin-top: 4px;
}
-/* Guardian */
+/* Guardian — amber sub-card to denote the minor-dependency */
.nom-guardian-section {
display: flex;
align-items: center;
- gap: 8px;
+ gap: 10px;
flex-wrap: wrap;
- margin-bottom: 8px;
+ padding: 10px 12px;
+ background: #fffbeb;
+ border: 1px solid #fde68a;
+ border-radius: 8px;
+ margin-bottom: 10px;
}
.nom-guardian-label {
display: flex;
align-items: center;
- gap: 4px;
- font-size: 12px;
- font-weight: 600;
+ gap: 5px;
+ font-size: 11.5px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
color: #92400e;
mat-icon {
font-size: 16px;
+ width: 16px;
+ height: 16px;
+ color: #d97706;
}
}
diff --git a/src/app/clients/clients-view/nominee-tab/nominee-tab.component.ts b/src/app/clients/clients-view/nominee-tab/nominee-tab.component.ts
index 99731db5f..69f781318 100644
--- a/src/app/clients/clients-view/nominee-tab/nominee-tab.component.ts
+++ b/src/app/clients/clients-view/nominee-tab/nominee-tab.component.ts
@@ -294,7 +294,8 @@ export class NomineeTabComponent implements OnInit {
}
this.searching = true;
this.searchTimer = setTimeout(() => {
- this.clientsService.searchByText(text, 0, 8, '', '', true).subscribe({
+ // Nominee picker (post-create tab): nominees must be natural persons.
+ this.clientsService.searchByText(text, 0, 8, '', '', true, 'INDIVIDUAL').subscribe({
next: (data: any) => {
this.searchResults = data?.content || [];
this.searching = false;
@@ -412,7 +413,8 @@ export class NomineeTabComponent implements OnInit {
}
this.guardianSearching = true;
this.guardianSearchTimer = setTimeout(() => {
- this.clientsService.searchByText(text, 0, 8, '', '', true).subscribe({
+ // Guardian picker (post-create tab): guardians must be natural persons.
+ this.clientsService.searchByText(text, 0, 8, '', '', true, 'INDIVIDUAL').subscribe({
next: (data: any) => {
this.guardianSearchResults = data?.content || [];
this.guardianSearching = false;
diff --git a/src/app/clients/clients.service.ts b/src/app/clients/clients.service.ts
index 3b7e201c4..920320470 100644
--- a/src/app/clients/clients.service.ts
+++ b/src/app/clients/clients.service.ts
@@ -582,12 +582,20 @@ export class ClientsService {
pageSize: number,
sortAttribute: string = '',
sortDirection: string = '',
- activeOnly: boolean = false
+ activeOnly: boolean = false,
+ legalFormType: string = ''
) {
const innerRequest: any = { text };
if (activeOnly) {
innerRequest.activeOnly = true;
}
+ // Restrict to one legal form when caller asks for it. Used by guardian/nominee/family-member
+ // pickers — non-individual entities (companies, partnerships) make no sense as a "guardian"
+ // or "spouse", so the SPA passes 'INDIVIDUAL' there. Backend treats this as authoritative;
+ // omitting the param still returns all forms (backwards-compatible for the general client list).
+ if (legalFormType) {
+ innerRequest.legalFormType = legalFormType;
+ }
let request: any = {
request: innerRequest,
page,
diff --git a/src/app/core/authentication/authentication.service.ts b/src/app/core/authentication/authentication.service.ts
index 3467bd2b1..5b9828cd7 100644
--- a/src/app/core/authentication/authentication.service.ts
+++ b/src/app/core/authentication/authentication.service.ts
@@ -480,6 +480,24 @@ export class AuthenticationService {
);
}
+ /**
+ * Finalises login when a TFA access token has already been minted server-side (DSC factor).
+ * The DSC flow ends with /v1/login/dsc/{rn}/claim returning a ready-to-use token — calling the
+ * OTP-shaped /twofactor/validate path here would 4xx because there is no in-memory OTPRequest
+ * to exchange. We just need the post-success bookkeeping: stash the token on the interceptor,
+ * persist the validity window, set credentials, and fire the login alert.
+ */
+ applyTwoFactorToken(token: string, liveSeconds: number): void {
+ const now = Date.now();
+ const ttlMs = (liveSeconds && liveSeconds > 0 ? liveSeconds : 0) * 1000;
+ const response = {
+ token,
+ validFrom: now,
+ validTo: ttlMs > 0 ? now + ttlMs : now
+ };
+ this.onOTPValidateSuccess(response);
+ }
+
/**
* Sets the two factor authorization token followed by one of the following:
*
diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html
index 8028aae7a..15a0246e5 100644
--- a/src/app/core/shell/shell.component.html
+++ b/src/app/core/shell/shell.component.html
@@ -947,7 +947,12 @@
-
+
+
- ISO 27001 Certified
+ ISO 27001
Session {{ sessionTimer }}
@@ -45,26 +45,206 @@
-
+
-
-
+
+
+
+
-
+

+
+
+
+
@if (subLedgerOpen) {
diff --git a/src/app/core/shell/toolbar/toolbar.component.scss b/src/app/core/shell/toolbar/toolbar.component.scss
index 4e91f2fc9..cce99bd4f 100644
--- a/src/app/core/shell/toolbar/toolbar.component.scss
+++ b/src/app/core/shell/toolbar/toolbar.component.scss
@@ -160,12 +160,15 @@ $top-h: 32px;
box-shadow: 0 0 0 2.5px rgb(16 185 129 / 25%);
}
-// ===== Right Actions — Unified =====
-$btn-size: 30px;
-$icon-size: 14px;
-$icon-color: rgb(255 255 255 / 55%);
+// ===== Right Actions — Unified, polished =====
+$btn-size: 26px;
+$icon-size: 13px;
+$icon-color: rgb(255 255 255 / 70%);
$icon-hover: #fff;
-$icon-hover-bg: rgb(255 255 255 / 10%);
+$icon-bg: rgb(255 255 255 / 6%);
+$icon-border: rgb(255 255 255 / 10%);
+$icon-hover-bg: rgb(255 255 255 / 14%);
+$icon-hover-border: rgb(255 255 255 / 28%);
.t-actions {
display: flex;
@@ -173,41 +176,58 @@ $icon-hover-bg: rgb(255 255 255 / 10%);
margin-left: auto;
height: $top-h;
flex-shrink: 0;
- gap: 2px;
- padding-right: 8px;
+ gap: 6px;
+ padding-right: 12px;
}
.t-divider {
width: 1px;
- height: 16px;
- background: rgb(255 255 255 / 12%);
+ height: 18px;
+ background: rgb(255 255 255 / 14%);
margin: 0 4px;
}
.t-action-btn {
+ position: relative;
width: $btn-size;
height: $btn-size;
- display: flex;
+ display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: $icon-color;
- border-radius: 6px;
- transition: all 0.15s;
+ background: $icon-bg;
+ border: 1px solid $icon-border;
+ border-radius: 50%;
+ transition:
+ color 0.16s ease,
+ background 0.16s ease,
+ border-color 0.16s ease,
+ transform 0.16s ease;
flex-shrink: 0;
+ outline: none;
- &:hover {
+ &:hover,
+ &:focus-visible {
color: $icon-hover;
background: $icon-hover-bg;
+ border-color: $icon-hover-border;
}
- // Direct fa-icon (Settings)
- fa-icon {
+ &:active {
+ transform: scale(0.94);
+ }
+
+ // Direct fa-icon (Settings, AI)
+ > fa-icon {
font-size: $icon-size;
+ line-height: 1;
}
- // Notifications tray — force same size
+ // Notifications tray — kill its own button chrome; outer pill is the visual
::ng-deep mifosx-notifications-tray {
+ width: $btn-size;
+ height: $btn-size;
display: flex;
align-items: center;
justify-content: center;
@@ -216,67 +236,103 @@ $icon-hover-bg: rgb(255 255 255 / 10%);
width: $btn-size;
height: $btn-size;
padding: 0;
- color: $icon-color;
+ color: inherit;
+ background: transparent !important;
+ border-radius: 50%;
line-height: $btn-size;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+
+ // Suppress Material's ripple/state layer so we don't get a double hover
+ .mat-mdc-button-persistent-ripple,
+ .mat-mdc-button-ripple,
+ .mat-mdc-focus-indicator {
+ display: none !important;
+ }
&:hover {
- color: $icon-hover;
- background: transparent; // parent handles hover bg
+ color: inherit;
+ background: transparent !important;
}
+ }
- .mat-icon,
- mat-icon {
- font-size: $icon-size;
- width: $icon-size;
- height: $icon-size;
- line-height: $icon-size;
- }
+ // Colorful bell SVG — same 20px box as the AI icon
+ .t-notif-svg {
+ width: 20px;
+ height: 20px;
+ display: block;
+ filter: drop-shadow(0 1px 2px rgb(11 30 61 / 35%));
}
.mat-badge-content {
font-size: 8px;
- width: 14px;
- height: 14px;
- line-height: 14px;
- top: 2px !important;
- right: 2px !important;
- }
- }
-
- // Theme toggle — force same size
- ::ng-deep mifosx-theme-toggle {
- display: flex;
- align-items: center;
- justify-content: center;
-
- button {
- width: $btn-size;
- height: $btn-size;
- padding: 0;
- color: $icon-color;
- line-height: $btn-size;
-
- &:hover {
- color: $icon-hover;
- background: transparent;
- }
-
- mat-icon,
- .mat-icon {
- font-size: $icon-size;
- width: $icon-size;
- height: $icon-size;
- line-height: $icon-size;
- }
+ width: 13px;
+ height: 13px;
+ line-height: 13px;
+ top: -2px !important;
+ right: -2px !important;
+ background: #ef4444 !important;
+ color: #fff !important;
+ font-weight: 700;
+ border: 1.5px solid $brand-dark;
}
}
}
-// ===== User Button — Same size, round avatar =====
+// ===== AI button — full-color SVG mark, ringed pill =====
+.t-action-btn--ai {
+ background: rgb(255 255 255 / 9%);
+ border-color: rgb(94 204 186 / 50%);
+ padding: 0;
+ overflow: visible;
+
+ &:hover,
+ &:focus-visible {
+ background: rgb(255 255 255 / 14%);
+ border-color: rgb(94 204 186 / 85%);
+ box-shadow: 0 0 0 3px rgb(94 204 186 / 22%);
+ }
+}
+
+.t-ai-svg {
+ width: 20px;
+ height: 20px;
+ display: block;
+ filter: drop-shadow(0 1px 2px rgb(11 30 61 / 35%));
+}
+
+.t-action-dot {
+ position: absolute;
+ top: 1px;
+ right: 1px;
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: #34d399;
+ border: 1.5px solid $brand-dark;
+ box-shadow: 0 0 0 0 rgb(52 211 153 / 50%);
+ animation: t-ai-pulse 2.2s ease-in-out infinite;
+}
+
+@keyframes t-ai-pulse {
+ 0%,
+ 100% {
+ box-shadow: 0 0 0 0 rgb(52 211 153 / 45%);
+ }
+
+ 50% {
+ box-shadow: 0 0 0 5px rgb(52 211 153 / 0%);
+ }
+}
+
+// ===== User Button — Same pill, round avatar inside =====
.t-user-btn {
width: $btn-size;
height: $btn-size;
padding: 0;
+ overflow: visible;
+ background: transparent;
}
.t-user-img {
@@ -284,7 +340,252 @@ $icon-hover-bg: rgb(255 255 255 / 10%);
height: 22px;
border-radius: 50%;
object-fit: cover;
- border: 1.5px solid rgb(255 255 255 / 20%);
+ border: 1.5px solid rgb(255 255 255 / 22%);
+ transition: border-color 0.16s ease;
+}
+
+.t-user-btn:hover .t-user-img,
+.t-user-btn:focus-visible .t-user-img {
+ border-color: rgb(255 255 255 / 55%);
+}
+
+.t-user-status {
+ position: absolute;
+ bottom: 0;
+ right: 0;
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: #10b981;
+ border: 1.5px solid $brand-dark;
+}
+
+// ===== AI Mega Menu Panel =====
+::ng-deep .ai-menu {
+ &.mat-mdc-menu-panel {
+ min-width: 880px !important;
+ max-width: 96vw !important;
+ max-height: 80vh;
+ border-radius: 14px !important;
+ padding: 0 !important;
+ box-shadow:
+ 0 18px 36px rgb(11 30 61 / 22%),
+ 0 6px 14px rgb(11 30 61 / 14%) !important;
+ overflow: hidden;
+ background: #fff !important;
+ }
+
+ .mat-mdc-menu-content {
+ padding: 0 !important;
+ max-height: 80vh;
+ overflow-y: auto;
+ }
+}
+
+.ai-menu-header {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 14px 16px 12px;
+ background: linear-gradient(135deg, #0b1e3d 0%, #1d4ed8 100%);
+ color: #fff;
+
+ .ai-menu-header-svg {
+ width: 36px;
+ height: 36px;
+ flex-shrink: 0;
+ filter: drop-shadow(0 1px 3px rgb(0 0 0 / 35%));
+ }
+
+ .ai-menu-title {
+ font-size: 14px;
+ font-weight: 700;
+ letter-spacing: 0.01em;
+ }
+
+ .ai-menu-sub {
+ font-size: 11px;
+ color: rgb(255 255 255 / 80%);
+ margin-top: 2px;
+ }
+}
+
+// ===== Mega Layout — 4 colour-coded columns =====
+.ai-menu-cols {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 0;
+ padding: 14px 14px 6px;
+ background: #f8fafc;
+ align-items: stretch;
+}
+
+.ai-col {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ padding: 8px 8px 12px;
+ background: #fff;
+ border-radius: 12px;
+ margin: 0 6px;
+ box-shadow: 0 1px 2px rgb(15 23 42 / 5%);
+ border-top: 3px solid var(--c-color);
+
+ --c-color: #4f46e5;
+ --c-bg: #eef2ff;
+ --c-hover: #4f46e5;
+}
+
+.ai-col--red {
+ --c-color: #c0392b;
+ --c-bg: #fee2e2;
+ --c-hover: #c0392b;
+}
+
+.ai-col--teal {
+ --c-color: #0e7e6b;
+ --c-bg: #ccfbf1;
+ --c-hover: #0e7e6b;
+}
+
+.ai-col--indigo {
+ --c-color: #3730a3;
+ --c-bg: #e0e7ff;
+ --c-hover: #3730a3;
+}
+
+.ai-col--pink {
+ --c-color: #be185d;
+ --c-bg: #fce7f3;
+ --c-hover: #be185d;
+}
+
+.ai-col-h {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ padding: 6px 8px 10px;
+ margin-bottom: 4px;
+ font-size: 10.5px;
+ font-weight: 800;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--c-color);
+ border-bottom: 1px dashed rgb(15 23 42 / 10%);
+}
+
+.ai-col-h-ic {
+ font-size: 11px;
+}
+
+// Compact card per AI module
+.ai-card.mat-mdc-menu-item {
+ display: flex !important;
+ align-items: flex-start;
+ gap: 10px;
+ padding: 9px 10px;
+ min-height: 0;
+ height: auto;
+ border-radius: 9px;
+ font-family: inherit;
+ line-height: 1.3;
+ white-space: normal;
+ transition: background 0.16s ease;
+
+ .ai-card-ic {
+ width: 32px;
+ height: 32px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 8px;
+ font-size: 13px;
+ flex-shrink: 0;
+ background: var(--c-bg);
+ color: var(--c-color);
+ transition:
+ background 0.16s ease,
+ color 0.16s ease,
+ transform 0.16s ease;
+ }
+
+ .ai-card-body {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-width: 0;
+ text-align: left;
+ }
+
+ .ai-card-t {
+ font-size: 12.5px;
+ font-weight: 600;
+ color: #0f172a;
+ line-height: 1.25;
+ }
+
+ .ai-card-d {
+ font-size: 10.5px;
+ color: #64748b;
+ margin-top: 2px;
+ line-height: 1.35;
+ }
+
+ &:hover {
+ background: var(--c-bg);
+
+ .ai-card-ic {
+ background: var(--c-hover);
+ color: #fff;
+ transform: scale(1.06);
+ }
+
+ .ai-card-t {
+ color: var(--c-color);
+ }
+ }
+}
+
+.ai-menu-footer {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 9px 18px 11px;
+ background: #f8fafc;
+ border-top: 1px solid #eef2f7;
+ font-size: 11px;
+ color: #64748b;
+ line-height: 1.4;
+
+ fa-icon {
+ font-size: 11px;
+ color: #1d4ed8;
+ }
+}
+
+// Responsive collapse
+@media (width <= 1100px) {
+ ::ng-deep .ai-menu.mat-mdc-menu-panel {
+ min-width: 680px !important;
+ }
+
+ .ai-menu-cols {
+ grid-template-columns: repeat(2, 1fr);
+ }
+}
+
+@media (width <= 720px) {
+ ::ng-deep .ai-menu.mat-mdc-menu-panel {
+ min-width: 90vw !important;
+ }
+
+ .ai-menu-cols {
+ grid-template-columns: 1fr;
+ }
+
+ .ai-col {
+ margin: 4px 0;
+ }
}
// ===== Sub Ledger Mega Menu =====
diff --git a/src/app/core/shell/toolbar/toolbar.component.ts b/src/app/core/shell/toolbar/toolbar.component.ts
index 1ba1e1194..d142b6df4 100644
--- a/src/app/core/shell/toolbar/toolbar.component.ts
+++ b/src/app/core/shell/toolbar/toolbar.component.ts
@@ -47,7 +47,6 @@ import { SearchToolComponent } from '../../../shared/search-tool/search-tool.com
import { LanguageSelectorComponent } from '../../../shared/language-selector/language-selector.component';
import { MatIcon } from '@angular/material/icon';
import { NotificationsTrayComponent as NotificationsTrayComponent_1 } from '../../../shared/notifications-tray/notifications-tray.component';
-import { ThemeToggleComponent } from '../../../shared/theme-toggle/theme-toggle.component';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { DocumentationLinksService } from 'app/shared/services/documentation-links.service';
@@ -69,7 +68,6 @@ import { DocumentationLinksService } from 'app/shared/services/documentation-lin
LanguageSelectorComponent,
MatIcon,
NotificationsTrayComponent_1,
- ThemeToggleComponent,
MatMenu,
MatMenuItem
]
diff --git a/src/app/digital/digital-routing.module.ts b/src/app/digital/digital-routing.module.ts
new file mode 100644
index 000000000..0ff67712e
--- /dev/null
+++ b/src/app/digital/digital-routing.module.ts
@@ -0,0 +1,35 @@
+/**
+ * Copyright since 2025 Mifos Initiative
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+import { NgModule } from '@angular/core';
+import { Routes, RouterModule } from '@angular/router';
+
+import { Route } from '../core/route/route.service';
+import { NeftRtgsEntryComponent } from './neft-rtgs-entry/neft-rtgs-entry.component';
+import { NeftRtgsReportComponent } from './neft-rtgs-report/neft-rtgs-report.component';
+
+const routes: Routes = [
+ Route.withShell([
+ {
+ path: 'digital/neft-rtgs',
+ component: NeftRtgsEntryComponent,
+ data: { title: 'NEFT / RTGS Entry', breadcrumb: 'NEFT / RTGS' }
+ },
+ {
+ path: 'digital/neft-rtgs-report',
+ component: NeftRtgsReportComponent,
+ data: { title: 'NEFT / RTGS Report', breadcrumb: 'NEFT / RTGS Report' }
+ }
+ ])
+];
+
+@NgModule({
+ imports: [RouterModule.forChild(routes)],
+ exports: [RouterModule]
+})
+export class DigitalRoutingModule {}
diff --git a/src/app/digital/digital.module.ts b/src/app/digital/digital.module.ts
new file mode 100644
index 000000000..40c9e2a7b
--- /dev/null
+++ b/src/app/digital/digital.module.ts
@@ -0,0 +1,24 @@
+/**
+ * Copyright since 2025 Mifos Initiative
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+import { NgModule } from '@angular/core';
+
+import { SharedModule } from '../shared/shared.module';
+import { DigitalRoutingModule } from './digital-routing.module';
+import { NeftRtgsEntryComponent } from './neft-rtgs-entry/neft-rtgs-entry.component';
+import { NeftRtgsReportComponent } from './neft-rtgs-report/neft-rtgs-report.component';
+
+@NgModule({
+ imports: [
+ SharedModule,
+ DigitalRoutingModule,
+ NeftRtgsEntryComponent,
+ NeftRtgsReportComponent
+ ]
+})
+export class DigitalModule {}
diff --git a/src/app/digital/neft-rtgs-entry/neft-rtgs-entry.component.html b/src/app/digital/neft-rtgs-entry/neft-rtgs-entry.component.html
new file mode 100644
index 000000000..e66db34a2
--- /dev/null
+++ b/src/app/digital/neft-rtgs-entry/neft-rtgs-entry.component.html
@@ -0,0 +1,304 @@
+
+
+
+
+
+
+
+
+
NEFT / RTGS — Outward Remittance
+
+ {{ entryForm?.get('transactionType')?.value }}
+ window ·
+
+ {{ isRtgs ? 'Real-time gross settlement' : 'Half-hourly NEFT batches · 24×7' }}
+
+
+
+
+
+
+ @if (entryForm) {
+
+ }
+
diff --git a/src/app/digital/neft-rtgs-entry/neft-rtgs-entry.component.scss b/src/app/digital/neft-rtgs-entry/neft-rtgs-entry.component.scss
new file mode 100644
index 000000000..0553668f4
--- /dev/null
+++ b/src/app/digital/neft-rtgs-entry/neft-rtgs-entry.component.scss
@@ -0,0 +1,595 @@
+/**
+ * Copyright since 2025 Mifos Initiative
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+:host {
+ --nr-navy: #0b1e3d;
+ --nr-blue: #1d4ed8;
+ --nr-blue-d: #1e40af;
+ --nr-blue-l: #eff6ff;
+ --nr-teal: #0e7e6b;
+ --nr-teal-l: #e0f4f0;
+ --nr-green: #15803d;
+ --nr-green-l: #dcfce7;
+ --nr-amber: #d97706;
+ --nr-amber-l: #fef3c7;
+ --nr-red: #c0392b;
+ --nr-red-l: #fdecea;
+ --nr-purple: #6d28d9;
+ --nr-purple-l: #ede9fe;
+ --nr-g50: #f8fafc;
+ --nr-g100: #f1f5f9;
+ --nr-g200: #e2e8f0;
+ --nr-g300: #cbd5e1;
+ --nr-g500: #64748b;
+ --nr-g600: #475569;
+ --nr-g700: #334155;
+ --nr-g900: #0f172a;
+
+ display: block;
+ font-family:
+ 'DM Sans',
+ system-ui,
+ -apple-system,
+ sans-serif;
+ background: var(--nr-g50);
+ color: var(--nr-g900);
+ font-size: 13.5px;
+ line-height: 1.5;
+}
+
+/* ───────── ROUTED PAGE LAYOUT ───────── */
+.nr-root {
+ padding: 18px 22px;
+ max-width: 1100px;
+ margin: 0 auto;
+}
+
+.nr-head {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 14px;
+ flex-wrap: wrap;
+ margin-bottom: 12px;
+}
+
+.nr-head-left {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.nr-head-ic {
+ font-size: 24px;
+ color: var(--nr-blue);
+}
+
+.nr-head-t {
+ margin: 0;
+ font-size: 17px;
+ font-weight: 700;
+ color: var(--nr-g900);
+}
+
+.nr-head-s {
+ font-size: 12px;
+ color: var(--nr-g500);
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex-wrap: wrap;
+
+ .is-rtgs {
+ color: var(--nr-purple);
+ font-weight: 700;
+ }
+
+ .is-neft {
+ color: var(--nr-blue);
+ font-weight: 700;
+ }
+}
+
+.nr-head-s-ic {
+ font-size: 10px;
+ color: var(--nr-g500);
+}
+
+.nr-head-meta {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.nr-head-pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ background: var(--nr-blue-l);
+ border: 1px solid #dbeafe;
+ border-radius: 999px;
+ padding: 5px 12px 5px 14px;
+ color: var(--nr-blue-d);
+
+ &.is-rtgs {
+ background: var(--nr-purple-l);
+ border-color: #ddd6fe;
+ color: #4c1d95;
+ }
+}
+
+.nr-head-pill-l {
+ font-size: 9.5px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ opacity: 0.8;
+}
+
+.nr-head-pill-v {
+ font-size: 12.5px;
+ font-weight: 700;
+}
+
+.nr-dialog-x {
+ background: transparent;
+ border: 1px solid var(--nr-g200);
+ color: var(--nr-g600);
+ width: 30px;
+ height: 30px;
+ border-radius: 50%;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ transition: all 0.15s ease;
+ margin-left: 4px;
+
+ &:hover {
+ background: var(--nr-red-l);
+ color: var(--nr-red);
+ border-color: #fecaca;
+ }
+}
+
+/* ───────── SECTION ───────── */
+.nr-section {
+ background: #fff;
+ border: 1px solid var(--nr-g200);
+ border-radius: 12px;
+ padding: 14px 16px;
+ margin-bottom: 12px;
+ box-shadow: 0 1px 2px rgb(15 23 42 / 4%);
+}
+
+.nr-section-h {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-bottom: 12px;
+ padding-bottom: 8px;
+ border-bottom: 1px dashed var(--nr-g200);
+ font-size: 12px;
+ font-weight: 700;
+ color: var(--nr-g700);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+
+.nr-section-ic {
+ color: var(--nr-blue);
+ font-size: 13px;
+}
+
+.nr-required-pill {
+ margin-left: auto;
+ font-size: 10px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ padding: 3px 9px;
+ border-radius: 999px;
+ background: var(--nr-amber-l);
+ color: var(--nr-amber);
+}
+
+.nr-banl-status-pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ font-size: 10.5px;
+ font-weight: 700;
+ padding: 3px 10px;
+ border-radius: 999px;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+
+ fa-icon {
+ font-size: 10px;
+ }
+
+ &.verified {
+ background: var(--nr-green-l);
+ color: var(--nr-green);
+ border: 1px solid #bbf7d0;
+ }
+
+ &.pending {
+ background: var(--nr-amber-l);
+ color: var(--nr-amber);
+ border: 1px solid #fde68a;
+ }
+}
+
+.nr-banl-btn {
+ margin-left: 6px;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ background: var(--nr-teal-l);
+ color: var(--nr-teal);
+ border: 1px solid #b8e5dc;
+ border-radius: 999px;
+ padding: 4px 12px;
+ font-size: 11.5px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.15s ease;
+ text-transform: none;
+ letter-spacing: 0;
+
+ fa-icon {
+ font-size: 11px;
+ }
+
+ &:hover:not([disabled]) {
+ background: var(--nr-teal);
+ color: #fff;
+ border-color: var(--nr-teal);
+ }
+
+ &[disabled] {
+ opacity: 0.55;
+ cursor: not-allowed;
+ }
+
+ /* When verification is pending but the operator has entered a valid A/C — nudge them */
+ &.is-pending {
+ background: var(--nr-amber);
+ color: #fff;
+ border-color: var(--nr-amber);
+ animation: nr-banl-pulse 2s ease-in-out infinite;
+
+ &:hover:not([disabled]) {
+ background: #b45309;
+ border-color: #b45309;
+ }
+ }
+}
+
+@keyframes nr-banl-pulse {
+ 0%,
+ 100% {
+ box-shadow: 0 0 0 0 rgb(217 119 6 / 45%);
+ }
+
+ 50% {
+ box-shadow: 0 0 0 6px rgb(217 119 6 / 0%);
+ }
+}
+
+.nr-name-field .nr-name-ok {
+ color: var(--nr-green);
+ font-size: 14px;
+}
+
+.nr-name-field .nr-name-lock {
+ color: var(--nr-g500);
+ font-size: 12px;
+}
+
+/* Read-only beneficiary name: full-strength text, no caret, subtle tint when verified */
+/* stylelint-disable selector-no-qualifying-type */
+:host ::ng-deep .nr-name-field input[readonly] {
+ cursor: default;
+ color: var(--nr-g900);
+ -webkit-text-fill-color: var(--nr-g900);
+ caret-color: transparent;
+ font-weight: 600;
+}
+/* stylelint-enable selector-no-qualifying-type */
+
+.nr-submit-hint {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 11.5px;
+ font-weight: 600;
+ color: var(--nr-amber);
+ padding: 4px 10px;
+ background: var(--nr-amber-l);
+ border: 1px solid #fde68a;
+ border-radius: 999px;
+ margin-right: 6px;
+
+ fa-icon {
+ font-size: 11px;
+ }
+}
+
+/* ───────── GRID (auto-fit) ───────── */
+.nr-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+ gap: 8px 12px;
+}
+
+.nr-field {
+ width: 100%;
+}
+
+.nr-span-2 {
+ grid-column: span 2;
+
+ @media (width <= 560px) {
+ grid-column: span 1;
+ }
+}
+
+.nr-prefix {
+ color: var(--nr-g500);
+ font-weight: 600;
+ font-size: 13px;
+}
+
+.nr-hint-ic {
+ color: var(--nr-g500);
+ font-size: 12px;
+ cursor: help;
+}
+
+.nr-req-star {
+ color: var(--nr-red);
+ font-weight: 700;
+}
+
+/* ───────── BANL RESULT (compact, single-line) ───────── */
+.nr-banl-card {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 8px 12px;
+ border-radius: 8px;
+ margin-top: 6px;
+ font-size: 12.5px;
+ line-height: 1.4;
+
+ &.success {
+ background: var(--nr-green-l);
+ border: 1px solid #bbf7d0;
+ color: var(--nr-g900);
+
+ .nr-banl-card-ic {
+ color: var(--nr-green);
+ }
+ }
+
+ &.error {
+ background: var(--nr-red-l);
+ border: 1px solid #fecaca;
+ color: var(--nr-red);
+
+ .nr-banl-card-ic {
+ color: var(--nr-red);
+ }
+ }
+}
+
+.nr-banl-card-ic {
+ font-size: 16px;
+ flex-shrink: 0;
+}
+
+.nr-banl-card-body {
+ flex: 1;
+
+ .mono {
+ font-family: 'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace;
+ font-size: 12px;
+ color: var(--nr-g700);
+ }
+}
+
+.nr-banl-status {
+ font-weight: 700;
+ color: var(--nr-green);
+
+ &.dormant {
+ color: var(--nr-amber);
+ }
+}
+
+.nr-banl-card-x {
+ background: transparent;
+ border: 0;
+ color: var(--nr-g500);
+ cursor: pointer;
+ padding: 2px 6px;
+ border-radius: 4px;
+
+ &:hover {
+ background: rgb(0 0 0 / 5%);
+ color: var(--nr-g700);
+ }
+}
+
+/* ───────── ACTIONS ───────── */
+.nr-actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 12px 4px 4px;
+ flex-wrap: wrap;
+}
+
+.nr-actions-spacer {
+ flex: 1;
+}
+
+.nr-btn-ghost {
+ color: var(--nr-g600) !important;
+
+ fa-icon {
+ margin-right: 4px;
+ }
+}
+
+.nr-btn-submit fa-icon {
+ margin-right: 6px;
+}
+
+.mono {
+ font-family: 'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace;
+ font-size: 12.5px;
+}
+
+/* ═══════════════════════════════════════════════════
+ DIALOG MODE — sticky header/footer, compact body
+ ═══════════════════════════════════════════════════ */
+.nr-root.nr-in-dialog {
+ padding: 0;
+ background: #fff;
+ max-width: none;
+ display: flex;
+ flex-direction: column;
+ max-height: min(720px, 88vh);
+
+ /* compact sticky header */
+ .nr-head {
+ position: sticky;
+ top: 0;
+ z-index: 5;
+ background: linear-gradient(180deg, #fff 0%, var(--nr-g50) 100%);
+ border-bottom: 1px solid var(--nr-g200);
+ padding: 12px 18px;
+ margin-bottom: 0;
+ flex-wrap: nowrap;
+ }
+
+ .nr-head-ic {
+ font-size: 20px;
+ }
+
+ .nr-head-t {
+ font-size: 15px;
+ }
+
+ .nr-head-s {
+ font-size: 11.5px;
+ }
+
+ .nr-head-pill {
+ padding: 4px 10px 4px 12px;
+ }
+
+ .nr-head-pill-l {
+ font-size: 9px;
+ }
+
+ .nr-head-pill-v {
+ font-size: 11.5px;
+ }
+
+ /* scrollable body */
+ .nr-form {
+ flex: 1;
+ overflow-y: auto;
+ padding: 14px 18px 12px;
+ background: var(--nr-g50);
+ }
+
+ /* sections become subtle blocks with smaller padding */
+ .nr-section {
+ background: #fff;
+ border: 1px solid var(--nr-g200);
+ border-radius: 10px;
+ padding: 10px 14px;
+ margin-bottom: 10px;
+ box-shadow: 0 1px 2px rgb(15 23 42 / 4%);
+
+ &:last-of-type {
+ margin-bottom: 0;
+ }
+ }
+
+ .nr-section-h {
+ font-size: 11px;
+ padding-bottom: 6px;
+ margin-bottom: 10px;
+ }
+
+ .nr-section-ic {
+ font-size: 11px;
+ }
+
+ .nr-banl-btn {
+ padding: 3px 10px;
+ font-size: 11px;
+
+ fa-icon {
+ font-size: 10px;
+ }
+ }
+
+ .nr-required-pill {
+ font-size: 9.5px;
+ padding: 2px 8px;
+ }
+
+ /* tighter grid in dialog — packs 3+ columns at full width */
+ .nr-grid {
+ grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
+ gap: 4px 12px;
+ }
+
+ /* sticky footer actions */
+ .nr-actions {
+ position: sticky;
+ bottom: 0;
+ background: #fff;
+ border-top: 1px solid var(--nr-g200);
+ padding: 10px 18px;
+ margin: 0;
+ z-index: 5;
+ box-shadow: 0 -2px 6px rgb(15 23 42 / 4%);
+ }
+
+ .nr-banl-card {
+ margin-top: 6px;
+ padding: 7px 11px;
+ font-size: 12px;
+ }
+}
+
+/* Density: shrink Material outlined form-field internals in dialog mode */
+:host ::ng-deep .nr-root.nr-in-dialog .mat-mdc-form-field {
+ .mat-mdc-text-field-wrapper {
+ padding-top: 2px;
+ padding-bottom: 2px;
+ }
+
+ .mat-mdc-form-field-infix {
+ min-height: 42px;
+ padding-top: 8px;
+ padding-bottom: 8px;
+ }
+
+ .mat-mdc-form-field-subscript-wrapper {
+ height: 14px;
+ }
+}
diff --git a/src/app/digital/neft-rtgs-entry/neft-rtgs-entry.component.ts b/src/app/digital/neft-rtgs-entry/neft-rtgs-entry.component.ts
new file mode 100644
index 000000000..4a4318730
--- /dev/null
+++ b/src/app/digital/neft-rtgs-entry/neft-rtgs-entry.component.ts
@@ -0,0 +1,434 @@
+/**
+ * Copyright since 2025 Mifos Initiative
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+import { Component, inject, OnInit } from '@angular/core';
+import { FormBuilder, FormGroup, Validators } from '@angular/forms';
+import { FaIconComponent } from '@fortawesome/angular-fontawesome';
+import { MatTooltip } from '@angular/material/tooltip';
+import { MatSnackBar } from '@angular/material/snack-bar';
+import { MatDialogRef } from '@angular/material/dialog';
+import { Router } from '@angular/router';
+import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
+
+type TxnType = 'NEFT' | 'RTGS';
+
+interface PurposeOption {
+ code: string;
+ label: string;
+ highValueOnly?: boolean;
+}
+
+interface BanlMatch {
+ beneficiaryName: string;
+ beneficiaryAccount: string;
+ ifscCode: string;
+ bankName: string;
+ branchName: string;
+ status: 'ACTIVE' | 'DORMANT';
+}
+
+@Component({
+ selector: 'mifosx-neft-rtgs-entry',
+ templateUrl: './neft-rtgs-entry.component.html',
+ styleUrls: ['./neft-rtgs-entry.component.scss'],
+ imports: [
+ ...STANDALONE_SHARED_IMPORTS,
+ FaIconComponent,
+ MatTooltip
+ ]
+})
+export class NeftRtgsEntryComponent implements OnInit {
+ private fb = inject(FormBuilder);
+ private snack = inject(MatSnackBar);
+ private router = inject(Router);
+ /** Present when this component is opened inside a dialog. */
+ private dialogRef = inject(MatDialogRef
, { optional: true });
+
+ get isDialog(): boolean {
+ return this.dialogRef !== null;
+ }
+
+ /** RTGS minimum threshold (₹2 Lakhs). */
+ readonly RTGS_MIN = 200000;
+ /** Threshold above which Purpose of Remittance is mandatory for NEFT (cross-border-style high value). */
+ readonly HIGH_VALUE_THRESHOLD = 200000;
+
+ minDate = new Date(2000, 0, 1);
+ maxDate = new Date(2100, 11, 31);
+
+ entryForm: FormGroup;
+
+ /** BANL lookup state */
+ banlLooking = false;
+ banlMatch: BanlMatch | null = null;
+ banlError: string | null = null;
+
+ /** Mock BANL directory — replace with API call later. */
+ private banlDirectory: BanlMatch[] = [
+ {
+ beneficiaryName: 'Rohan S. Mehta',
+ beneficiaryAccount: '50100123456789',
+ ifscCode: 'HDFC0001234',
+ bankName: 'HDFC Bank',
+ branchName: 'Mumbai — Andheri (E)',
+ status: 'ACTIVE'
+ },
+ {
+ beneficiaryName: 'Bluestone Trading Co.',
+ beneficiaryAccount: '345678901234',
+ ifscCode: 'ICIC0000567',
+ bankName: 'ICICI Bank',
+ branchName: 'Bengaluru — MG Road',
+ status: 'ACTIVE'
+ },
+ {
+ beneficiaryName: 'Anita Verma',
+ beneficiaryAccount: '912345678901',
+ ifscCode: 'SBIN0011223',
+ bankName: 'State Bank of India',
+ branchName: 'New Delhi — Connaught Place',
+ status: 'DORMANT'
+ }
+ ];
+
+ /** RBI standard purpose-of-remittance categories (subset). */
+ purposeOptions: PurposeOption[] = [
+ { code: 'CAPITAL_TRANSFER', label: 'Capital Transfer' },
+ { code: 'FAMILY_MAINTENANCE', label: 'Family Maintenance' },
+ { code: 'TRADE_SETTLEMENT', label: 'Trade Settlement' },
+ { code: 'SALARY_PAYMENT', label: 'Salary Payment' },
+ { code: 'LOAN_REPAYMENT', label: 'Loan Repayment' },
+ { code: 'TAX_PAYMENT', label: 'Tax Payment' },
+ { code: 'VENDOR_PAYMENT', label: 'Vendor / Supplier Payment' },
+ { code: 'INVESTMENT', label: 'Investment / Securities' },
+ { code: 'GIFT', label: 'Gift / Personal Remittance' },
+ { code: 'OTHER', label: 'Other' }
+ ];
+
+ ngOnInit(): void {
+ this.entryForm = this.fb.group({
+ transactionType: [
+ 'NEFT' as TxnType,
+ Validators.required
+ ],
+ valueDate: [
+ new Date(),
+ Validators.required
+ ],
+ amount: [
+ null,
+ [
+ Validators.required,
+ Validators.min(1)
+ ]
+ ],
+ senderAccount: [
+ '',
+ [
+ Validators.required,
+ Validators.pattern(/^[A-Za-z0-9]{6,20}$/)
+ ]
+ ],
+ beneficiaryName: [
+ '',
+ [
+ Validators.required,
+ Validators.maxLength(120)
+ ]
+ ],
+ beneficiaryAccount: [
+ '',
+ [
+ Validators.required,
+ Validators.pattern(/^[A-Za-z0-9]{6,20}$/)
+ ]
+ ],
+ ifscCode: [
+ '',
+ [
+ Validators.required,
+ Validators.pattern(/^[A-Z]{4}0[A-Z0-9]{6}$/)
+ ]
+ ],
+ purposeOfRemittance: [
+ '',
+ []
+ ],
+ remarks: ['']
+ });
+
+ // Re-evaluate purpose requirement whenever amount or type changes.
+ this.entryForm.get('amount')?.valueChanges.subscribe(() => this.refreshPurposeValidator());
+ this.entryForm.get('transactionType')?.valueChanges.subscribe((t) => {
+ this.refreshPurposeValidator();
+ this.refreshAmountValidator(t);
+ });
+
+ // Any change to A/C or IFSC invalidates the prior BANL match — operator must re-verify.
+ this.entryForm.get('beneficiaryAccount')?.valueChanges.subscribe(() => this.invalidateBanl());
+ this.entryForm.get('ifscCode')?.valueChanges.subscribe(() => this.invalidateBanl());
+
+ this.refreshAmountValidator(this.entryForm.get('transactionType')?.value);
+ }
+
+ /** Drop the prior BANL match + clear the auto-filled beneficiary name. */
+ private invalidateBanl(): void {
+ if (!this.banlMatch) return;
+ this.banlMatch = null;
+ this.banlError = null;
+ this.entryForm.get('beneficiaryName')?.setValue('', { emitEvent: false });
+ }
+
+ // ───────── Validators ─────────
+ private refreshAmountValidator(type: TxnType): void {
+ const amt = this.entryForm.get('amount');
+ if (!amt) return;
+ const validators = [
+ Validators.required,
+ Validators.min(1)
+ ];
+ if (type === 'RTGS') {
+ validators.push(Validators.min(this.RTGS_MIN));
+ }
+ amt.setValidators(validators);
+ amt.updateValueAndValidity({ emitEvent: false });
+ }
+
+ private refreshPurposeValidator(): void {
+ const purpose = this.entryForm.get('purposeOfRemittance');
+ if (!purpose) return;
+ if (this.purposeMandatory) {
+ purpose.setValidators([
+ Validators.required
+ ]);
+ } else {
+ purpose.clearValidators();
+ }
+ purpose.updateValueAndValidity({ emitEvent: false });
+ }
+
+ // ───────── Derived state ─────────
+ get isRtgs(): boolean {
+ return this.entryForm?.get('transactionType')?.value === 'RTGS';
+ }
+
+ get amount(): number {
+ return Number(this.entryForm?.get('amount')?.value || 0);
+ }
+
+ /** Purpose is mandatory for RTGS or any high-value (≥ ₹2 L) txn per spec. */
+ get purposeMandatory(): boolean {
+ return this.isRtgs || this.amount >= this.HIGH_VALUE_THRESHOLD;
+ }
+
+ get rtgsUnderMin(): boolean {
+ return this.isRtgs && this.amount > 0 && this.amount < this.RTGS_MIN;
+ }
+
+ /** True once a BANL lookup has resolved a valid beneficiary record. */
+ get banlVerified(): boolean {
+ return this.banlMatch !== null;
+ }
+
+ /** Master gate — Initiate is only enabled when both form is valid AND BANL is verified. */
+ get canSubmit(): boolean {
+ return this.entryForm?.valid === true && this.banlVerified;
+ }
+
+ /** Pre-requisites for kicking off BANL (need A/C number that's structurally valid). */
+ get canPerformBanl(): boolean {
+ const acct = this.entryForm?.get('beneficiaryAccount');
+ return !!acct && acct.value && !acct.hasError('pattern');
+ }
+
+ get clearingWindow(): string {
+ if (this.isRtgs) {
+ return 'RTGS — settled in real time during RBI hours (Mon–Sat, 24×7 supported since Dec 2020).';
+ }
+ return 'NEFT — half-hourly batches, 24×7 settlement.';
+ }
+
+ // ───────── BANL lookup ─────────
+ /**
+ * Beneficiary Account Number Look-up — stand-in for the IMPS Switch API
+ * (account-no + IFSC → beneficiary name). Mandatory before Initiate.
+ */
+ performBanl(): void {
+ const acct = (this.entryForm.get('beneficiaryAccount')?.value || '').trim();
+ const ifsc = (this.entryForm.get('ifscCode')?.value || '').trim().toUpperCase();
+ this.banlMatch = null;
+ this.banlError = null;
+
+ if (!acct) {
+ this.banlError = 'Enter the Beneficiary Account Number to look up.';
+ return;
+ }
+ if (this.entryForm.get('beneficiaryAccount')?.hasError('pattern')) {
+ this.banlError = 'Beneficiary Account format is invalid — fix it before look-up.';
+ return;
+ }
+ if (ifsc && this.entryForm.get('ifscCode')?.hasError('pattern')) {
+ this.banlError = 'IFSC format is invalid — fix it before look-up.';
+ return;
+ }
+
+ this.banlLooking = true;
+ // Simulated lookup; swap for the IMPS Switch API call later.
+ setTimeout(() => {
+ this.banlLooking = false;
+ const match = this.banlDirectory.find((b) => b.beneficiaryAccount === acct && (!ifsc || b.ifscCode === ifsc));
+ if (!match) {
+ // Demo fallback: synthesise a name for any account that passes structural checks.
+ // Real implementation will route through the IMPS Switch.
+ const demo = this.synthesiseBanlForDemo(acct, ifsc);
+ this.banlMatch = demo;
+ this.applyBanlMatch(demo, true);
+ return;
+ }
+ this.applyBanlMatch(match, false);
+ }, 600);
+ }
+
+ private applyBanlMatch(match: BanlMatch, isDemo: boolean): void {
+ this.banlMatch = match;
+ // The input is `readonly` in the template, so only BANL writes this value.
+ this.entryForm.get('beneficiaryName')?.setValue(match.beneficiaryName, { emitEvent: false });
+ this.entryForm.patchValue({ ifscCode: match.ifscCode }, { emitEvent: false });
+ const tag = isDemo ? ' (demo)' : '';
+ this.snack.open(`BANL verified${tag} — ${match.beneficiaryName}`, 'OK', { duration: 2500 });
+ }
+
+ /**
+ * Demo-only stand-in for the IMPS Switch response.
+ * Produces a plausible-looking beneficiary record from the entered A/C + IFSC.
+ */
+ private synthesiseBanlForDemo(acct: string, ifsc: string): BanlMatch {
+ const bankFromIfsc = (code: string): string => {
+ const map: Record = {
+ HDFC: 'HDFC Bank',
+ ICIC: 'ICICI Bank',
+ SBIN: 'State Bank of India',
+ KKBK: 'Kotak Mahindra Bank',
+ AXIS: 'Axis Bank',
+ UTIB: 'Axis Bank',
+ PUNB: 'Punjab National Bank',
+ BARB: 'Bank of Baroda',
+ CNRB: 'Canara Bank',
+ YESB: 'YES Bank',
+ IDFB: 'IDFC FIRST Bank'
+ };
+ return map[(code || '').slice(0, 4).toUpperCase()] || 'Partner Bank';
+ };
+ const seed = acct.slice(-2);
+ const sampleNames = [
+ 'Rohan S. Mehta',
+ 'Anita Verma',
+ 'Bluestone Trading Co.',
+ 'Madhavi Singh',
+ 'Aarav Enterprises',
+ 'Neha Kapoor',
+ 'Spectra Components',
+ 'Indrani Sen',
+ 'Vijaya Traders',
+ 'Pinnacle Realty Pvt Ltd'
+ ];
+ const name = sampleNames[parseInt(seed, 10) % sampleNames.length] || 'Verified Beneficiary';
+ return {
+ beneficiaryName: name,
+ beneficiaryAccount: acct,
+ ifscCode: ifsc || 'HDFC0001234',
+ bankName: bankFromIfsc(ifsc),
+ branchName: 'Verified via IMPS Switch',
+ status: 'ACTIVE'
+ };
+ }
+
+ clearBanl(): void {
+ this.banlMatch = null;
+ this.banlError = null;
+ this.entryForm.get('beneficiaryName')?.setValue('', { emitEvent: false });
+ }
+
+ // ───────── Actions ─────────
+ submit(): void {
+ if (!this.banlVerified) {
+ this.banlError = 'BANL verification is mandatory before initiating a transfer.';
+ this.snack.open('Run BANL Look-up to verify the beneficiary before submitting.', 'OK', {
+ duration: 3500
+ });
+ return;
+ }
+ if (this.entryForm.invalid) {
+ this.entryForm.markAllAsTouched();
+ this.snack.open('Please fix the highlighted fields.', 'OK', { duration: 2800 });
+ return;
+ }
+ const v = this.entryForm.getRawValue(); // include disabled (BANL-filled) beneficiaryName
+ const txnId = 'TX' + Date.now().toString().slice(-10);
+ this.snack.open(
+ `${v.transactionType} initiated · ₹${Number(v.amount).toLocaleString('en-IN')} · Ref ${txnId}`,
+ 'OK',
+ { duration: 4500 }
+ );
+ if (this.isDialog) {
+ this.dialogRef!.close({ submitted: true, payload: { ...v, txnId } });
+ return;
+ }
+ this.resetForm();
+ }
+
+ resetForm(): void {
+ this.entryForm.reset({
+ transactionType: 'NEFT',
+ valueDate: new Date(),
+ amount: null,
+ senderAccount: '',
+ beneficiaryName: '',
+ beneficiaryAccount: '',
+ ifscCode: '',
+ purposeOfRemittance: '',
+ remarks: ''
+ });
+ this.clearBanl();
+ this.refreshAmountValidator('NEFT');
+ this.refreshPurposeValidator();
+ }
+
+ /** Sample / demo entry to make the form discoverable. Auto-runs BANL after prefill. */
+ loadSample(): void {
+ this.entryForm.patchValue(
+ {
+ transactionType: 'RTGS',
+ valueDate: new Date(),
+ amount: 250000,
+ senderAccount: 'SB10029384',
+ beneficiaryAccount: '50100123456789',
+ ifscCode: 'HDFC0001234',
+ purposeOfRemittance: 'TRADE_SETTLEMENT',
+ remarks: 'Q1 vendor settlement'
+ },
+ { emitEvent: false }
+ );
+ this.refreshAmountValidator('RTGS');
+ this.refreshPurposeValidator();
+ this.banlMatch = null;
+ this.banlError = null;
+ // Kick BANL so sample is immediately submittable.
+ this.performBanl();
+ }
+
+ back(): void {
+ if (this.isDialog) {
+ this.dialogRef!.close({ submitted: false });
+ return;
+ }
+ this.router.navigate([
+ '/home'
+ ]);
+ }
+}
diff --git a/src/app/digital/neft-rtgs-report/neft-rtgs-report.component.html b/src/app/digital/neft-rtgs-report/neft-rtgs-report.component.html
new file mode 100644
index 000000000..9c7fbed97
--- /dev/null
+++ b/src/app/digital/neft-rtgs-report/neft-rtgs-report.component.html
@@ -0,0 +1,433 @@
+
+
+
+
+
+
+
+
+
NEFT / RTGS — Report & Reconciliation
+
+ Digital · Outward Remittance Analytics · Showing
+ {{ totalCount | number }} transactions worth
+ ₹{{ totalAmount | number: '1.0-0' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ totalCount | number }}
+
Total Transactions
+
{{ neftCount }} NEFT · {{ rtgsCount }} RTGS
+
+
+
+
+
+
₹{{ totalAmount | number: '1.0-0' }}
+
Total Value
+
avg ₹{{ avgTicket | number: '1.0-0' }} / txn
+
+
+
+
+
+
{{ settledCount | number }}
+
Settled
+
success {{ successRate | number: '1.1-1' }}%
+
+
+
+
+
+
{{ pendingCount | number }}
+
In-Flight
+
uploaded / validated / sent
+
+
+
+
+
+
{{ returnedCount | number }}
+
Returned
+
verify return codes
+
+
+
+
+
+
+ @for (t of tabs; track t.id) {
+
+ }
+
+
+
+ @if (activeTab === 'trends') {
+
+
+ Daily Throughput (Last 7 Value Dates)
+ NEFT
+ RTGS
+
+
+ @for (b of trendBuckets; track b.date) {
+
+
+
+ ₹{{ b.amount | number: '1.0-0' }}
+
+
+
+
+ }
+
+
+ }
+
+
+ @if (activeTab === 'bank') {
+
+
+ Beneficiary Bank Distribution
+
+
+
+
+
+ | Bank |
+ Count |
+ Amount |
+ Settled |
+ Pending |
+ Returned |
+
+
+
+ @for (r of bankRows; track r.bank) {
+
+ |
+ {{ r.bank }}
+ |
+ {{ r.count | number }} |
+
+ ₹{{ r.amount | number: '1.0-0' }}
+ |
+
+ {{ r.settled }}
+ |
+
+ {{ r.pending }}
+ |
+
+ {{ r.returned }}
+ |
+
+ }
+
+
+
+
+ }
+
+
+ @if (activeTab === 'date') {
+
+
+ Value-Date Aggregation
+
+
+
+
+
+ | Value Date |
+ Count |
+ NEFT |
+ RTGS |
+ Amount |
+
+
+
+ @for (r of dateRows; track r.date) {
+
+ | {{ r.date | date: 'EEE, d MMM y' }} |
+ {{ r.count | number }} |
+
+ {{ r.neft }}
+ |
+
+ {{ r.rtgs }}
+ |
+
+ ₹{{ r.amount | number: '1.0-0' }}
+ |
+
+ }
+
+
+
+
+ }
+
+
+ @if (activeTab === 'pending') {
+
+
+
+ Processing-State Funnel
+
+
+
+ Uploaded / Validated / Sent
+ {{ pendingCount | number }}
+
+
+ Settled (Pass)
+ {{ settledCount | number }}
+
+
+ Returned
+ {{ returnedCount | number }}
+
+
+
+
+
+
+
+
+
+
+ Return-Code Breakdown
+
+ @if (returnCodeRows.length === 0) {
+
No returned transactions in current window.
+ } @else {
+
+
+
+ | Code |
+ Reason |
+ Count |
+ Amount |
+
+
+
+ @for (r of returnCodeRows; track r.code) {
+
+ |
+ {{ r.code }}
+ |
+ {{ r.reason }} |
+ {{ r.count }} |
+ ₹{{ r.amount | number: '1.0-0' }} |
+
+ }
+
+
+ }
+
+
+ }
+
+
+ @if (activeTab === 'channel') {
+
+
+ Channel Mix — Branch vs Mobile
+
+
+ @for (r of channelRows; track r.channel) {
+
+
+
+
+
+
+ {{ r.channel === 'MOBILE' ? 'Mobile / Self-Service' : 'Branch / Counter' }}
+
+
{{ r.count | number }} txns
+
₹{{ r.amount | number: '1.0-0' }}
+
+
+
+
{{ r.share | number: '1.1-1' }}% of value
+
+
+ }
+
+
+ }
+
+
+ @if (activeTab === 'user') {
+
+
+ Maker / Operator Leaderboard
+
+
+
+
+
+ | User |
+ Branch |
+ Txns |
+ Amount |
+
+
+
+ @for (r of userRows; track r.user + r.branch) {
+
+ |
+ {{ r.user }}
+ |
+ {{ r.branch }} |
+ {{ r.count | number }} |
+
+ ₹{{ r.amount | number: '1.0-0' }}
+ |
+
+ }
+
+
+
+
+ }
+
+
+
+
+
Transactions
+
+
+
+
+
+
+
+ @if (search || typeFilter || stateFilter) {
+
+ }
+
+
+
+
+
+
+ | Batch / Instruction |
+ UTR |
+ Remitter A/C |
+ Beneficiary |
+ IFSC |
+ Type |
+ Amount |
+ Value Date |
+ Purpose |
+ State |
+ Sent |
+
+
+
+ @for (t of filteredTxns; track t.utrNumber) {
+
+ |
+ {{ t.batchId }}
+ {{ t.instructionId }}
+ |
+ {{ t.utrNumber }} |
+ {{ t.remitterAcc }} |
+
+ {{ t.beneName }}
+ {{ t.beneAcc }} · {{ t.beneBank }}
+ |
+ {{ t.beneIfsc }} |
+
+
+ {{ t.paymentType }}
+
+ |
+
+ ₹{{ t.amount | number: '1.2-2' }}
+ |
+ {{ t.valueDate | date: 'd MMM y' }} |
+
+ {{ t.purposeCode }}
+ @if (t.leiCode) {
+ LEI
+ }
+ |
+
+
+ {{ t.processingState }}
+
+ @if (t.returnCode) {
+ {{ t.returnCode }} · {{ t.returnReason }}
+ }
+ |
+ {{ t.sentTimestamp }} |
+
+ }
+ @if (filteredTxns.length === 0) {
+
+ | No transactions match the current filters. |
+
+ }
+
+
+
+
+
+
+
+
diff --git a/src/app/digital/neft-rtgs-report/neft-rtgs-report.component.scss b/src/app/digital/neft-rtgs-report/neft-rtgs-report.component.scss
new file mode 100644
index 000000000..c5b49f241
--- /dev/null
+++ b/src/app/digital/neft-rtgs-report/neft-rtgs-report.component.scss
@@ -0,0 +1,795 @@
+/**
+ * Copyright since 2025 Mifos Initiative
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+:host {
+ --r-navy: #0b1e3d;
+ --r-blue: #1d4ed8;
+ --r-blue-l: #eff6ff;
+ --r-teal: #0e7e6b;
+ --r-teal-l: #e0f4f0;
+ --r-green: #15803d;
+ --r-green-l: #dcfce7;
+ --r-amber: #d97706;
+ --r-amber-l: #fef3c7;
+ --r-red: #c0392b;
+ --r-red-l: #fdecea;
+ --r-purple: #6d28d9;
+ --r-purple-l: #ede9fe;
+ --r-pink: #db2777;
+ --r-pink-l: #fce7f3;
+ --r-g50: #f8fafc;
+ --r-g100: #f1f5f9;
+ --r-g200: #e2e8f0;
+ --r-g300: #cbd5e1;
+ --r-g500: #64748b;
+ --r-g600: #475569;
+ --r-g700: #334155;
+ --r-g900: #0f172a;
+
+ display: block;
+ font-family:
+ 'DM Sans',
+ system-ui,
+ -apple-system,
+ sans-serif;
+ background: var(--r-g50);
+ color: var(--r-g900);
+ font-size: 13.5px;
+ line-height: 1.5;
+}
+
+.nrr-root {
+ padding: 18px 22px 90px;
+ max-width: 1500px;
+ margin: 0 auto;
+ position: relative;
+}
+
+/* ───────── HEADER ───────── */
+.nrr-head {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 16px;
+ flex-wrap: wrap;
+ margin-bottom: 16px;
+}
+
+.nrr-head-left {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+}
+
+.nrr-head-ic {
+ font-size: 26px;
+ color: var(--r-blue);
+}
+
+.nrr-head-t {
+ margin: 0;
+ font-size: 18px;
+ font-weight: 700;
+ color: var(--r-g900);
+}
+
+.nrr-head-s {
+ font-size: 12px;
+ color: var(--r-g500);
+
+ strong {
+ color: var(--r-g700);
+ }
+}
+
+.nrr-head-actions {
+ display: flex;
+ gap: 8px;
+
+ fa-icon {
+ margin-right: 6px;
+ }
+}
+
+/* ───────── KPIs ───────── */
+.nrr-kpis {
+ display: grid;
+ grid-template-columns: repeat(5, 1fr);
+ gap: 10px;
+ margin-bottom: 14px;
+}
+
+@media (width <= 1100px) {
+ .nrr-kpis {
+ grid-template-columns: repeat(3, 1fr);
+ }
+}
+
+@media (width <= 720px) {
+ .nrr-kpis {
+ grid-template-columns: repeat(2, 1fr);
+ }
+}
+
+.nrr-kpi {
+ background: #fff;
+ border: 1px solid var(--r-g200);
+ border-radius: 10px;
+ padding: 12px 14px;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ box-shadow: 0 1px 2px rgb(15 23 42 / 4%);
+ border-left-width: 4px;
+}
+
+.nrr-kpi-ic {
+ width: 36px;
+ height: 36px;
+ border-radius: 8px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 16px;
+ flex-shrink: 0;
+}
+
+.nrr-kpi-n {
+ font-size: 18px;
+ font-weight: 700;
+ color: var(--r-g900);
+ line-height: 1.2;
+}
+
+.nrr-kpi-l {
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--r-g700);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+
+.nrr-kpi-sub {
+ font-size: 11px;
+ color: var(--r-g500);
+}
+
+.nrr-kpi--blue {
+ border-left-color: var(--r-blue);
+
+ .nrr-kpi-ic {
+ background: var(--r-blue-l);
+ color: var(--r-blue);
+ }
+}
+
+.nrr-kpi--teal {
+ border-left-color: var(--r-teal);
+
+ .nrr-kpi-ic {
+ background: var(--r-teal-l);
+ color: var(--r-teal);
+ }
+}
+
+.nrr-kpi--green {
+ border-left-color: var(--r-green);
+
+ .nrr-kpi-ic {
+ background: var(--r-green-l);
+ color: var(--r-green);
+ }
+}
+
+.nrr-kpi--amber {
+ border-left-color: var(--r-amber);
+
+ .nrr-kpi-ic {
+ background: var(--r-amber-l);
+ color: var(--r-amber);
+ }
+}
+
+.nrr-kpi--red {
+ border-left-color: var(--r-red);
+
+ .nrr-kpi-ic {
+ background: var(--r-red-l);
+ color: var(--r-red);
+ }
+}
+
+/* ───────── TABS ───────── */
+.nrr-tabs {
+ display: flex;
+ gap: 4px;
+ background: #fff;
+ border: 1px solid var(--r-g200);
+ border-radius: 10px;
+ padding: 4px;
+ margin-bottom: 14px;
+ overflow-x: auto;
+}
+
+.nrr-tab {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ background: transparent;
+ border: 0;
+ border-radius: 7px;
+ padding: 8px 14px;
+ font-size: 12.5px;
+ font-weight: 600;
+ color: var(--r-g600);
+ cursor: pointer;
+ white-space: nowrap;
+ transition: all 0.15s ease;
+
+ fa-icon {
+ font-size: 12px;
+ }
+
+ &:hover {
+ background: var(--r-g100);
+ color: var(--r-g900);
+ }
+
+ &.active {
+ background: var(--r-blue);
+ color: #fff;
+ }
+}
+
+/* ───────── CARDS ───────── */
+.nrr-card {
+ background: #fff;
+ border: 1px solid var(--r-g200);
+ border-radius: 12px;
+ padding: 14px 16px;
+ margin-bottom: 14px;
+ box-shadow: 0 1px 2px rgb(15 23 42 / 4%);
+}
+
+.nrr-card-h {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 10px;
+ font-size: 13px;
+ font-weight: 700;
+ color: var(--r-g700);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ margin-bottom: 12px;
+ padding-bottom: 10px;
+ border-bottom: 1px dashed var(--r-g200);
+}
+
+.nrr-card-h-ic {
+ color: var(--r-blue);
+ font-size: 13px;
+ margin-right: 4px;
+}
+
+.nrr-grid-2 {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 14px;
+
+ @media (width <= 900px) {
+ grid-template-columns: 1fr;
+ }
+}
+
+/* ───────── PILLS ───────── */
+.nrr-pill {
+ display: inline-flex;
+ align-items: center;
+ font-size: 11px;
+ font-weight: 700;
+ padding: 3px 9px;
+ border-radius: 999px;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+
+ &.mini {
+ font-size: 10px;
+ padding: 2px 7px;
+ }
+
+ &.nrr-pill--blue {
+ background: var(--r-blue-l);
+ color: var(--r-blue);
+ }
+
+ &.nrr-pill--teal {
+ background: var(--r-teal-l);
+ color: var(--r-teal);
+ }
+
+ &.nrr-pill--green {
+ background: var(--r-green-l);
+ color: var(--r-green);
+ }
+
+ &.nrr-pill--amber {
+ background: var(--r-amber-l);
+ color: var(--r-amber);
+ }
+
+ &.nrr-pill--red {
+ background: var(--r-red-l);
+ color: var(--r-red);
+ }
+
+ &.nrr-pill--purple {
+ background: var(--r-purple-l);
+ color: var(--r-purple);
+ }
+}
+
+/* ───────── TREND CHART ───────── */
+.nrr-trend {
+ display: flex;
+ align-items: flex-end;
+ gap: 12px;
+ min-height: 220px;
+ padding: 10px 4px;
+}
+
+.nrr-trend-col {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 8px;
+ min-width: 60px;
+}
+
+.nrr-trend-bar-wrap {
+ width: 100%;
+ height: 160px;
+ display: flex;
+ align-items: flex-end;
+ justify-content: center;
+}
+
+.nrr-trend-bar {
+ width: 75%;
+ min-height: 6px;
+ background: linear-gradient(180deg, var(--r-blue) 0%, var(--r-purple) 100%);
+ border-radius: 6px 6px 0 0;
+ position: relative;
+ transition: height 0.3s ease;
+}
+
+.nrr-trend-amt {
+ position: absolute;
+ top: -22px;
+ left: 50%;
+ transform: translateX(-50%);
+ font-size: 11px;
+ font-weight: 700;
+ color: var(--r-g700);
+ white-space: nowrap;
+}
+
+.nrr-trend-meta {
+ text-align: center;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.nrr-trend-date {
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--r-g700);
+}
+
+.nrr-trend-split {
+ display: flex;
+ gap: 4px;
+ justify-content: center;
+}
+
+/* ───────── TABLE ───────── */
+.nrr-table-wrap {
+ overflow-x: auto;
+}
+
+.nrr-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 12.5px;
+
+ thead th {
+ background: var(--r-g100);
+ color: var(--r-g700);
+ text-align: left;
+ font-size: 11px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ padding: 8px 10px;
+ border-bottom: 1px solid var(--r-g200);
+ white-space: nowrap;
+
+ &.num {
+ text-align: right;
+ }
+ }
+
+ tbody td {
+ padding: 8px 10px;
+ border-bottom: 1px solid var(--r-g100);
+ vertical-align: top;
+
+ &.num {
+ text-align: right;
+ }
+ }
+
+ tbody tr:hover {
+ background: var(--r-blue-l);
+ }
+}
+
+.nrr-table-detail {
+ font-size: 12px;
+
+ tbody td {
+ padding: 7px 10px;
+ }
+}
+
+.mono {
+ font-family: 'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace;
+ font-size: 12px;
+}
+
+.small {
+ font-size: 11.5px;
+}
+
+.muted {
+ color: var(--r-g500);
+}
+
+/* ───────── FUNNEL ───────── */
+.nrr-funnel {
+ padding: 8px 4px;
+}
+
+.nrr-funnel-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 10px 0;
+ border-bottom: 1px dashed var(--r-g200);
+
+ &:last-of-type {
+ border-bottom: 0;
+ }
+}
+
+.nrr-funnel-l {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ font-weight: 600;
+ color: var(--r-g700);
+}
+
+.nrr-funnel-v {
+ font-size: 16px;
+ font-weight: 700;
+ color: var(--r-g900);
+}
+
+.nrr-dot {
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ display: inline-block;
+
+ &.nrr-dot--green {
+ background: var(--r-green);
+ }
+
+ &.nrr-dot--amber {
+ background: var(--r-amber);
+ }
+
+ &.nrr-dot--red {
+ background: var(--r-red);
+ }
+}
+
+.nrr-funnel-meter {
+ display: flex;
+ height: 10px;
+ border-radius: 999px;
+ overflow: hidden;
+ margin-top: 12px;
+ background: var(--r-g100);
+
+ .seg {
+ display: block;
+
+ &.green {
+ background: var(--r-green);
+ }
+
+ &.amber {
+ background: var(--r-amber);
+ }
+
+ &.red {
+ background: var(--r-red);
+ }
+ }
+}
+
+/* ───────── CHANNEL CARDS ───────── */
+.nrr-channel-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 12px;
+
+ @media (width <= 720px) {
+ grid-template-columns: 1fr;
+ }
+}
+
+.nrr-channel-card {
+ background: linear-gradient(135deg, var(--r-blue-l) 0%, #fff 100%);
+ border: 1px solid #dbeafe;
+ border-radius: 12px;
+ padding: 16px 18px;
+ display: flex;
+ gap: 14px;
+ align-items: flex-start;
+
+ &.is-mobile {
+ background: linear-gradient(135deg, var(--r-purple-l) 0%, #fff 100%);
+ border-color: #ddd6fe;
+
+ .nrr-channel-ic {
+ background: var(--r-purple);
+ }
+ }
+}
+
+.nrr-channel-ic {
+ width: 44px;
+ height: 44px;
+ border-radius: 10px;
+ background: var(--r-blue);
+ color: #fff;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 18px;
+}
+
+.nrr-channel-body {
+ flex: 1;
+}
+
+.nrr-channel-t {
+ font-size: 12px;
+ font-weight: 700;
+ color: var(--r-g700);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ margin-bottom: 4px;
+}
+
+.nrr-channel-n {
+ font-size: 22px;
+ font-weight: 700;
+ color: var(--r-g900);
+ line-height: 1.1;
+
+ span {
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--r-g500);
+ }
+}
+
+.nrr-channel-amt {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--r-g700);
+ margin: 6px 0;
+}
+
+.nrr-channel-bar {
+ background: rgb(0 0 0 / 6%);
+ height: 6px;
+ border-radius: 999px;
+ overflow: hidden;
+
+ span {
+ display: block;
+ height: 100%;
+ background: var(--r-blue);
+ border-radius: 999px;
+ transition: width 0.3s ease;
+ }
+}
+
+.is-mobile .nrr-channel-bar span {
+ background: var(--r-purple);
+}
+
+.nrr-channel-share {
+ font-size: 11px;
+ color: var(--r-g500);
+ margin-top: 4px;
+}
+
+/* ───────── FILTER ROW ───────── */
+.nrr-filters {
+ margin-left: auto;
+ display: flex;
+ gap: 6px;
+ align-items: center;
+ flex-wrap: wrap;
+ text-transform: none;
+ letter-spacing: 0;
+}
+
+.nrr-search {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+
+ input {
+ border: 1px solid var(--r-g200);
+ border-radius: 8px;
+ padding: 6px 10px 6px 28px;
+ font-size: 12px;
+ min-width: 240px;
+ background: #fff;
+ color: var(--r-g900);
+
+ &:focus {
+ outline: none;
+ border-color: var(--r-blue);
+ box-shadow: 0 0 0 2px rgb(29 78 216 / 15%);
+ }
+ }
+}
+
+.nrr-search-ic {
+ position: absolute;
+ left: 10px;
+ top: 50%;
+ transform: translateY(-50%);
+ color: var(--r-g500);
+ font-size: 11px;
+ pointer-events: none;
+}
+
+.nrr-select {
+ border: 1px solid var(--r-g200);
+ border-radius: 8px;
+ padding: 6px 10px;
+ font-size: 12px;
+ background: #fff;
+ color: var(--r-g900);
+ cursor: pointer;
+
+ &:focus {
+ outline: none;
+ border-color: var(--r-blue);
+ }
+}
+
+.nrr-clear-btn {
+ background: var(--r-red-l);
+ color: var(--r-red);
+ border: 1px solid #fecaca;
+ border-radius: 999px;
+ padding: 5px 12px;
+ font-size: 11px;
+ font-weight: 600;
+ cursor: pointer;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+
+ fa-icon {
+ margin-right: 4px;
+ }
+
+ &:hover {
+ background: var(--r-red);
+ color: #fff;
+ }
+}
+
+.nrr-empty {
+ padding: 22px 0;
+ text-align: center;
+ color: var(--r-g500);
+ font-size: 13px;
+}
+
+/* ───────── ENTRY DIALOG PANEL OVERRIDE ───────── */
+:host ::ng-deep .nr-entry-dialog {
+ .mat-mdc-dialog-surface {
+ padding: 0 !important;
+ border-radius: 14px !important;
+ overflow: hidden !important;
+ box-shadow:
+ 0 24px 48px rgb(15 23 42 / 28%),
+ 0 8px 16px rgb(15 23 42 / 18%) !important;
+ }
+
+ .mat-mdc-dialog-container .mdc-dialog__container,
+ .mat-mdc-dialog-container .mdc-dialog__surface {
+ padding: 0 !important;
+ }
+
+ .mat-mdc-dialog-content {
+ padding: 0 !important;
+ margin: 0 !important;
+ max-height: none !important;
+ }
+}
+
+:host ::ng-deep .nr-entry-dialog-backdrop {
+ background: rgb(11 30 61 / 55%);
+}
+
+/* ───────── FAB ───────── */
+.nrr-fab {
+ position: fixed;
+ right: 28px;
+ bottom: 28px;
+ background: linear-gradient(135deg, var(--r-blue) 0%, var(--r-purple) 100%);
+ color: #fff;
+ border: 0;
+ border-radius: 999px;
+ padding: 14px 22px;
+ font-size: 13.5px;
+ font-weight: 700;
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+ cursor: pointer;
+ box-shadow:
+ 0 10px 24px rgb(29 78 216 / 35%),
+ 0 4px 8px rgb(15 23 42 / 18%);
+ transition: all 0.18s ease;
+ z-index: 50;
+
+ fa-icon {
+ font-size: 16px;
+ }
+
+ .nrr-fab-label {
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ }
+
+ &:hover {
+ transform: translateY(-2px);
+ box-shadow:
+ 0 14px 30px rgb(29 78 216 / 45%),
+ 0 6px 12px rgb(15 23 42 / 22%);
+ }
+
+ &:active {
+ transform: translateY(0);
+ }
+}
diff --git a/src/app/digital/neft-rtgs-report/neft-rtgs-report.component.ts b/src/app/digital/neft-rtgs-report/neft-rtgs-report.component.ts
new file mode 100644
index 000000000..4f2a6f78b
--- /dev/null
+++ b/src/app/digital/neft-rtgs-report/neft-rtgs-report.component.ts
@@ -0,0 +1,747 @@
+/**
+ * Copyright since 2025 Mifos Initiative
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+import { Component, inject, OnInit } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { FaIconComponent } from '@fortawesome/angular-fontawesome';
+import { MatDialog } from '@angular/material/dialog';
+import { MatTooltip } from '@angular/material/tooltip';
+import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
+
+import { NeftRtgsEntryComponent } from '../neft-rtgs-entry/neft-rtgs-entry.component';
+
+export type PaymentType = 'NEFT' | 'RTGS';
+export type ProcessingState = 'UPLOADED' | 'VALIDATED' | 'SENT' | 'SETTLED' | 'PENDING' | 'RETURNED';
+
+export interface NeftRtgsTxn {
+ batchId: string;
+ instructionId: string;
+ utrNumber: string;
+ remitterAcc: string;
+ remitterBranch: string;
+ remitterUser: string;
+ remitterChannel: 'BRANCH' | 'MOBILE';
+ beneAcc: string;
+ beneIfsc: string;
+ beneName: string;
+ beneBank: string;
+ paymentType: PaymentType;
+ amount: number;
+ valueDate: string; // YYYY-MM-DD
+ purposeCode: string;
+ leiCode?: string;
+ processingState: ProcessingState;
+ returnCode?: string;
+ returnReason?: string;
+ sentTimestamp: string; // YYYY-MM-DD HH:mm:ss
+}
+
+type ReportTab = 'trends' | 'bank' | 'date' | 'pending' | 'channel' | 'user';
+
+interface BankRow {
+ bank: string;
+ count: number;
+ amount: number;
+ settled: number;
+ pending: number;
+ returned: number;
+}
+
+interface DateRow {
+ date: string;
+ count: number;
+ amount: number;
+ neft: number;
+ rtgs: number;
+}
+
+interface UserRow {
+ user: string;
+ count: number;
+ amount: number;
+ branch: string;
+}
+
+interface ChannelRow {
+ channel: 'BRANCH' | 'MOBILE';
+ count: number;
+ amount: number;
+ share: number;
+}
+
+@Component({
+ selector: 'mifosx-neft-rtgs-report',
+ templateUrl: './neft-rtgs-report.component.html',
+ styleUrls: ['./neft-rtgs-report.component.scss'],
+ imports: [
+ ...STANDALONE_SHARED_IMPORTS,
+ FormsModule,
+ FaIconComponent,
+ MatTooltip
+ ]
+})
+export class NeftRtgsReportComponent implements OnInit {
+ private dialog = inject(MatDialog);
+
+ /** Active tab */
+ activeTab: ReportTab = 'trends';
+
+ /** Search / filter */
+ search = '';
+ typeFilter: '' | PaymentType = '';
+ stateFilter: '' | ProcessingState = '';
+
+ /** All transactions (mock data — replace with API). */
+ txns: NeftRtgsTxn[] = [];
+
+ /** Tab labels */
+ readonly tabs: { id: ReportTab; label: string; icon: string }[] = [
+ { id: 'trends', label: 'Trends', icon: 'chart-line' },
+ { id: 'bank', label: 'Bank-wise', icon: 'university' },
+ { id: 'date', label: 'Date-wise', icon: 'calendar-alt' },
+ { id: 'pending', label: 'Pending / Pass / Return', icon: 'tasks' },
+ { id: 'channel', label: 'Branch / Mobile', icon: 'mobile-alt' },
+ { id: 'user', label: 'User-wise', icon: 'user-tie' }
+ ];
+
+ ngOnInit(): void {
+ this.txns = this.seedTransactions();
+ }
+
+ // ───────────────────── KPIs ─────────────────────
+ get totalCount(): number {
+ return this.txns.length;
+ }
+ get totalAmount(): number {
+ return this.txns.reduce((s, t) => s + t.amount, 0);
+ }
+ get settledCount(): number {
+ return this.txns.filter((t) => t.processingState === 'SETTLED').length;
+ }
+ get pendingCount(): number {
+ return this.txns.filter((t) => [
+ 'UPLOADED',
+ 'VALIDATED',
+ 'SENT',
+ 'PENDING'
+ ].includes(t.processingState)).length;
+ }
+ get returnedCount(): number {
+ return this.txns.filter((t) => t.processingState === 'RETURNED').length;
+ }
+ get neftCount(): number {
+ return this.txns.filter((t) => t.paymentType === 'NEFT').length;
+ }
+ get rtgsCount(): number {
+ return this.txns.filter((t) => t.paymentType === 'RTGS').length;
+ }
+ get neftAmount(): number {
+ return this.txns.filter((t) => t.paymentType === 'NEFT').reduce((s, t) => s + t.amount, 0);
+ }
+ get rtgsAmount(): number {
+ return this.txns.filter((t) => t.paymentType === 'RTGS').reduce((s, t) => s + t.amount, 0);
+ }
+ get successRate(): number {
+ if (this.totalCount === 0) return 0;
+ return (this.settledCount / this.totalCount) * 100;
+ }
+ get avgTicket(): number {
+ if (this.totalCount === 0) return 0;
+ return this.totalAmount / this.totalCount;
+ }
+
+ // ───────────────────── Filtered list ─────────────────────
+ get filteredTxns(): NeftRtgsTxn[] {
+ const q = this.search.trim().toLowerCase();
+ return this.txns.filter((t) => {
+ if (this.typeFilter && t.paymentType !== this.typeFilter) return false;
+ if (this.stateFilter && t.processingState !== this.stateFilter) return false;
+ if (!q) return true;
+ return (
+ t.utrNumber.toLowerCase().includes(q) ||
+ t.batchId.toLowerCase().includes(q) ||
+ t.instructionId.toLowerCase().includes(q) ||
+ t.beneAcc.toLowerCase().includes(q) ||
+ t.beneIfsc.toLowerCase().includes(q) ||
+ t.beneName.toLowerCase().includes(q) ||
+ t.remitterAcc.toLowerCase().includes(q)
+ );
+ });
+ }
+
+ // ───────────────────── Tab-specific aggregations ─────────────────────
+ get bankRows(): BankRow[] {
+ const map = new Map();
+ for (const t of this.txns) {
+ const key = t.beneBank;
+ if (!map.has(key)) {
+ map.set(key, { bank: key, count: 0, amount: 0, settled: 0, pending: 0, returned: 0 });
+ }
+ const r = map.get(key)!;
+ r.count++;
+ r.amount += t.amount;
+ if (t.processingState === 'SETTLED') r.settled++;
+ else if (t.processingState === 'RETURNED') r.returned++;
+ else r.pending++;
+ }
+ return Array.from(map.values()).sort((a, b) => b.amount - a.amount);
+ }
+
+ get dateRows(): DateRow[] {
+ const map = new Map();
+ for (const t of this.txns) {
+ if (!map.has(t.valueDate)) {
+ map.set(t.valueDate, { date: t.valueDate, count: 0, amount: 0, neft: 0, rtgs: 0 });
+ }
+ const r = map.get(t.valueDate)!;
+ r.count++;
+ r.amount += t.amount;
+ if (t.paymentType === 'NEFT') r.neft++;
+ else r.rtgs++;
+ }
+ return Array.from(map.values()).sort((a, b) => (a.date < b.date ? 1 : -1));
+ }
+
+ get userRows(): UserRow[] {
+ const map = new Map();
+ for (const t of this.txns) {
+ const key = `${t.remitterUser}|${t.remitterBranch}`;
+ if (!map.has(key)) {
+ map.set(key, { user: t.remitterUser, branch: t.remitterBranch, count: 0, amount: 0 });
+ }
+ const r = map.get(key)!;
+ r.count++;
+ r.amount += t.amount;
+ }
+ return Array.from(map.values()).sort((a, b) => b.amount - a.amount);
+ }
+
+ get channelRows(): ChannelRow[] {
+ const total = this.totalAmount || 1;
+ const map = new Map<'BRANCH' | 'MOBILE', ChannelRow>();
+ for (const t of this.txns) {
+ if (!map.has(t.remitterChannel)) {
+ map.set(t.remitterChannel, {
+ channel: t.remitterChannel,
+ count: 0,
+ amount: 0,
+ share: 0
+ });
+ }
+ const r = map.get(t.remitterChannel)!;
+ r.count++;
+ r.amount += t.amount;
+ }
+ map.forEach((r) => (r.share = (r.amount / total) * 100));
+ return Array.from(map.values());
+ }
+
+ /** Trend buckets — last 7 value-dates */
+ get trendBuckets(): DateRow[] {
+ return this.dateRows.slice(0, 7).reverse();
+ }
+
+ /** Max amount across trend buckets (for bar chart scaling). */
+ get trendMaxAmount(): number {
+ const buckets = this.trendBuckets;
+ if (!buckets.length) return 1;
+ return Math.max(...buckets.map((b) => b.amount));
+ }
+
+ /** Return-code breakdown for Pending/Pass/Return tab */
+ get returnCodeRows(): { code: string; reason: string; count: number; amount: number }[] {
+ const map = new Map();
+ for (const t of this.txns) {
+ if (t.processingState !== 'RETURNED' || !t.returnCode) continue;
+ if (!map.has(t.returnCode)) {
+ map.set(t.returnCode, {
+ code: t.returnCode,
+ reason: t.returnReason || '',
+ count: 0,
+ amount: 0
+ });
+ }
+ const r = map.get(t.returnCode)!;
+ r.count++;
+ r.amount += t.amount;
+ }
+ return Array.from(map.values()).sort((a, b) => b.count - a.count);
+ }
+
+ // ───────────────────── Actions ─────────────────────
+ showTab(tab: ReportTab): void {
+ this.activeTab = tab;
+ }
+
+ /** Floating action — open NEFT/RTGS entry in a non-dismissible dialog. */
+ openInitiateDialog(): void {
+ const ref = this.dialog.open(NeftRtgsEntryComponent, {
+ width: '880px',
+ maxWidth: '94vw',
+ maxHeight: '88vh',
+ disableClose: true,
+ autoFocus: false,
+ restoreFocus: true,
+ panelClass: 'nr-entry-dialog',
+ backdropClass: 'nr-entry-backdrop'
+ });
+ ref.afterClosed().subscribe((result) => {
+ if (result && result.submitted && result.payload) {
+ this.txns = [
+ this.toTxn(result.payload),
+ ...this.txns
+ ];
+ }
+ });
+ }
+
+ exportCsv(): void {
+ const headers = [
+ 'Batch ID',
+ 'Instruction ID',
+ 'UTR Number',
+ 'Remitter Acc',
+ 'Bene Acc No',
+ 'Bene IFSC',
+ 'Bene Name',
+ 'Payment Type',
+ 'Amount',
+ 'Value Date',
+ 'Purpose Code',
+ 'LEI Code',
+ 'Processing State',
+ 'Return Code',
+ 'Sent Timestamp'
+ ];
+ const rows: string[][] = [headers];
+ for (const t of this.filteredTxns) {
+ rows.push([
+ t.batchId,
+ t.instructionId,
+ t.utrNumber,
+ t.remitterAcc,
+ t.beneAcc,
+ t.beneIfsc,
+ t.beneName,
+ t.paymentType,
+ t.amount.toFixed(2),
+ t.valueDate,
+ t.purposeCode,
+ t.leiCode || '',
+ t.processingState,
+ t.returnCode || '',
+ t.sentTimestamp
+ ]);
+ }
+ const escape = (v: string): string => {
+ const s = (v || '').replace(/"/g, '""');
+ return /[,"\n]/.test(s) ? `"${s}"` : s;
+ };
+ const csv = rows.map((r) => r.map(escape).join(',')).join('\n') + '\n';
+ const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `NEFT_RTGS_Report_${new Date().toISOString().slice(0, 10)}.csv`;
+ a.click();
+ URL.revokeObjectURL(url);
+ }
+
+ clearFilters(): void {
+ this.search = '';
+ this.typeFilter = '';
+ this.stateFilter = '';
+ }
+
+ // ───────────────────── Helpers ─────────────────────
+ stateColor(state: ProcessingState): string {
+ switch (state) {
+ case 'SETTLED':
+ return 'green';
+ case 'RETURNED':
+ return 'red';
+ case 'SENT':
+ return 'blue';
+ case 'VALIDATED':
+ return 'teal';
+ case 'UPLOADED':
+ return 'amber';
+ case 'PENDING':
+ return 'amber';
+ }
+ }
+
+ trendBarHeight(amount: number): number {
+ return Math.max(6, Math.round((amount / this.trendMaxAmount) * 100));
+ }
+
+ /** Convert a submitted entry-form payload into a synthetic transaction row. */
+ private toTxn(p: any): NeftRtgsTxn {
+ const valueDate = p.valueDate
+ ? new Date(p.valueDate).toISOString().slice(0, 10)
+ : new Date().toISOString().slice(0, 10);
+ const ts = new Date();
+ const pad = (n: number) => String(n).padStart(2, '0');
+ return {
+ batchId: `H2H-${valueDate.replace(/-/g, '')}-${pad(Math.floor(Math.random() * 99) + 1)}`,
+ instructionId: 'ERP' + Date.now().toString().slice(-8),
+ utrNumber: 'KKBK' + Date.now().toString().slice(-11),
+ remitterAcc: p.senderAccount || '',
+ remitterBranch: 'Main Branch',
+ remitterUser: 'Current User',
+ remitterChannel: 'BRANCH',
+ beneAcc: p.beneficiaryAccount || '',
+ beneIfsc: (p.ifscCode || '').toUpperCase(),
+ beneName: p.beneficiaryName || '',
+ beneBank: this.bankFromIfsc(p.ifscCode || ''),
+ paymentType: p.transactionType,
+ amount: Number(p.amount) || 0,
+ valueDate,
+ purposeCode: this.purposeCodeFor(p.purposeOfRemittance),
+ leiCode: Number(p.amount) >= 500000000 ? '549300ABCDEF1234' : undefined,
+ processingState: 'UPLOADED',
+ sentTimestamp:
+ ts.getFullYear() +
+ '-' +
+ pad(ts.getMonth() + 1) +
+ '-' +
+ pad(ts.getDate()) +
+ ' ' +
+ pad(ts.getHours()) +
+ ':' +
+ pad(ts.getMinutes()) +
+ ':' +
+ pad(ts.getSeconds())
+ };
+ }
+
+ private bankFromIfsc(ifsc: string): string {
+ const code = ifsc.slice(0, 4).toUpperCase();
+ const map: Record = {
+ 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 = {
+ CAPITAL_TRANSFER: 'P0102',
+ FAMILY_MAINTENANCE: 'P1301',
+ TRADE_SETTLEMENT: 'P0801',
+ SALARY_PAYMENT: 'P1006',
+ LOAN_REPAYMENT: 'P0103',
+ TAX_PAYMENT: 'P1099',
+ VENDOR_PAYMENT: 'P0802',
+ INVESTMENT: 'P0202',
+ GIFT: 'P1302',
+ OTHER: 'P0099'
+ };
+ return map[p] || 'P0099';
+ }
+
+ // ───────────────────── Mock seed data ─────────────────────
+ private seedTransactions(): NeftRtgsTxn[] {
+ const today = new Date();
+ const dt = (offset: number, h = 10, m = 0): string => {
+ const d = new Date(today);
+ d.setDate(d.getDate() - offset);
+ const pad = (n: number) => String(n).padStart(2, '0');
+ return (
+ d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + pad(h) + ':' + pad(m) + ':00'
+ );
+ };
+ const vd = (offset: number): string => dt(offset).slice(0, 10);
+
+ return [
+ {
+ batchId: 'H2H-20260326-01',
+ instructionId: 'ERP77889922',
+ utrNumber: 'KKBKR5202608511',
+ remitterAcc: '50200012345678',
+ remitterBranch: 'Mumbai — BKC',
+ remitterUser: 'Anita Desai',
+ remitterChannel: 'BRANCH',
+ beneAcc: '91000222333444',
+ beneIfsc: 'HDFC0001234',
+ beneName: 'Global Logistics Ltd',
+ beneBank: 'HDFC Bank',
+ paymentType: 'RTGS',
+ amount: 550000,
+ valueDate: vd(0),
+ purposeCode: 'P0102',
+ leiCode: '549300ABCDEF1234',
+ processingState: 'SETTLED',
+ sentTimestamp: dt(0, 14, 5)
+ },
+ {
+ batchId: 'H2H-20260326-01',
+ instructionId: 'ERP77889923',
+ utrNumber: 'HDFCR5202608712',
+ remitterAcc: '50200012345678',
+ remitterBranch: 'Mumbai — BKC',
+ remitterUser: 'Anita Desai',
+ remitterChannel: 'BRANCH',
+ beneAcc: '345678901234',
+ beneIfsc: 'ICIC0000567',
+ beneName: 'Bluestone Trading Co.',
+ beneBank: 'ICICI Bank',
+ paymentType: 'NEFT',
+ amount: 78500,
+ valueDate: vd(0),
+ purposeCode: 'P0802',
+ processingState: 'SENT',
+ sentTimestamp: dt(0, 14, 6)
+ },
+ {
+ batchId: 'H2H-20260325-04',
+ instructionId: 'ERP77889811',
+ utrNumber: 'SBINR5202608902',
+ remitterAcc: '50200099887766',
+ remitterBranch: 'Bengaluru — MG Road',
+ remitterUser: 'Karthik Rao',
+ remitterChannel: 'MOBILE',
+ beneAcc: '912345678901',
+ beneIfsc: 'SBIN0011223',
+ beneName: 'Anita Verma',
+ beneBank: 'State Bank of India',
+ paymentType: 'NEFT',
+ amount: 15000,
+ valueDate: vd(1),
+ purposeCode: 'P1301',
+ processingState: 'SETTLED',
+ sentTimestamp: dt(1, 11, 22)
+ },
+ {
+ batchId: 'H2H-20260325-04',
+ instructionId: 'ERP77889812',
+ utrNumber: 'AXISR5202608811',
+ remitterAcc: '50200099887766',
+ remitterBranch: 'Bengaluru — MG Road',
+ remitterUser: 'Karthik Rao',
+ remitterChannel: 'MOBILE',
+ beneAcc: '6789012345',
+ beneIfsc: 'UTIB0000456',
+ beneName: 'Rakesh Industries',
+ beneBank: 'Axis Bank',
+ paymentType: 'RTGS',
+ amount: 1250000,
+ valueDate: vd(1),
+ purposeCode: 'P0801',
+ leiCode: '549300XYZAB7890',
+ processingState: 'RETURNED',
+ returnCode: 'R01',
+ returnReason: 'Insufficient Funds',
+ sentTimestamp: dt(1, 11, 25)
+ },
+ {
+ batchId: 'H2H-20260324-02',
+ instructionId: 'ERP77889701',
+ utrNumber: 'PUNBR5202608619',
+ remitterAcc: '50100221122334',
+ remitterBranch: 'Delhi — Connaught',
+ remitterUser: 'Sushma Iyer',
+ remitterChannel: 'BRANCH',
+ beneAcc: '50100998877665',
+ beneIfsc: 'PUNB0123400',
+ beneName: 'Madhavi Singh',
+ beneBank: 'Punjab National Bank',
+ paymentType: 'NEFT',
+ amount: 42000,
+ valueDate: vd(2),
+ purposeCode: 'P1006',
+ processingState: 'SETTLED',
+ sentTimestamp: dt(2, 9, 35)
+ },
+ {
+ batchId: 'H2H-20260324-02',
+ instructionId: 'ERP77889702',
+ utrNumber: 'YESBR5202608520',
+ remitterAcc: '50100221122334',
+ remitterBranch: 'Delhi — Connaught',
+ remitterUser: 'Sushma Iyer',
+ remitterChannel: 'BRANCH',
+ beneAcc: '11223344556677',
+ beneIfsc: 'YESB0000123',
+ beneName: 'Sigma Constructions',
+ beneBank: 'YES Bank',
+ paymentType: 'RTGS',
+ amount: 825000,
+ valueDate: vd(2),
+ purposeCode: 'P0102',
+ processingState: 'PENDING',
+ sentTimestamp: dt(2, 9, 38)
+ },
+ {
+ batchId: 'H2H-20260323-07',
+ instructionId: 'ERP77889601',
+ utrNumber: 'CNRBR5202608417',
+ remitterAcc: '50100887766554',
+ remitterBranch: 'Chennai — T. Nagar',
+ remitterUser: 'Ramesh Krishnan',
+ remitterChannel: 'MOBILE',
+ beneAcc: '99887766554433',
+ beneIfsc: 'CNRB0001890',
+ beneName: 'Padma Rao',
+ beneBank: 'Canara Bank',
+ paymentType: 'NEFT',
+ amount: 9800,
+ valueDate: vd(3),
+ purposeCode: 'P1302',
+ processingState: 'SETTLED',
+ sentTimestamp: dt(3, 16, 15)
+ },
+ {
+ batchId: 'H2H-20260323-07',
+ instructionId: 'ERP77889602',
+ utrNumber: 'BARBR5202608318',
+ remitterAcc: '50100887766554',
+ remitterBranch: 'Chennai — T. Nagar',
+ remitterUser: 'Ramesh Krishnan',
+ remitterChannel: 'BRANCH',
+ beneAcc: '50100123456987',
+ beneIfsc: 'BARB0VJVATA',
+ beneName: 'Vijaya Traders',
+ beneBank: 'Bank of Baroda',
+ paymentType: 'NEFT',
+ amount: 125000,
+ valueDate: vd(3),
+ purposeCode: 'P0802',
+ processingState: 'VALIDATED',
+ sentTimestamp: dt(3, 16, 20)
+ },
+ {
+ batchId: 'H2H-20260322-03',
+ instructionId: 'ERP77889501',
+ utrNumber: 'IDFBR5202608215',
+ remitterAcc: '50200445566778',
+ remitterBranch: 'Pune — Koregaon Park',
+ remitterUser: 'Vikram Joshi',
+ remitterChannel: 'BRANCH',
+ beneAcc: '70010023456789',
+ beneIfsc: 'IDFB0040101',
+ beneName: 'Pinnacle Realty Pvt Ltd',
+ beneBank: 'IDFC FIRST Bank',
+ paymentType: 'RTGS',
+ amount: 3450000,
+ valueDate: vd(4),
+ purposeCode: 'P0202',
+ leiCode: '549300QRSTUV5678',
+ processingState: 'SETTLED',
+ sentTimestamp: dt(4, 12, 45)
+ },
+ {
+ batchId: 'H2H-20260322-03',
+ instructionId: 'ERP77889502',
+ utrNumber: 'HDFCR5202608116',
+ remitterAcc: '50200445566778',
+ remitterBranch: 'Pune — Koregaon Park',
+ remitterUser: 'Vikram Joshi',
+ remitterChannel: 'MOBILE',
+ beneAcc: '50100654321789',
+ beneIfsc: 'HDFC0009876',
+ beneName: 'Neha Kapoor',
+ beneBank: 'HDFC Bank',
+ paymentType: 'NEFT',
+ amount: 25000,
+ valueDate: vd(4),
+ purposeCode: 'P1301',
+ processingState: 'SETTLED',
+ sentTimestamp: dt(4, 12, 50)
+ },
+ {
+ batchId: 'H2H-20260321-05',
+ instructionId: 'ERP77889411',
+ utrNumber: 'KKBKR5202608013',
+ remitterAcc: '50200778899001',
+ remitterBranch: 'Hyderabad — Banjara',
+ remitterUser: 'Lakshmi Naidu',
+ remitterChannel: 'BRANCH',
+ beneAcc: '12345678900011',
+ beneIfsc: 'KKBK0001899',
+ beneName: 'Aarav Enterprises',
+ beneBank: 'Kotak Mahindra Bank',
+ paymentType: 'NEFT',
+ amount: 67000,
+ valueDate: vd(5),
+ purposeCode: 'P0802',
+ processingState: 'RETURNED',
+ returnCode: 'R03',
+ returnReason: 'Account Closed',
+ sentTimestamp: dt(5, 10, 5)
+ },
+ {
+ batchId: 'H2H-20260321-05',
+ instructionId: 'ERP77889412',
+ utrNumber: 'SBINR5202607914',
+ remitterAcc: '50200778899001',
+ remitterBranch: 'Hyderabad — Banjara',
+ remitterUser: 'Lakshmi Naidu',
+ remitterChannel: 'BRANCH',
+ beneAcc: '50100445566778',
+ beneIfsc: 'SBIN0008765',
+ beneName: 'Spectra Components',
+ beneBank: 'State Bank of India',
+ paymentType: 'RTGS',
+ amount: 980000,
+ valueDate: vd(5),
+ purposeCode: 'P0801',
+ leiCode: '549300LMNOPQ2468',
+ processingState: 'SETTLED',
+ sentTimestamp: dt(5, 10, 10)
+ },
+ {
+ batchId: 'H2H-20260320-06',
+ instructionId: 'ERP77889301',
+ utrNumber: 'ICICR5202607812',
+ remitterAcc: '50100334455667',
+ remitterBranch: 'Kolkata — Park Street',
+ remitterUser: 'Arjun Banerjee',
+ remitterChannel: 'MOBILE',
+ beneAcc: '88990011223344',
+ beneIfsc: 'ICIC0001234',
+ beneName: 'Eastern Logistics',
+ beneBank: 'ICICI Bank',
+ paymentType: 'NEFT',
+ amount: 188000,
+ valueDate: vd(6),
+ purposeCode: 'P0802',
+ processingState: 'SETTLED',
+ sentTimestamp: dt(6, 14, 30)
+ },
+ {
+ batchId: 'H2H-20260320-06',
+ instructionId: 'ERP77889302',
+ utrNumber: 'AXISR5202607711',
+ remitterAcc: '50100334455667',
+ remitterBranch: 'Kolkata — Park Street',
+ remitterUser: 'Arjun Banerjee',
+ remitterChannel: 'BRANCH',
+ beneAcc: '50200998877665',
+ beneIfsc: 'UTIB0001234',
+ beneName: 'Indrani Sen',
+ beneBank: 'Axis Bank',
+ paymentType: 'NEFT',
+ amount: 5500,
+ valueDate: vd(6),
+ purposeCode: 'P1302',
+ processingState: 'SETTLED',
+ sentTimestamp: dt(6, 14, 32)
+ }
+ ];
+ }
+}
diff --git a/src/app/login/two-factor-authentication/two-factor-authentication.component.ts b/src/app/login/two-factor-authentication/two-factor-authentication.component.ts
index c8d6dface..1f6106f4b 100644
--- a/src/app/login/two-factor-authentication/two-factor-authentication.component.ts
+++ b/src/app/login/two-factor-authentication/two-factor-authentication.component.ts
@@ -317,15 +317,11 @@ export class TwoFactorAuthenticationComponent implements OnInit, OnDestroy {
res?.errorMessage || 'DSC verified, but the verification token could not be claimed.';
return;
}
- // Reuse the existing /twofactor/validate pipeline; backend treats the
- // DSC-issued bearer token the same as an OTP code.
- this.authenticationService.validateOTP(token).subscribe({
- error: (err) => {
- this.dscFailed = true;
- this.dscErrorMessage =
- err?.error?.defaultUserMessage || err?.error?.message || err?.message || 'Token rejected by server.';
- }
- });
+ // The token returned by /claim is already a valid TFAccessToken (DscLoginChallengeService
+ // mints it directly into tf_access_token). The OTP-shaped /twofactor/validate path would
+ // 4xx here because there's no in-memory OTPRequest to exchange — just run the finalise-
+ // login bookkeeping with the token we already have.
+ this.authenticationService.applyTwoFactorToken(token, res?.tfaTokenLiveSeconds || 0);
},
error: (err) => {
this.dscFailed = true;
diff --git a/src/app/shared/icons.module.ts b/src/app/shared/icons.module.ts
index 32acaeb75..2e3a3e2fd 100644
--- a/src/app/shared/icons.module.ts
+++ b/src/app/shared/icons.module.ts
@@ -188,7 +188,11 @@ import {
faCube,
faLongArrowAltRight,
faStream,
- faThLarge
+ faThLarge,
+ faBrain,
+ faRobot,
+ faMicrochip,
+ faMagic
} from '@fortawesome/free-solid-svg-icons';
/**
@@ -383,7 +387,11 @@ export class IconsModule {
faCube,
faLongArrowAltRight,
faStream,
- faThLarge
+ faThLarge,
+ faBrain,
+ faRobot,
+ faMicrochip,
+ faMagic
);
}
}
diff --git a/src/app/shared/notifications-tray/notifications-tray.component.html b/src/app/shared/notifications-tray/notifications-tray.component.html
index ef0e6206b..912f51672 100644
--- a/src/app/shared/notifications-tray/notifications-tray.component.html
+++ b/src/app/shared/notifications-tray/notifications-tray.component.html
@@ -19,7 +19,36 @@
matBadgeSize="medium"
>
{{ 'tooltips.Notifications' | translate }}
-
+
+