/** * 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 { DomSanitizer, SafeUrl } from '@angular/platform-browser'; import { MatDialog, MatDialogRef, MAT_DIALOG_DATA, MatDialogTitle, MatDialogActions, MatDialogClose, MatDialogContent } from '@angular/material/dialog'; import { UntypedFormGroup, UntypedFormBuilder, UntypedFormArray, Validators } from '@angular/forms'; import { SettingsService } from 'app/settings/settings.service'; import { Dates } from 'app/core/utils/dates'; 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'; @Component({ selector: 'mifosx-client-family-member-dialog', templateUrl: './client-family-member-dialog.component.html', styleUrls: ['./client-family-member-dialog.component.scss'], imports: [ ...STANDALONE_SHARED_IMPORTS, MatDialogTitle, MatDialogContent, MatCheckbox, MatDialogActions, MatDialogClose, MatIcon, MatProgressSpinner, MatTooltip, FaIconComponent, DateFormatPipe ] }) export class ClientFamilyMemberDialogComponent implements OnInit { dialogRef = inject>(MatDialogRef); private formBuilder = inject(UntypedFormBuilder); private dateUtils = inject(Dates); data = inject(MAT_DIALOG_DATA); private settingsService = inject(SettingsService); private clientsService = inject(ClientsService); private sanitizer = inject(DomSanitizer); private dialog = inject(MatDialog); maxDate = new Date(); hasChildrenChecked = false; familyMemberForm: UntypedFormGroup; /** Client search */ searchText = ''; searchResults: any[] = []; searching = false; selectedClient: any = null; private searchTimer: any = null; /** Mode: 'manual' or 'linked' */ entryMode: 'manual' | 'linked' = 'manual'; /** Marriage authority options */ marriageAuthorityOptions = [ { value: 'SUB_REGISTRAR_OFFICE', label: 'Sub-Registrar Office' }, { value: 'MARRIAGE_REGISTRAR', label: 'Registrar of Marriages' }, { value: 'COURT_MARRIAGE_OFFICER', label: 'Court Marriage Officer' }, { value: 'MUNICIPAL_AUTHORITY', label: 'Municipal Corporation / Nagar Palika' }, { value: 'EMBASSY_CONSULATE', label: 'Indian Embassy / Consulate' }, { value: 'DEFENCE_AUTHORITY', label: 'Defence Authority' } ]; /** Whether selected relationship is Spouse */ get isSpouse(): boolean { const relId = this.familyMemberForm?.get('relationshipId')?.value; if (!relId || !this.data?.options?.relationshipIdOptions) return false; const rel = this.data.options.relationshipIdOptions.find((r: any) => r.id === relId); return rel?.name?.toUpperCase() === 'SPOUSE'; } /** * Relationships that may appear at most once per client. Names compared * case-insensitively against the option list returned by the backend. */ private static readonly UNIQUE_RELATIONSHIPS = new Set([ 'FATHER', 'MOTHER', 'SPOUSE' ]); /** * Set of relationshipId values that are already taken by another family * member and should therefore be disabled in the dropdown. The member * currently being edited is excluded so its existing relationship stays * selectable. Empty set in Add mode when no unique relations exist yet. */ get disabledRelationshipIds(): Set { const disabled = new Set(); const existing = this.data?.existingMembers || []; const options = this.data?.options?.relationshipIdOptions || []; if (!existing.length || !options.length) return disabled; const editingId = this.data?.context === 'Edit' ? this.data?.member?.id : undefined; for (const m of existing) { // Skip the member being edited so it can keep its current selection. if (editingId && m.id === editingId) continue; const relId = typeof m.relationshipId === 'object' ? m.relationshipId?.id : m.relationshipId; if (relId == null) continue; const rel = options.find((r: any) => r.id === relId); const name = (rel?.name || '').toUpperCase(); if (ClientFamilyMemberDialogComponent.UNIQUE_RELATIONSHIPS.has(name)) { disabled.add(relId); } } return disabled; } /** Whether dialog is in View-only mode */ get isViewMode(): boolean { return this.data.context === 'View'; } /** Get relationship name from ID */ get relationshipName(): string { const relId = this.familyMemberForm?.get('relationshipId')?.value; if (!relId || !this.data?.options?.relationshipIdOptions) return '—'; const rel = this.data.options.relationshipIdOptions.find((r: any) => r.id === relId); return rel?.name || '—'; } /** 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 marriage authority display label */ getMarriageAuthorityLabel(value: string): string { return this.marriageAuthorityOptions.find((a) => a.value === value)?.label || value || '—'; } /** Marriage certificate file */ marriageCertFile: File | null = null; marriageCertFileName: string = ''; /** Linked client fetched data */ fetchingLinkedData = false; linkedProfileImage: SafeUrl | null = null; linkedSignatureImage: SafeUrl | null = null; /** KYC verification status for linked client */ linkedKycStatus: { panVerified?: boolean; aadhaarVerified?: boolean; panHolderName?: string; aadhaarHolderName?: string; riskCategory?: number; } | null = null; ngOnInit() { this.maxDate = this.settingsService.businessDate; this.createFamilyMemberForm(); if (this.data.context === 'Edit' || this.data.context === 'View') { const m = this.data.member; this.familyMemberForm.patchValue({ firstName: m.firstName, relationshipId: m.relationshipId, dateOfBirth: m.dateOfBirth && new Date(m.dateOfBirth), isDependent: m.isDependent, remarks: m.remarks || '', aadhaarNumber: m.aadhaarNumber || '', panNumber: m.panNumber || '', nationality: m.nationality || 'INDIAN', isAlive: m.isAlive !== false, marriageDate: m.marriageDate && new Date(m.marriageDate), marriageAuthority: m.marriageAuthority || '', linkedClientId: m.linkedClientId || '' }); if (m.linkedClientId) { this.entryMode = 'linked'; this.selectedClient = { id: m.linkedClientId, displayName: m.linkedClientName || m.firstName || `Client #${m.linkedClientId}` }; // Re-fetch linked client details (profile, signature, KYC status) this.fetchLinkedClientData(m.linkedClientId.toString()); } if (m.children && m.children.length > 0) { this.hasChildrenChecked = true; this.familyMemberForm.patchValue({ hasChildren: true }); m.children.forEach((child: any) => { this.children.push( this.formBuilder.group({ id: [child.id], fullName: [ child.fullName, Validators.required ], genderId: [ child.genderId, Validators.required ], dateOfBirth: [child.dateOfBirth ? new Date(child.dateOfBirth) : ''], nationality: [child.nationality || 'INDIAN'], aadhaarNumber: [child.aadhaarNumber || ''], panNumber: [ child.panNumber || '', Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/) ] }) ); }); } if (this.isViewMode) { this.familyMemberForm.disable(); } } } createFamilyMemberForm() { this.familyMemberForm = this.formBuilder.group({ firstName: [ '', Validators.required ], relationshipId: [ '', Validators.required ], dateOfBirth: [''], isDependent: [false], remarks: [''], aadhaarNumber: [''], panNumber: [ '', Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/) ], nationality: ['INDIAN'], isAlive: [true], marriageDate: [''], marriageAuthority: [''], linkedClientId: [''], hasChildren: [false], children: this.formBuilder.array([]) }); this.familyMemberForm.get('hasChildren')?.valueChanges.subscribe((value) => { if (value && !this.hasChildrenChecked) { this.openChildrenDialog(); } else if (!value) { this.hasChildrenChecked = false; this.children.clear(); } }); } get children(): UntypedFormArray { return this.familyMemberForm.get('children') as UntypedFormArray; } async openChildrenDialog() { // Dynamically import the dialog to avoid a circular dependency between the two dialogs. const { ClientFamilyMemberChildDialogComponent } = await import('../client-family-member-child-dialog/client-family-member-child-dialog.component'); const dialogRef = this.dialog.open(ClientFamilyMemberChildDialogComponent, { data: { children: this.children.value, genderIdOptions: this.data.options.genderIdOptions }, width: '500px' }); dialogRef.afterClosed().subscribe((result: any[]) => { if (result) { this.children.clear(); result.forEach((child) => { this.children.push( this.formBuilder.group({ id: [child.id], fullName: [ child.fullName, Validators.required ], genderId: [ child.genderId, Validators.required ], dateOfBirth: [child.dateOfBirth], nationality: [child.nationality || 'INDIAN'], aadhaarNumber: [child.aadhaarNumber || ''], panNumber: [ child.panNumber || '', Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/) ] }) ); }); this.hasChildrenChecked = true; this.familyMemberForm.patchValue({ hasChildren: true }, { emitEvent: false }); } else if (this.children.length === 0) { this.hasChildrenChecked = false; this.familyMemberForm.patchValue({ hasChildren: false }, { emitEvent: false }); } }); } createChildFormGroup(): UntypedFormGroup { return this.formBuilder.group({ id: [''], fullName: [ '', Validators.required ], genderId: [ '', Validators.required ], dateOfBirth: [''], nationality: ['INDIAN'], aadhaarNumber: [''], panNumber: [ '', Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/) ] }); } addChild(): void { this.children.push(this.createChildFormGroup()); } removeChild(index: number): void { this.children.removeAt(index); if (this.children.length === 0) { this.familyMemberForm.patchValue({ hasChildren: false }); } } /** Handle marriage certificate file selection */ onMarriageCertSelected(event: Event): void { const input = event.target as HTMLInputElement; if (input.files?.length) { this.marriageCertFile = input.files[0]; this.marriageCertFileName = this.marriageCertFile.name; } input.value = ''; } removeMarriageCert(): void { this.marriageCertFile = null; this.marriageCertFileName = ''; } /** Search existing clients */ onSearch(text: string): void { this.searchText = text; this.linkedSelectError = ''; clearTimeout(this.searchTimer); if (!text || text.length < 2) { this.searchResults = []; return; } this.searching = true; this.searchTimer = setTimeout(() => { // 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) => { // Filter the host client out of results so they can't even appear as // a candidate. The dialog also blocks selection defensively in case // the host slips through (e.g. via a paged search). 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); } /** * 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 = ''; /** * Self-check: prevent a client from being added as their own family member. * The host client's id is passed in via dialog data — comparing as strings * because the search API uses numbers but route params are strings. */ isSelfClient(clientId: number | string): boolean { const host = this.data?.hostClientId; if (host == null || clientId == null) return false; return String(host) === String(clientId); } /** * 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.isSelfClient(client?.id)) { this.linkedSelectError = `You can't add this client to their own family. Please pick a different person.`; return; } 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 = ''; this.familyMemberForm.patchValue({ firstName: client.displayName, linkedClientId: client.id }); this.fetchLinkedClientData(client.id); } /** Fetch DOB, KYC, profile image & signature for linked client */ private fetchLinkedClientData(clientId: string): void { this.fetchingLinkedData = true; this.linkedProfileImage = null; this.linkedSignatureImage = null; this.linkedKycStatus = null; // Fetch client details (DOB, gender etc.) this.clientsService.getClientData(clientId).subscribe({ next: (data: any) => { if (data.dateOfBirth) { this.familyMemberForm.patchValue({ dateOfBirth: new Date(data.dateOfBirth) }); } // Make fetched fields read-only this.setLinkedFieldsReadonly(true); this.fetchingLinkedData = false; }, error: () => { this.fetchingLinkedData = false; } }); // Fetch KYC (PAN, Aadhaar, verification status) this.clientsService.getClientKyc(clientId).subscribe({ next: (kyc: any) => { if (kyc) { const patch: any = {}; if (kyc.panNumber) { patch.panNumber = kyc.panNumber; } if (kyc.aadhaarNumberMasked) { patch.aadhaarNumber = kyc.aadhaarNumberMasked; } this.familyMemberForm.patchValue(patch); this.linkedKycStatus = { panVerified: kyc.panVerified || false, aadhaarVerified: kyc.aadhaarVerified || false, panHolderName: kyc.panFullName || kyc.panHolderName || '', aadhaarHolderName: kyc.aadhaarHolderName || '', riskCategory: kyc.riskCategory || null }; } }, error: () => {} }); // Fetch profile image this.clientsService.getClientProfileImage(clientId).subscribe({ next: (imageData: any) => { if (imageData) { this.linkedProfileImage = this.sanitizer.bypassSecurityTrustResourceUrl(imageData); } }, error: () => {} }); // Fetch signature image this.clientsService.getClientDocuments(clientId).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, sigDoc.id.toString()).subscribe({ next: (blob: Blob) => { this.linkedSignatureImage = this.sanitizer.bypassSecurityTrustUrl(URL.createObjectURL(blob)); }, error: () => {} }); } }, error: () => {} }); } /** Disable/enable fields that are auto-populated from linked client */ private setLinkedFieldsReadonly(readonly: boolean): void { const fields = [ 'firstName', 'dateOfBirth', 'aadhaarNumber', 'panNumber' ]; fields.forEach((f) => { const ctrl = this.familyMemberForm.get(f); if (ctrl) { readonly ? ctrl.disable() : ctrl.enable(); } }); } /** Remove linked client */ removeLinkedClient(): void { this.selectedClient = null; this.linkedProfileImage = null; this.linkedSignatureImage = null; this.linkedKycStatus = null; this.setLinkedFieldsReadonly(false); this.familyMemberForm.patchValue({ firstName: '', linkedClientId: '', dateOfBirth: '', aadhaarNumber: '', panNumber: '' }); } /** Switch entry mode */ setMode(mode: 'manual' | 'linked'): void { this.entryMode = mode; if (mode === 'manual') { this.removeLinkedClient(); } } get familyMember() { const formValue = { ...this.familyMemberForm.getRawValue() }; const locale = this.settingsService.language.code; const dateFormat = this.settingsService.dateFormat; if (formValue.dateOfBirth instanceof Date) { formValue.dateOfBirth = this.dateUtils.formatDate(formValue.dateOfBirth, dateFormat); } else if (!formValue.dateOfBirth) { delete formValue.dateOfBirth; } if (formValue.marriageDate instanceof Date) { formValue.marriageDate = this.dateUtils.formatDate(formValue.marriageDate, dateFormat); } else if (!formValue.marriageDate) { delete formValue.marriageDate; } if (formValue.panNumber) { formValue.panNumber = formValue.panNumber.toUpperCase(); } const member = { ...formValue, dateFormat, locale }; // Clean empty fields for (const key in member) { if (member[key] === '' || member[key] === undefined || member[key] === null) { delete member[key]; } } // Preserve selectedClient info for display if (this.selectedClient) { member._linkedClient = this.selectedClient; } // Format children dates if (this.isSpouse && member.children && member.children.length > 0) { member.children = member.children.map((child: any) => { const c = { ...child }; if (c.dateOfBirth instanceof Date) { c.dateOfBirth = this.dateUtils.formatDate(c.dateOfBirth, dateFormat); } else if (!c.dateOfBirth) { delete c.dateOfBirth; } if (c.panNumber) { c.panNumber = c.panNumber.toUpperCase(); } return c; }); } else { delete member.children; } delete member.hasChildren; // Attach marriage certificate file if present if (this.marriageCertFile) { member._marriageCertFile = this.marriageCertFile; } return member; } } /** * Lightweight image zoom dialog */ @Component({ selector: 'mifosx-image-zoom-dialog', template: `
{{ data.title }}
`, styles: [ ` .img-zoom-dialog { display: flex; flex-direction: column; } .img-zoom-header { display: flex; align-items: center; justify-content: space-between; padding: 10px 16px; background: #f8fafc; border-bottom: 1px solid #e2e8f0; font-size: 14px; font-weight: 600; color: #1e293b; } .img-zoom-body { display: flex; align-items: center; justify-content: center; padding: 20px; background: #fff; min-height: 200px; } .img-zoom-body img { max-width: 100%; max-height: 70vh; object-fit: contain; border-radius: 6px; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1); } ` ], imports: [ MatIcon, STANDALONE_SHARED_IMPORTS ] }) export class ImageZoomDialogComponent { dialogRef = inject>(MatDialogRef); data = inject(MAT_DIALOG_DATA); close() { this.dialogRef.close(); } }