Files
cbsnexora/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.ts
T
nitesh 4281a596af
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
fix: restore remaining content dropped by the dev merge (comprehensive audit)
Systematic file-by-file audit of demo-backup against dev to find any
remaining instances of the same silent-drop pattern from the earlier
merge (git's 3-way auto-resolve keeping demo-backup's side wherever it
had independently touched nearby lines, even when dev's side was a
genuine, unrelated addition). Restored:

- clients.module.ts: missing ClientFamilyMemberChildDialogComponent
  registration (component existed but was never wired in)
- client-family-member-dialog: full children-management flow (checkbox,
  form array, child dialog integration, view/edit summary blocks)
- edit-client: FIR-exception flow for inaccessible old mobile numbers,
  and a hardened detectSensitiveFieldChanges() (null-guard, phone-digit
  normalization, dirty-checked comparisons to avoid false-positive
  Verify-Contact-Change prompts)
- shell.component.ts: withdrawal-review URL matcher extended to cover
  the Share Withdrawal Request review route
- fiu.component.html: AML alert status filter and monthly CTR reports
  table UI (backing logic already existed unused in the .ts)
- Shares module: Passbook tab (component existed but had no route or
  tab link), running-balance/resolution columns on the transactions
  tab, subscript-sizing fixes and a pill-style filter-bar redesign on
  Share Holders
- Savings/Share product accounting steps: Payment-Channel-to-Fund-
  Source mapping section, and Share Reference account restructuring
- pending-client-edits: email status badges + "View Email" button
  (dialog component existed but nothing on this list triggered it)

Everything else that still differs from dev was individually verified
as either demo-backup's own independent work (Investment/Loan
Origination/NPA/RBI-OSS modules, the Approval Center tab-nav redesign,
AML/compliance review features, bank-name rebrand in demo seed data)
or purely cosmetic formatting differences — left untouched.
2026-08-06 16:35:52 +05:30

696 lines
22 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 { 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<ClientFamilyMemberDialogComponent>>(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<number> {
const disabled = new Set<number>();
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: `
<div class="img-zoom-dialog" (click)="close()">
<div class="img-zoom-header">
<span>{{ data.title }}</span>
<button mat-icon-button (click)="close()"><mat-icon>close</mat-icon></button>
</div>
<div class="img-zoom-body" (click)="$event.stopPropagation()">
<img [src]="data.imageSrc" [alt]="data.title" />
</div>
</div>
`,
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<ImageZoomDialogComponent>>(MatDialogRef);
data = inject(MAT_DIALOG_DATA);
close() {
this.dialogRef.close();
}
}