- Family member dialog: disable Father / Mother / Spouse in the Relationship dropdown when one is already added to the client. Son, daughter, brother etc. stay unrestricted. Editing a member keeps its own current relation selectable. Hint added below the field. - Home welcome banner: render a small green "DSC Verified" pill next to the username when GET /v1/dsc/me returns at least one ACTIVE certificate. Tooltip surfaces the masked serial. Silent when no DSC is mapped. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
502 lines
16 KiB
TypeScript
502 lines
16 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 { 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,
|
|
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;
|
|
clearTimeout(this.searchTimer);
|
|
if (!text || text.length < 2) {
|
|
this.searchResults = [];
|
|
return;
|
|
}
|
|
this.searching = true;
|
|
this.searchTimer = setTimeout(() => {
|
|
this.clientsService.searchByText(text, 0, 8, '', '', true).subscribe({
|
|
next: (data: any) => {
|
|
this.searchResults = data?.content || [];
|
|
this.searching = false;
|
|
this.clientsService.enrichSearchResults(this.searchResults);
|
|
},
|
|
error: () => {
|
|
this.searchResults = [];
|
|
this.searching = false;
|
|
}
|
|
});
|
|
}, 400);
|
|
}
|
|
|
|
/** Select a client from search results and fetch full details */
|
|
selectClient(client: any): void {
|
|
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();
|
|
}
|
|
}
|