Files
cbsnexora/src/app/clients/clients-view/nominee-tab/nominee-tab.component.ts
T
AdityaandClaude Opus 4.7 83ea57ccf0 Client-stepper: pre-fill existing KYC/address proofs in edit, fix identifier saves
Edit-mode now shows the previously selected KYC document type + uploaded file thumbnail
(loads from /clients/{id}/identifiers + /client_identifiers/{id}/documents). Same for
address proof on the India address step. Closes the gap where editing a client showed
an empty file picker even when proof had been uploaded on create.

Identifier-save hardened across all three callsites (create-client, edit-client,
identities-tab.createIdentifierWithDocs) - the KYC chip picker proofId is a CODE
string ('AADHAAR'), but backend ClientIdentifierRequest.documentTypeId is Long, which
Spring rejects with HttpMessageNotReadableException -> 400. resolveDocumentTypeId
helper looks up the code against /kyc/proof-types?type=id and substitutes the numeric
CodeValue id before posting. Skips silently for constitution-specific non-individual
docs that have no CodeValue mapping.

KYC payload normalises panNumber / enterprisePan / gstinNumber / cinNumber via
toUpperCase + whitespace strip so user-typed lowercase or trailing spaces don't trip
the backend regex (validation.msg.CLIENTKYC.panNumber.does.not.match.regexp).

State name match in client-address save now normalises both sides (lowercase, collapse
underscores + spaces) so legacy MADHYA_PRADESH resolves to 'Madhya Pradesh' returned
by api.postalpincode.in. Backed by tenant migration 0297.

Identities-tab resolver flattened - was returning identifiers before the per-row
forkJoin(getClientIdentificationDocuments) completed, so the view rendered empty
documents arrays. Now uses switchMap + forkJoin so the resolver awaits completion.

Also bundles concurrent work-in-progress on letter-mgmt, organization, users,
bank-calendar, shell, nominee/notes/identifier dialogs, and theming.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 12:40:58 +05:30

615 lines
19 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(() => {
// Nominee picker (post-create tab): nominees must be natural persons.
// The host client is excluded from results so they can't be picked as
// their own nominee.
this.clientsService.searchByText(text, 0, 8, '', '', true, 'INDIVIDUAL').subscribe({
next: (data: any) => {
this.searchResults = (data?.content || []).filter((c: any) => !this.isSelfClient(c?.id));
this.searching = false;
this.clientsService.enrichSearchResults(this.searchResults);
},
error: () => {
this.searchResults = [];
this.searching = false;
}
});
}, 400);
}
dismissSearch(): void {
setTimeout(() => {
this.searchResults = [];
this.searching = false;
}, 200);
}
/** Self-check: prevent a client from being added as their own nominee/guardian. */
isSelfClient(clientId: number | string | null | undefined): boolean {
if (this.clientId == null || clientId == null) return false;
return String(this.clientId) === String(clientId);
}
/** Inline error shown above the picker when the user tries to pick themselves. */
selfSelectError = '';
selectClient(client: any): void {
if (this.isSelfClient(client?.id)) {
this.selfSelectError = `You can't link this client as their own nominee. Please pick a different person.`;
return;
}
this.selfSelectError = '';
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(() => {
// Guardian picker (post-create tab): guardians must be natural persons.
// The host client is filtered out so they can't be picked as their own guardian.
this.clientsService.searchByText(text, 0, 8, '', '', true, 'INDIVIDUAL').subscribe({
next: (data: any) => {
this.guardianSearchResults = (data?.content || []).filter((c: any) => !this.isSelfClient(c?.id));
this.guardianSearching = false;
this.clientsService.enrichSearchResults(this.guardianSearchResults);
},
error: () => {
this.guardianSearchResults = [];
this.guardianSearching = false;
}
});
}, 400);
}
/** Inline error shown above the guardian picker when self-select is attempted. */
guardianSelfSelectError = '';
selectGuardian(client: any): void {
if (this.isSelfClient(client?.id)) {
this.guardianSelfSelectError = `You can't link this client as their own guardian. Please pick a different person.`;
return;
}
this.guardianSelfSelectError = '';
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 = '';
}
}