- Access Control module: SMS/Email/TOTP/Hardware-token MFA factor management - OTP Audit Logs module under Admin menu (list + detail) with 401/403 handling - Two-factor authentication login flow updates - Client KYC, India address, family-members, ckyc, nominee enhancements - New shared KYC helpers (kyc-proof-types, kyc-upload-helper.service) - User create/edit/view enhancements - Shell navigation: OTP Audit Logs entry under real Admin menu
588 lines
18 KiB
TypeScript
588 lines
18 KiB
TypeScript
/**
|
|
* Copyright since 2025 Mifos Initiative
|
|
*
|
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
*/
|
|
import { Component, OnInit, inject } from '@angular/core';
|
|
import { ActivatedRoute } from '@angular/router';
|
|
import { MatDialog } from '@angular/material/dialog';
|
|
import { DomSanitizer, SafeUrl } from '@angular/platform-browser';
|
|
import { ClientsService } from '../../clients.service';
|
|
import { SettingsService } from 'app/settings/settings.service';
|
|
import { DeleteDialogComponent } from '../../../shared/delete-dialog/delete-dialog.component';
|
|
import { ImageZoomDialogComponent } from '../../client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component';
|
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
|
import { MatIcon } from '@angular/material/icon';
|
|
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
|
import { MatTooltip } from '@angular/material/tooltip';
|
|
import { MatSlideToggle } from '@angular/material/slide-toggle';
|
|
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
|
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
|
import { FormsModule } from '@angular/forms';
|
|
|
|
interface AccountGroup {
|
|
type: string;
|
|
apiType: string; // SB, CB, FD, RD, LA
|
|
label: string;
|
|
icon: string;
|
|
accounts: any[];
|
|
}
|
|
|
|
@Component({
|
|
selector: 'mifosx-nominee-tab',
|
|
templateUrl: './nominee-tab.component.html',
|
|
styleUrls: ['./nominee-tab.component.scss'],
|
|
imports: [
|
|
...STANDALONE_SHARED_IMPORTS,
|
|
FormsModule,
|
|
FaIconComponent,
|
|
MatIcon,
|
|
MatProgressSpinner,
|
|
MatTooltip,
|
|
MatSlideToggle,
|
|
DateFormatPipe
|
|
]
|
|
})
|
|
export class NomineeTabComponent implements OnInit {
|
|
private route = inject(ActivatedRoute);
|
|
private clientsService = inject(ClientsService);
|
|
private settingsService = inject(SettingsService);
|
|
private dialog = inject(MatDialog);
|
|
private sanitizer = inject(DomSanitizer);
|
|
|
|
clientId: string;
|
|
loading = true;
|
|
accountGroups: AccountGroup[] = [];
|
|
selectedType: string = '';
|
|
selectedAccountId: number | null = null;
|
|
|
|
/** Nominees for selected account */
|
|
nominees: any[] = [];
|
|
nomineesLoading = false;
|
|
|
|
/** Add nominee form */
|
|
showAddForm = false;
|
|
searchText = '';
|
|
searchResults: any[] = [];
|
|
searching = false;
|
|
private searchTimer: any = null;
|
|
selectedClient: any = null;
|
|
fetchingDetails = false;
|
|
|
|
/** Nominee form fields */
|
|
nomineeRelationshipId: number | null = null;
|
|
nomineeShare: number | null = null;
|
|
nomineeIsMinor = false;
|
|
nomineeSaving = false;
|
|
nomineeRemarks = '';
|
|
|
|
/** Guardian */
|
|
guardianSearchText = '';
|
|
guardianSearchResults: any[] = [];
|
|
guardianSearching = false;
|
|
selectedGuardian: any = null;
|
|
private guardianSearchTimer: any = null;
|
|
|
|
/** Linked client details */
|
|
linkedProfileImage: SafeUrl | null = null;
|
|
linkedSignatureImage: SafeUrl | null = null;
|
|
linkedKycStatus: any = null;
|
|
|
|
/** Template data from API */
|
|
relationshipOptions: any[] = [];
|
|
|
|
/** Nomination type options */
|
|
nominationTypeOptions = [
|
|
{ value: 'DA1', label: 'DA1 - Add Nominee' },
|
|
{ value: 'DA5', label: 'DA5 - Modify / Cancel Nominee' }
|
|
];
|
|
|
|
/** Add form nomination type */
|
|
nomineeNomType = 'DA1';
|
|
|
|
/** Editing state */
|
|
editingIndex: number | null = null;
|
|
editRelId: number | null = null;
|
|
editShare: number | null = null;
|
|
editNomType: string = 'DA1';
|
|
|
|
get hasAccounts(): boolean {
|
|
return this.accountGroups.some((g) => g.accounts.length > 0);
|
|
}
|
|
|
|
get selectedGroup(): AccountGroup | undefined {
|
|
return this.accountGroups.find((g) => g.type === this.selectedType);
|
|
}
|
|
|
|
get selectedAccount(): any {
|
|
return this.selectedGroup?.accounts.find((a: any) => a.id === this.selectedAccountId);
|
|
}
|
|
|
|
get totalShare(): number {
|
|
return this.nominees.reduce((sum, n) => sum + (n.percentageShare || 0), 0);
|
|
}
|
|
|
|
get maxAllowedShare(): number {
|
|
return 100 - this.totalShare;
|
|
}
|
|
|
|
ngOnInit(): void {
|
|
this.clientId = this.route.parent.snapshot.params['clientId'];
|
|
this.loadAccounts();
|
|
this.loadTemplate();
|
|
}
|
|
|
|
private loadTemplate(): void {
|
|
this.clientsService.getClientNomineeTemplate(this.clientId).subscribe({
|
|
next: (template: any) => {
|
|
this.relationshipOptions = template?.relationshipOptions || [];
|
|
},
|
|
error: () => {
|
|
// Fallback to family member template
|
|
this.clientsService.getClientTemplate().subscribe((t: any) => {
|
|
this.relationshipOptions = t?.familyMemberOptions?.relationshipIdOptions || [];
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
private loadAccounts(): void {
|
|
this.loading = true;
|
|
this.clientsService.getClientAccountData(this.clientId).subscribe({
|
|
next: (data: any) => {
|
|
this.loading = false;
|
|
this.accountGroups = [
|
|
{
|
|
type: 'savings',
|
|
apiType: 'SB',
|
|
label: 'Savings Account',
|
|
icon: 'savings',
|
|
accounts: this.filterActive(data.savingsAccounts)
|
|
},
|
|
{
|
|
type: 'current',
|
|
apiType: 'CB',
|
|
label: 'Current Account',
|
|
icon: 'account_balance',
|
|
accounts: this.filterActive(data.currentAccounts)
|
|
},
|
|
{
|
|
type: 'fd',
|
|
apiType: 'FD',
|
|
label: 'Fixed Deposit',
|
|
icon: 'lock',
|
|
accounts: this.filterActive(data.fixedDepositAccounts)
|
|
},
|
|
{
|
|
type: 'rd',
|
|
apiType: 'RD',
|
|
label: 'Recurring Deposit',
|
|
icon: 'autorenew',
|
|
accounts: this.filterActive(data.recurringDepositAccounts)
|
|
},
|
|
{
|
|
type: 'loan',
|
|
apiType: 'LA',
|
|
label: 'Loan Account',
|
|
icon: 'credit_score',
|
|
accounts: this.filterActive(data.loanAccounts)
|
|
}
|
|
];
|
|
const firstWithAccounts = this.accountGroups.find((g) => g.accounts.length > 0);
|
|
if (firstWithAccounts) {
|
|
this.selectedType = firstWithAccounts.type;
|
|
// Auto-select if only 1 account in the group
|
|
if (firstWithAccounts.accounts.length === 1) {
|
|
this.selectedAccountId = firstWithAccounts.accounts[0].id;
|
|
this.loadNominees();
|
|
}
|
|
}
|
|
},
|
|
error: () => {
|
|
this.loading = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
private filterActive(accounts: any[]): any[] {
|
|
if (!accounts) return [];
|
|
// Include all non-closed accounts (active, disbursed, pending, etc.)
|
|
return accounts.filter((a: any) => a.status?.active || a.status?.value === 'Active' || !a.status?.closed);
|
|
}
|
|
|
|
onTypeChange(): void {
|
|
this.selectedAccountId = null;
|
|
this.nominees = [];
|
|
this.resetAddForm();
|
|
// Auto-select if only 1 account
|
|
if (this.selectedGroup?.accounts.length === 1) {
|
|
this.selectedAccountId = this.selectedGroup.accounts[0].id;
|
|
this.loadNominees();
|
|
}
|
|
}
|
|
|
|
onAccountChange(): void {
|
|
this.nominees = [];
|
|
this.resetAddForm();
|
|
if (this.selectedAccountId) {
|
|
this.loadNominees();
|
|
}
|
|
}
|
|
|
|
private loadNominees(): void {
|
|
this.nomineesLoading = true;
|
|
const account = this.selectedAccount;
|
|
const accountType = this.selectedGroup?.apiType || '';
|
|
this.clientsService.getClientNominees(this.clientId, account?.accountNo, accountType).subscribe({
|
|
next: (data: any) => {
|
|
this.nomineesLoading = false;
|
|
this.nominees = Array.isArray(data) ? data : [];
|
|
// Enrich each nominee with profile image & signature
|
|
this.nominees.forEach((n) => this.enrichNomineeImages(n));
|
|
},
|
|
error: () => {
|
|
this.nomineesLoading = false;
|
|
this.nominees = [];
|
|
}
|
|
});
|
|
}
|
|
|
|
/** Fetch profile image & signature for a nominee card */
|
|
private enrichNomineeImages(nominee: any): void {
|
|
const cid = nominee.nomineeClientId?.toString();
|
|
if (!cid) return;
|
|
|
|
// Profile image
|
|
this.clientsService.getClientProfileImage(cid).subscribe({
|
|
next: (img: any) => {
|
|
if (img) {
|
|
nominee.profileImageUrl = this.sanitizer.bypassSecurityTrustResourceUrl(img);
|
|
}
|
|
},
|
|
error: () => {}
|
|
});
|
|
|
|
// Signature
|
|
this.clientsService.getClientDocuments(cid).subscribe({
|
|
next: (docs: any[]) => {
|
|
const sigDoc = docs?.find(
|
|
(d: any) => d.name?.toLowerCase().includes('signature') || d.description?.toLowerCase().includes('signature')
|
|
);
|
|
if (sigDoc) {
|
|
this.clientsService.getClientSignatureImage(cid, sigDoc.id.toString()).subscribe({
|
|
next: (blob: Blob) => {
|
|
nominee.signatureUrl = this.sanitizer.bypassSecurityTrustUrl(URL.createObjectURL(blob));
|
|
},
|
|
error: () => {}
|
|
});
|
|
}
|
|
},
|
|
error: () => {}
|
|
});
|
|
}
|
|
|
|
// ═══ Search & Link ═══
|
|
|
|
onSearch(text: string): void {
|
|
this.searchText = text;
|
|
clearTimeout(this.searchTimer);
|
|
if (!text || text.length < 2) {
|
|
this.searchResults = [];
|
|
return;
|
|
}
|
|
this.searching = true;
|
|
this.searchTimer = setTimeout(() => {
|
|
this.clientsService.searchByText(text, 0, 8, '', '', true).subscribe({
|
|
next: (data: any) => {
|
|
this.searchResults = data?.content || [];
|
|
this.searching = false;
|
|
this.clientsService.enrichSearchResults(this.searchResults);
|
|
},
|
|
error: () => {
|
|
this.searchResults = [];
|
|
this.searching = false;
|
|
}
|
|
});
|
|
}, 400);
|
|
}
|
|
|
|
dismissSearch(): void {
|
|
setTimeout(() => {
|
|
this.searchResults = [];
|
|
this.searching = false;
|
|
}, 200);
|
|
}
|
|
|
|
selectClient(client: any): void {
|
|
this.selectedClient = client;
|
|
this.searchResults = [];
|
|
this.searchText = '';
|
|
this.fetchClientDetails(client.id);
|
|
}
|
|
|
|
removeSelectedClient(): void {
|
|
this.selectedClient = null;
|
|
this.linkedProfileImage = null;
|
|
this.linkedSignatureImage = null;
|
|
this.linkedKycStatus = null;
|
|
}
|
|
|
|
private fetchClientDetails(clientId: number): void {
|
|
this.fetchingDetails = true;
|
|
this.linkedProfileImage = null;
|
|
this.linkedSignatureImage = null;
|
|
this.linkedKycStatus = null;
|
|
let pending = 4;
|
|
const done = () => {
|
|
pending--;
|
|
if (pending <= 0) {
|
|
this.fetchingDetails = false;
|
|
}
|
|
};
|
|
|
|
this.clientsService.getClientData(clientId.toString()).subscribe({
|
|
next: (data: any) => {
|
|
if (data?.dateOfBirth) {
|
|
this.selectedClient = { ...this.selectedClient, dateOfBirth: data.dateOfBirth };
|
|
}
|
|
done();
|
|
},
|
|
error: () => done()
|
|
});
|
|
|
|
this.clientsService.getClientKyc(clientId.toString()).subscribe({
|
|
next: (kyc: any) => {
|
|
if (kyc) {
|
|
this.selectedClient = {
|
|
...this.selectedClient,
|
|
panNumber: kyc.panNumber,
|
|
aadhaarMasked: kyc.aadhaarNumberMasked
|
|
};
|
|
this.linkedKycStatus = {
|
|
panVerified: kyc.panVerified || false,
|
|
aadhaarVerified: kyc.aadhaarVerified || false
|
|
};
|
|
}
|
|
done();
|
|
},
|
|
error: () => done()
|
|
});
|
|
|
|
this.clientsService.getClientProfileImage(clientId.toString()).subscribe({
|
|
next: (img: any) => {
|
|
if (img) {
|
|
this.linkedProfileImage = this.sanitizer.bypassSecurityTrustResourceUrl(img);
|
|
}
|
|
done();
|
|
},
|
|
error: () => done()
|
|
});
|
|
|
|
this.clientsService.getClientDocuments(clientId.toString()).subscribe({
|
|
next: (docs: any[]) => {
|
|
const sigDoc = docs?.find(
|
|
(d: any) => d.name?.toLowerCase().includes('signature') || d.description?.toLowerCase().includes('signature')
|
|
);
|
|
if (sigDoc) {
|
|
this.clientsService.getClientSignatureImage(clientId.toString(), sigDoc.id.toString()).subscribe({
|
|
next: (blob: Blob) => {
|
|
this.linkedSignatureImage = this.sanitizer.bypassSecurityTrustUrl(URL.createObjectURL(blob));
|
|
done();
|
|
},
|
|
error: () => done()
|
|
});
|
|
} else {
|
|
done();
|
|
}
|
|
},
|
|
error: () => done()
|
|
});
|
|
}
|
|
|
|
// ═══ Guardian ═══
|
|
|
|
onGuardianSearch(text: string): void {
|
|
this.guardianSearchText = text;
|
|
clearTimeout(this.guardianSearchTimer);
|
|
if (!text || text.length < 2) {
|
|
this.guardianSearchResults = [];
|
|
return;
|
|
}
|
|
this.guardianSearching = true;
|
|
this.guardianSearchTimer = setTimeout(() => {
|
|
this.clientsService.searchByText(text, 0, 8, '', '', true).subscribe({
|
|
next: (data: any) => {
|
|
this.guardianSearchResults = data?.content || [];
|
|
this.guardianSearching = false;
|
|
this.clientsService.enrichSearchResults(this.guardianSearchResults);
|
|
},
|
|
error: () => {
|
|
this.guardianSearchResults = [];
|
|
this.guardianSearching = false;
|
|
}
|
|
});
|
|
}, 400);
|
|
}
|
|
|
|
selectGuardian(client: any): void {
|
|
this.selectedGuardian = client;
|
|
this.guardianSearchResults = [];
|
|
this.guardianSearchText = '';
|
|
}
|
|
|
|
// ═══ Save Nominee ═══
|
|
|
|
saveNominee(): void {
|
|
if (!this.selectedClient || !this.nomineeRelationshipId || !this.nomineeShare) return;
|
|
if (this.totalShare + this.nomineeShare > 100) return;
|
|
|
|
this.nomineeSaving = true;
|
|
const account = this.selectedAccount;
|
|
const accountType = this.selectedGroup?.apiType || '';
|
|
|
|
const payload: any = {
|
|
nomineeClientId: this.selectedClient.id,
|
|
accountNo: account?.accountNo,
|
|
accountType,
|
|
nominationType: this.nomineeNomType,
|
|
relationshipId: this.nomineeRelationshipId,
|
|
percentageShare: this.nomineeShare,
|
|
nomineeIsMinor: this.nomineeIsMinor,
|
|
remarks: this.nomineeRemarks || null,
|
|
locale: 'en'
|
|
};
|
|
|
|
if (this.nomineeIsMinor && this.selectedGuardian) {
|
|
payload.guardianClientId = this.selectedGuardian.id;
|
|
}
|
|
|
|
this.clientsService.addNominee(this.clientId, payload).subscribe({
|
|
next: (res: any) => {
|
|
this.nomineeSaving = false;
|
|
// Reload nominees to get the full response including nomineeIdentifier
|
|
this.loadNominees();
|
|
this.resetAddForm();
|
|
},
|
|
error: () => {
|
|
this.nomineeSaving = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
// ═══ View & Edit ═══
|
|
|
|
viewNominee(nominee: any): void {
|
|
// Resolve the linked client ID — could be nomineeClientId or nested
|
|
const clientId = nominee.nomineeClientId || nominee.linkedClientId || nominee.clientId;
|
|
const displayName = nominee.nomineeDisplayName || nominee.firstName || '';
|
|
import('../../client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component').then(
|
|
(m) => {
|
|
this.dialog.open(m.ClientFamilyMemberDialogComponent, {
|
|
data: {
|
|
context: 'View',
|
|
member: {
|
|
firstName: displayName,
|
|
relationshipId: nominee.relationshipId,
|
|
dateOfBirth: nominee.nomineeDateOfBirth || nominee.dateOfBirth,
|
|
linkedClientId: clientId,
|
|
linkedClientName: displayName,
|
|
isAlive: true,
|
|
isDependent: nominee.nomineeIsMinor,
|
|
panNumber: nominee.panNumber || '',
|
|
aadhaarNumber: nominee.aadhaarMasked || ''
|
|
},
|
|
options: { relationshipIdOptions: this.relationshipOptions }
|
|
},
|
|
width: '640px',
|
|
maxWidth: '95vw',
|
|
panelClass: 'fm-dialog-panel'
|
|
});
|
|
}
|
|
);
|
|
}
|
|
|
|
editNominee(nominee: any, index: number): void {
|
|
this.editingIndex = index;
|
|
this.editRelId = nominee.relationshipId || null;
|
|
this.editShare = nominee.percentageShare;
|
|
this.editNomType = nominee.nominationType || 'DA1';
|
|
}
|
|
|
|
cancelEdit(): void {
|
|
this.editingIndex = null;
|
|
}
|
|
|
|
maxShareForEdit(index: number): number {
|
|
return 100 - this.nominees.reduce((sum, n, i) => (i === index ? sum : sum + (n.percentageShare || 0)), 0);
|
|
}
|
|
|
|
saveEdit(nominee: any, index: number): void {
|
|
if (!this.editRelId || !this.editShare || this.editShare > this.maxShareForEdit(index)) return;
|
|
|
|
const payload: any = {
|
|
relationshipId: this.editRelId,
|
|
percentageShare: this.editShare,
|
|
nominationType: this.editNomType,
|
|
locale: 'en'
|
|
};
|
|
|
|
this.clientsService.updateNominee(this.clientId, nominee.id.toString(), payload).subscribe({
|
|
next: () => {
|
|
// Reload to get updated data
|
|
this.loadNominees();
|
|
this.editingIndex = null;
|
|
},
|
|
error: () => {}
|
|
});
|
|
}
|
|
|
|
deleteNominee(nominee: any, index: number): void {
|
|
const ref = this.dialog.open(DeleteDialogComponent, {
|
|
data: { deleteContext: `nominee ${nominee.nomineeDisplayName || nominee.firstName || ''}` }
|
|
});
|
|
ref.afterClosed().subscribe((response: any) => {
|
|
if (response?.delete) {
|
|
this.clientsService.deleteNominee(this.clientId, nominee.id.toString()).subscribe({
|
|
next: () => this.nominees.splice(index, 1),
|
|
error: () => this.nominees.splice(index, 1)
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
zoomImage(imageSrc: any, title: string): void {
|
|
this.dialog.open(ImageZoomDialogComponent, {
|
|
data: { imageSrc, title },
|
|
panelClass: 'image-zoom-panel',
|
|
maxWidth: '90vw',
|
|
maxHeight: '90vh'
|
|
});
|
|
}
|
|
|
|
getRelationship(member: any): string {
|
|
if (member.relationshipName) return member.relationshipName;
|
|
if (member.relationship) return member.relationship;
|
|
if (typeof member.relationshipId === 'object' && member.relationshipId?.name) return member.relationshipId.name;
|
|
const opt = this.relationshipOptions.find((r: any) => r.id === member.relationshipId);
|
|
return opt?.name || '';
|
|
}
|
|
|
|
resetAddForm(): void {
|
|
this.showAddForm = false;
|
|
this.selectedClient = null;
|
|
this.linkedProfileImage = null;
|
|
this.linkedSignatureImage = null;
|
|
this.linkedKycStatus = null;
|
|
this.nomineeNomType = 'DA1';
|
|
this.nomineeRelationshipId = null;
|
|
this.nomineeShare = null;
|
|
this.nomineeIsMinor = false;
|
|
this.nomineeRemarks = '';
|
|
this.selectedGuardian = null;
|
|
this.searchText = '';
|
|
this.guardianSearchText = '';
|
|
}
|
|
}
|