From 595054b0b815c85ba32460e9cafb716b602bd668 Mon Sep 17 00:00:00 2001 From: Aditya Date: Fri, 15 May 2026 11:50:26 +0530 Subject: [PATCH] feat(digital+toolbar): NEFT/RTGS module, AI menu, polished header icons - Add /digital/neft-rtgs (entry) and /digital/neft-rtgs-report (analytics): KPIs, tabbed views (Trends, Bank-wise, Date-wise, Pending/Pass/Return, Branch/Mobile, User-wise), filterable transaction table with CSV export, and a non-dismissible Initiate dialog launched from a floating action. - Make BANL verification mandatory before NEFT/RTGS submit; auto-fill and lock beneficiary name from the lookup response, invalidate match on A/C or IFSC edit, demo fallback for unknown accounts. - Add Digital menu links (NEFT/RTGS + Report) in shell top-nav. - Replace toolbar theme toggle with AI / Intelligence Modules mega menu: 4 colour-coded columns covering Lending & Risk, Investment & Profit, Compliance & Advisory, Governance (9 modules total). Wire RBI Notifications Monitoring to /rbi-circulars. - Unify notifications, settings, AI, and profile toolbar buttons into a 26px ring style; colourful gradient bell SVG for notifications, custom gear+petals AI mark, online dot on the avatar. - Trim ISO 27001 chip in the top bar. - Add MPL header to generated .env.ts and its template in version.js. - Bundles other pending working-tree changes (clients family/nominee tab tweaks, auth/2FA fixes, translations) into the same push. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/settings.local.json | 6 +- src/app/app.module.ts | 2 + ...client-family-member-dialog.component.html | 28 +- ...client-family-member-dialog.component.scss | 54 ++ .../client-family-member-dialog.component.ts | 37 +- .../client-family-members-step.component.ts | 7 +- .../client-general-step.component.ts | 3 +- src/app/clients/clients-routing.module.ts | 2 +- .../add-family-member.component.ts | 9 +- .../family-members-tab.component.ts | 88 +- .../nominee-tab/nominee-tab.component.html | 59 +- .../nominee-tab/nominee-tab.component.scss | 179 +++- .../nominee-tab/nominee-tab.component.ts | 6 +- src/app/clients/clients.service.ts | 10 +- .../authentication/authentication.service.ts | 18 + src/app/core/shell/shell.component.html | 7 +- .../core/shell/toolbar/toolbar.component.html | 190 ++++- .../core/shell/toolbar/toolbar.component.scss | 423 ++++++++-- .../core/shell/toolbar/toolbar.component.ts | 2 - src/app/digital/digital-routing.module.ts | 35 + src/app/digital/digital.module.ts | 24 + .../neft-rtgs-entry.component.html | 304 +++++++ .../neft-rtgs-entry.component.scss | 595 +++++++++++++ .../neft-rtgs-entry.component.ts | 434 ++++++++++ .../neft-rtgs-report.component.html | 433 ++++++++++ .../neft-rtgs-report.component.scss | 795 ++++++++++++++++++ .../neft-rtgs-report.component.ts | 747 ++++++++++++++++ .../two-factor-authentication.component.ts | 14 +- src/app/shared/icons.module.ts | 12 +- .../notifications-tray.component.html | 31 +- .../notifications-tray.component.ts | 2 - src/assets/translations/en-US.json | 10 +- src/environments/.env.ts | 5 +- src/main.scss | 23 + version.js | 9 +- 35 files changed, 4409 insertions(+), 194 deletions(-) create mode 100644 src/app/digital/digital-routing.module.ts create mode 100644 src/app/digital/digital.module.ts create mode 100644 src/app/digital/neft-rtgs-entry/neft-rtgs-entry.component.html create mode 100644 src/app/digital/neft-rtgs-entry/neft-rtgs-entry.component.scss create mode 100644 src/app/digital/neft-rtgs-entry/neft-rtgs-entry.component.ts create mode 100644 src/app/digital/neft-rtgs-report/neft-rtgs-report.component.html create mode 100644 src/app/digital/neft-rtgs-report/neft-rtgs-report.component.scss create mode 100644 src/app/digital/neft-rtgs-report/neft-rtgs-report.component.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json index f56f095f5..341623986 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -46,7 +46,11 @@ "Bash(node -e \"console.log\\(require\\(''./src/assets/translations/en-US.json''\\).labels?.heading?.[''System'']\\)\")", "Bash(npm list:*)", "Bash(find c:UsersadityMusicCBS -type d -name *fineract*)", - "Bash(xargs -I {} basename {})" + "Bash(xargs -I {} basename {})", + "Bash(findstr /n \"faExchange faBolt faClock faPaperPlane faFlask faSpinner faUserFriends faCommentDots faListAlt faInfoCircle faSearch faTimes faUndo faCheckCircle faExclamationTriangle\" \"c:\\\\Users\\\\adity\\\\Music\\\\CBS\\\\UI\\\\web-app\\\\src\\\\app\\\\shared\\\\icons.module.ts\")", + "Bash(findstr /i \"error neft-rtgs digital\")", + "Bash(findstr /I /C:\"error\" /C:\"neft-rtgs\" /C:\"digital\")", + "Bash(findstr /V \"warning\")" ] } } diff --git a/src/app/app.module.ts b/src/app/app.module.ts index dee8c359d..c3e26d686 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -61,6 +61,7 @@ import { DeafModule } from './deaf/deaf.module'; import { DicgcModule } from './dicgc/dicgc.module'; import { FiuModule } from './fiu/fiu.module'; import { SftModule } from './sft/sft.module'; +import { DigitalModule } from './digital/digital.module'; import { PortalModule } from '@angular/cdk/portal'; /** Main Routing Module */ @@ -158,6 +159,7 @@ export function HttpLoaderFactory(http: HttpClient) { DicgcModule, FiuModule, SftModule, + DigitalModule, AppRoutingModule, NotFoundComponent, CallbackComponent, diff --git a/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.html b/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.html index 7e928ce87..d5b1e8492 100644 --- a/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.html +++ b/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.html @@ -169,13 +169,28 @@ } + @if (linkedSelectError) { +
+ error_outline + {{ linkedSelectError }} +
+ } @if (!searching && searchResults.length > 0) {
@for (r of searchResults; track r.id) { -
+
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 @@
-
+
-
- +
+ + +
-
+
+
+ + +
+ +
+
AI / Intelligence Modules
+
Smart banking · Powered by ML & LLMs
+
+
+ +
+
+
+ + Lending & Risk +
+ + + +
+ +
+
+ + Investment & Profit +
+ + +
+ +
+
+ + Compliance & Advisory +
+ + + +
+ +
+
+ + Governance +
+ +
+
+ + +
+ @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' }} +
+
+
+
+
+ RTGS Floor + ₹{{ RTGS_MIN | number: '1.0-0' }} +
+ @if (isDialog) { + + } +
+
+ + @if (entryForm) { +
+ +
+
+ + Transaction Setup +
+ +
+ + Transaction Type + + NEFT — National EFT + RTGS — Real-Time Gross + + + @if (entryForm.get('transactionType')?.hasError('required')) { + Transaction Type is required + } + + + + Value Date + + + + @if (entryForm.get('valueDate')?.hasError('required')) { + Value Date is required + } + + + + Amount (₹) + ₹  + + @if (entryForm.get('amount')?.hasError('required')) { + Amount is required + } + @if (entryForm.get('amount')?.hasError('min') && !entryForm.get('amount')?.hasError('required')) { + + @if (rtgsUnderMin) { + RTGS min ₹{{ RTGS_MIN | number: '1.0-0' }} + } @else { + Amount must be > 0 + } + + } + + + + Sender Account No. + + @if (entryForm.get('senderAccount')?.hasError('required')) { + Sender Account is required + } + @if ( + entryForm.get('senderAccount')?.hasError('pattern') && + !entryForm.get('senderAccount')?.hasError('required') + ) { + Use 6–20 alphanumeric chars + } + +
+
+ + +
+
+ + Beneficiary Details + @if (banlVerified) { + + Verified + + } @else { + + Verification Required + + } + +
+ +
+ + Beneficiary Account + + @if (entryForm.get('beneficiaryAccount')?.hasError('required')) { + Beneficiary Account is required + } + @if ( + entryForm.get('beneficiaryAccount')?.hasError('pattern') && + !entryForm.get('beneficiaryAccount')?.hasError('required') + ) { + Use 6–20 alphanumeric chars + } + + + + IFSC Code + + @if (entryForm.get('ifscCode')?.hasError('required')) { + IFSC Code is required + } + @if (entryForm.get('ifscCode')?.hasError('pattern') && !entryForm.get('ifscCode')?.hasError('required')) { + Invalid IFSC — 11 chars (e.g. HDFC0001234) + } + + + + Beneficiary Name (auto-filled by BANL) + + @if (banlVerified) { + + } @else { + + } + Fetched from the IMPS Switch via account no. + IFSC — cannot be typed manually. + +
+ + + @if (banlMatch) { +
+ +
+ {{ banlMatch.beneficiaryName }} · + {{ banlMatch.beneficiaryAccount }} · + {{ banlMatch.ifscCode }} · {{ banlMatch.bankName }}, + {{ banlMatch.branchName }} + · {{ banlMatch.status }} +
+ +
+ } + @if (banlError) { +
+ +
{{ banlError }}
+ +
+ } +
+ + +
+
+ + Purpose & Notes + @if (purposeMandatory) { + Mandatory · {{ isRtgs ? 'RTGS' : 'High-Value NEFT' }} + } +
+ +
+ + + Purpose of Remittance + @if (purposeMandatory) { + * + } + + + @for (p of purposeOptions; track p.code) { + {{ p.label }} + } + + @if (entryForm.get('purposeOfRemittance')?.hasError('required')) { + Purpose required for {{ isRtgs ? 'RTGS' : 'high-value NEFT' }} + } + + + + Remarks (optional) + + +
+
+ + +
+ + + + @if (!banlVerified) { + + + BANL Look-up required to enable Initiate + + } + + +
+
+ } +
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' }} +
+
+
+
{{ b.date | date: 'd MMM' }}
+
+ N {{ b.neft }} + R {{ b.rtgs }} +
+
+
+ } +
+
+ } + + + @if (activeTab === 'bank') { +
+
+ Beneficiary Bank Distribution +
+
+ + + + + + + + + + + + + @for (r of bankRows; track r.bank) { + + + + + + + + + } + +
BankCountAmountSettledPendingReturned
+ {{ r.bank }} + {{ r.count | number }} + ₹{{ r.amount | number: '1.0-0' }} + + {{ r.settled }} + + {{ r.pending }} + + {{ r.returned }} +
+
+
+ } + + + @if (activeTab === 'date') { +
+
+ Value-Date Aggregation +
+
+ + + + + + + + + + + + @for (r of dateRows; track r.date) { + + + + + + + + } + +
Value DateCountNEFTRTGSAmount
{{ 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 { + + + + + + + + + + + @for (r of returnCodeRows; track r.code) { + + + + + + + } + +
CodeReasonCountAmount
+ {{ 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' }}
+
+ +
+ +
+
+ } +
+
+ } + + + @if (activeTab === 'user') { +
+
+ Maker / Operator Leaderboard +
+
+ + + + + + + + + + + @for (r of userRows; track r.user + r.branch) { + + + + + + + } + +
UserBranchTxnsAmount
+ {{ r.user }} + {{ r.branch }}{{ r.count | number }} + ₹{{ r.amount | number: '1.0-0' }} +
+
+
+ } + + +
+
+ Transactions +
+ + + + @if (search || typeFilter || stateFilter) { + + } +
+
+
+ + + + + + + + + + + + + + + + + + @for (t of filteredTxns; track t.utrNumber) { + + + + + + + + + + + + + + } + @if (filteredTxns.length === 0) { + + + + } + +
Batch / InstructionUTRRemitter A/CBeneficiaryIFSCTypeAmountValue DatePurposeStateSent
+
{{ 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 }}
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 }} - + +