/** * 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; } }