Files
cbsnexora/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.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

555 lines
18 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, 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();
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 (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: ['']
});
}
/** 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;
}
// 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();
}
}