Files
cbsnexora/src/app/clients/client-stepper/client-family-members-step/client-family-members-step.component.ts
T
nitesh 186a9057a1 feat(clients): confirm linked Client ID + details after adding a family member
The "Add Member" dialog closed silently on submit, giving no
acknowledgement of what got recorded — most notably the linked client's
own CBS Client ID when the member was linked to an existing client rather
than entered manually. Added a confirmation popup showing Client ID (when
linked), name, relationship, DOB and dependent status right after submit.
2026-07-30 13:38:10 +05:30

609 lines
20 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, Input, OnInit, inject } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { TranslateService } from '@ngx-translate/core';
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
import { ClientFamilyMemberDialogComponent } from './client-family-member-dialog/client-family-member-dialog.component';
import { ImageZoomDialogComponent } from './client-family-member-dialog/client-family-member-dialog.component';
import { FamilyMemberAddedDialogComponent } from './family-member-added-dialog.component';
import { ClientsService } from '../../clients.service';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatIcon } from '@angular/material/icon';
import { MatSlideToggle } from '@angular/material/slide-toggle';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { FindPipe } from '../../../pipes/find.pipe';
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FormsModule } from '@angular/forms';
import { DomSanitizer } from '@angular/platform-browser';
import { MatTooltip } from '@angular/material/tooltip';
@Component({
selector: 'mifosx-client-family-members-step',
templateUrl: './client-family-members-step.component.html',
styleUrls: ['./client-family-members-step.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule,
FaIconComponent,
MatIcon,
MatSlideToggle,
MatProgressSpinner,
FindPipe,
DateFormatPipe,
MatTooltip
]
})
export class ClientFamilyMembersStepComponent implements OnInit {
dialog = inject(MatDialog);
private translateService = inject(TranslateService);
private clientsService = inject(ClientsService);
private sanitizer = inject(DomSanitizer);
@Input() clientTemplate: any;
@Input() clientId: string = '';
@Input() maritalStatusId: number | null = null;
@Input() maritalStatusOptions: any[] = [];
/** Read-only mode — hides add/edit/delete buttons */
@Input() readOnly = false;
/**
* When true, hide the family-member section entirely. Used for clients that
* are eligible for nominees but not for family members (e.g. proprietorship
* entities — see {@link nomineeAllowed} / {@link familyAllowed}).
*/
@Input() nomineeOnly = false;
/** Family Members */
clientFamilyMembers: any[] = [];
private loaded = false;
/** Nominees */
nominees: any[] = [];
private nomineesLoaded = false;
showNomineeForm = false;
/** Nominee search */
nomineeSearchText = '';
nomineeSearchResults: any[] = [];
nomineeSearching = false;
private nomineeSearchTimer: any = null;
/** Selected nominee client */
selectedNomineeClient: any = null;
nomineeRelationshipId: number | null = null;
nomineePercentageShare: number | null = null;
nomineeIsMinor = false;
nomineeSaving = false;
nomineeType = 'DA1';
/** Nomination type options */
nominationTypeOptions = [
{ value: 'DA1', label: 'DA1 - Add Nominee' },
{ value: 'DA5', label: 'DA5 - Modify / Cancel Nominee' }
];
/** Guardian search (for minor nominee) */
guardianSearchText = '';
guardianSearchResults: any[] = [];
guardianSearching = false;
selectedGuardian: any = null;
guardianRelationship = '';
private guardianSearchTimer: any = null;
/** Relationship options */
relationshipOptions = [
'Father',
'Mother',
'Spouse',
'Son',
'Daughter',
'Brother',
'Sister',
'Uncle',
'Aunt',
'Grandfather',
'Grandmother',
'Friend',
'Business Partner',
'Other'
];
ngOnInit(): void {
if (this.clientId) {
this.loadExistingMembers();
this.loadNominees();
}
}
// ═══ Family Members ═══
private loadExistingMembers(): void {
if (this.loaded) return;
this.clientsService.getClientFamilyMembers(this.clientId).subscribe({
next: (data: any) => {
this.loaded = true;
this.clientFamilyMembers = Array.isArray(data) ? data : [];
},
error: () => {
this.loaded = true;
}
});
}
addFamilyMember() {
if (this.nomineeOnly || this.readOnly) return;
const ref = this.dialog.open(ClientFamilyMemberDialogComponent, {
data: {
context: 'Add',
options: this.clientTemplate.familyMemberOptions,
existingMembers: this.clientFamilyMembers,
hostClientId: this.clientId
},
width: '640px',
maxWidth: '95vw',
panelClass: 'fm-dialog-panel'
});
ref.afterClosed().subscribe((response: any) => {
if (response?.member) {
this.clientFamilyMembers.push(response.member);
this.showFamilyMemberAddedConfirmation(response.member);
}
});
}
/** Confirms what was just recorded — in particular the linked client's own CBS Client ID,
* since the "Add Member" dialog closes silently otherwise and gives no acknowledgement. */
private showFamilyMemberAddedConfirmation(member: any): void {
this.dialog.open(FamilyMemberAddedDialogComponent, {
width: '360px',
data: {
clientId: member._linkedClient?.id ?? member.linkedClientId ?? null,
name: member._linkedClient?.displayName || member.firstName,
relationship: this.getRelName(member.relationshipId),
dateOfBirth: member.dateOfBirth,
isDependent: member.isDependent
}
});
}
viewFamilyMember(member: any) {
this.dialog.open(ClientFamilyMemberDialogComponent, {
data: {
context: 'View',
member,
options: this.clientTemplate.familyMemberOptions,
existingMembers: this.clientFamilyMembers,
hostClientId: this.clientId
},
width: '640px',
maxWidth: '95vw',
panelClass: 'fm-dialog-panel'
});
}
editFamilyMember(member: any, index: number) {
const ref = this.dialog.open(ClientFamilyMemberDialogComponent, {
data: {
context: 'Edit',
member,
options: this.clientTemplate.familyMemberOptions,
existingMembers: this.clientFamilyMembers,
hostClientId: this.clientId
},
width: '640px',
maxWidth: '95vw',
panelClass: 'fm-dialog-panel'
});
ref.afterClosed().subscribe((response: any) => {
if (!response?.member) return;
const updated = response.member;
this.clientFamilyMembers.splice(index, 1, updated);
// Persist to backend if this is an existing (already-saved) member
if (member.id && this.clientId) {
const { id, _linkedClient, _marriageCertFile, ...payload } = updated;
this.clientsService.editFamilyMember(this.clientId, member.id, payload).subscribe({ error: () => {} });
}
});
}
deleteFamilyMember(name: string, index: number) {
const ref = this.dialog.open(DeleteDialogComponent, {
data: { deleteContext: `${name}` }
});
ref.afterClosed().subscribe((response: any) => {
if (!response?.delete) return;
const member = this.clientFamilyMembers[index];
// Persist deletion to backend for existing members; locally-added ones just drop from the array
if (member?.id && this.clientId) {
this.clientsService.deleteFamilyMember(this.clientId, member.id.toString()).subscribe({
next: () => this.clientFamilyMembers.splice(index, 1),
error: () => this.clientFamilyMembers.splice(index, 1)
});
} else {
this.clientFamilyMembers.splice(index, 1);
}
});
}
get familyMembers() {
return { familyMembers: this.clientFamilyMembers };
}
get hasFamilyMembers(): boolean {
return this.clientFamilyMembers.length > 0;
}
/** True if a family member with the given relationship (case-insensitive) has been added */
private hasRelationship(name: string): boolean {
const target = name.toLowerCase();
return this.clientFamilyMembers.some((m: any) => {
const resolved = this.getRelName(m.relationshipId ?? m.relationship).toLowerCase();
if (resolved === target) return true;
return typeof m.relationship === 'string' && m.relationship.toLowerCase() === target;
});
}
get hasMother(): boolean {
return this.hasRelationship('mother');
}
get hasFather(): boolean {
return this.hasRelationship('father');
}
get hasCaretaker(): boolean {
return this.hasRelationship('caretaker');
}
/** Mother is always required; Father is required unless a Caretaker has been added instead. */
get familyRelationshipRulesMet(): boolean {
return this.hasMother && (this.hasFather || this.hasCaretaker);
}
/** Check if client's marital status is "Married" */
get isMarried(): boolean {
if (!this.maritalStatusId || !this.maritalStatusOptions?.length) return false;
const selected = this.maritalStatusOptions.find((o: any) => o.id === this.maritalStatusId);
return selected?.name?.toLowerCase().includes('married') || false;
}
/** Check if a spouse has been added */
get hasSpouse(): boolean {
return this.clientFamilyMembers.some((m: any) => {
const rel = m.relationship || m.relationshipId;
const relName = typeof rel === 'string' ? rel : '';
return relName.toLowerCase() === 'spouse' || m.firstName?.toLowerCase().includes('spouse');
});
}
// ═══ Nominees ═══
private loadNominees(): void {
if (this.nomineesLoaded || !this.clientId) return;
// Nominees are stored as family members with nominationType set
this.clientsService.getClientFamilyMembers(this.clientId).subscribe({
next: (data: any) => {
this.nomineesLoaded = true;
const allMembers = Array.isArray(data) ? data : [];
this.nominees = allMembers.filter((m: any) => m.nominationType && m.nominationType !== '');
},
error: () => {
this.nomineesLoaded = true;
}
});
}
onNomineeSearch(text: string): void {
this.nomineeSearchText = text;
clearTimeout(this.nomineeSearchTimer);
if (!text || text.length < 2) {
this.nomineeSearchResults = [];
return;
}
this.nomineeSearching = true;
this.nomineeSearchTimer = setTimeout(() => {
// 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;
this.clientsService.enrichSearchResults(this.nomineeSearchResults);
},
error: () => {
this.nomineeSearchResults = [];
this.nomineeSearching = false;
}
});
}, 400);
}
selectNomineeClient(client: any): void {
this.selectedNomineeClient = { ...client, _loading: true };
this.nomineeSearchResults = [];
this.nomineeSearchText = '';
this.fetchNomineeClientDetails(client.id);
}
private fetchNomineeClientDetails(clientId: number): void {
const details: any = {};
let pending = 4;
const done = () => {
pending--;
if (pending <= 0) {
this.selectedNomineeClient = { ...this.selectedNomineeClient, ...details, _loading: false };
}
};
// Fetch client data for DOB
this.clientsService.getClientData(clientId.toString()).subscribe({
next: (data: any) => {
if (data?.dateOfBirth) {
details.dateOfBirth = data.dateOfBirth;
}
done();
},
error: () => done()
});
// Fetch KYC for PAN/Aadhaar + verification status
this.clientsService.getClientKyc(clientId.toString()).subscribe({
next: (kyc: any) => {
details.panNumber = kyc?.panNumber || null;
details.aadhaarMasked = kyc?.aadhaarNumberMasked || null;
details.panVerified = kyc?.panVerified || false;
details.aadhaarVerified = kyc?.aadhaarVerified || false;
done();
},
error: () => done()
});
// Fetch profile image
this.clientsService.getClientProfileImage(clientId.toString()).subscribe({
next: (img: any) => {
if (img) {
details.profileImageUrl = this.sanitizer.bypassSecurityTrustResourceUrl(img);
}
done();
},
error: () => done()
});
// Fetch signature image
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) => {
details.signatureUrl = this.sanitizer.bypassSecurityTrustUrl(URL.createObjectURL(blob));
done();
},
error: () => done()
});
} else {
done();
}
},
error: () => done()
});
}
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: 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;
this.clientsService.enrichSearchResults(this.guardianSearchResults);
},
error: () => {
this.guardianSearchResults = [];
this.guardianSearching = false;
}
});
}, 400);
}
selectGuardian(client: any): void {
this.selectedGuardian = client;
this.guardianSearchResults = [];
this.guardianSearchText = '';
}
get existingShareTotal(): number {
return this.nominees.reduce((sum, n) => sum + (n.percentageShare || 0), 0);
}
get totalSharePercentage(): number {
return this.existingShareTotal + (this.nomineePercentageShare || 0);
}
get maxAllowedShare(): number {
return 100 - this.existingShareTotal;
}
saveNominee(): void {
if (!this.clientId || !this.selectedNomineeClient || !this.nomineeRelationshipId || !this.nomineePercentageShare)
return;
if (this.totalSharePercentage > 100) return;
this.nomineeSaving = true;
// Get relationship name for display
const relOption = this.clientTemplate?.familyMemberOptions?.relationshipIdOptions?.find(
(r: any) => r.id === this.nomineeRelationshipId
);
const relName = relOption?.name || '';
const payload: any = {
firstName: this.selectedNomineeClient.displayName,
relationshipId: this.nomineeRelationshipId,
nominationType: this.nomineeType,
percentageShare: this.nomineePercentageShare,
isDependent: this.nomineeIsMinor,
dateFormat: 'dd MMMM yyyy',
locale: 'en'
};
if (this.nomineeIsMinor && this.selectedGuardian) {
payload.guardianName = this.selectedGuardian.displayName;
payload.guardianRelationship = this.guardianRelationship;
}
this.clientsService.addFamilyMember(this.clientId, payload).subscribe({
next: (res: any) => {
this.nomineeSaving = false;
this.nominees.push({
id: res?.resourceId,
...payload,
relationship: relName,
nomineeName: this.selectedNomineeClient.displayName,
nomineeCif: this.selectedNomineeClient.accountNo,
nomineeMobile: this.selectedNomineeClient.mobileNo,
nomineeDob: this.selectedNomineeClient.dateOfBirth,
profileImageUrl: this.selectedNomineeClient.profileImageUrl,
signatureUrl: this.selectedNomineeClient.signatureUrl,
panNumber: this.selectedNomineeClient.panNumber,
aadhaarMasked: this.selectedNomineeClient.aadhaarMasked,
guardianName: this.selectedGuardian?.displayName
});
this.resetNomineeForm();
},
error: () => {
this.nomineeSaving = false;
}
});
}
/** Editing nominee state */
editingNomineeIndex: number | null = null;
editNomineeRelId: number | null = null;
editNomineeShare: number | null = null;
editNomineeType: string = 'DA1';
editNominee(nominee: any, index: number): void {
this.editingNomineeIndex = index;
this.editNomineeRelId = nominee.relationshipId?.id || nominee.relationshipId || null;
this.editNomineeShare = nominee.percentageShare;
this.editNomineeType = nominee.nominationType || 'DA1';
}
cancelEditNominee(): void {
this.editingNomineeIndex = null;
}
/** Max share allowed for the nominee being edited (excludes its own share) */
maxShareForEdit(index: number): number {
const otherTotal = this.nominees.reduce((sum, n, i) => (i === index ? sum : sum + (n.percentageShare || 0)), 0);
return 100 - otherTotal;
}
saveEditNominee(nominee: any, index: number): void {
if (!this.editNomineeRelId || !this.editNomineeShare) return;
const otherTotal = this.nominees.reduce((sum, n, i) => (i === index ? sum : sum + (n.percentageShare || 0)), 0);
if (otherTotal + this.editNomineeShare > 100) return;
const relOption = this.clientTemplate?.familyMemberOptions?.relationshipIdOptions?.find(
(r: any) => r.id === this.editNomineeRelId
);
// Update in-memory
nominee.relationshipId = this.editNomineeRelId;
nominee.relationship = relOption?.name || nominee.relationship;
nominee.percentageShare = this.editNomineeShare;
nominee.nominationType = this.editNomineeType;
// If saved to DB, update via API
if (nominee.id && this.clientId) {
const payload: any = {
firstName: nominee.firstName || nominee.nomineeName,
relationshipId: this.editNomineeRelId,
nominationType: this.editNomineeType,
percentageShare: this.editNomineeShare,
isDependent: nominee.isDependent || false,
dateFormat: 'dd MMMM yyyy',
locale: 'en'
};
if (nominee.guardianName) {
payload.guardianName = nominee.guardianName;
}
if (nominee.guardianRelationship) {
payload.guardianRelationship = nominee.guardianRelationship;
}
this.clientsService.editFamilyMember(this.clientId, nominee.id, payload).subscribe({ error: () => {} });
}
this.editingNomineeIndex = null;
}
deleteNominee(nominee: any, index: number): void {
const ref = this.dialog.open(DeleteDialogComponent, {
data: { deleteContext: `nominee ${nominee.nomineeName || nominee.firstName || ''}` }
});
ref.afterClosed().subscribe((response: any) => {
if (response?.delete) {
if (nominee.id && this.clientId) {
this.clientsService.deleteFamilyMember(this.clientId, nominee.id.toString()).subscribe({
next: () => this.nominees.splice(index, 1),
error: () => this.nominees.splice(index, 1)
});
} else {
this.nominees.splice(index, 1);
}
}
});
}
resetNomineeForm(): void {
this.showNomineeForm = false;
this.selectedNomineeClient = null;
this.nomineeRelationshipId = null;
this.nomineePercentageShare = null;
this.nomineeIsMinor = false;
this.nomineeType = 'DA1';
this.selectedGuardian = null;
this.guardianRelationship = '';
this.nomineeSearchText = '';
this.guardianSearchText = '';
}
/** Open image in zoom overlay */
zoomImage(imageSrc: any, title: string): void {
this.dialog.open(ImageZoomDialogComponent, {
data: { imageSrc, title },
panelClass: 'image-zoom-panel',
maxWidth: '90vw',
maxHeight: '90vh'
});
}
/** Get relationship name from ID */
getRelName(relId: any): string {
if (typeof relId === 'object' && relId?.name) return relId.name;
const opt = this.clientTemplate?.familyMemberOptions?.relationshipIdOptions?.find((r: any) => r.id === relId);
return opt?.name || '';
}
get nomineeData(): any[] {
return this.nominees;
}
}