Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
539 lines
18 KiB
TypeScript
539 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/.
|
|
*/
|
|
|
|
/** Angular Imports */
|
|
import { Component, OnInit, inject } from '@angular/core';
|
|
import { environment } from '../../../environments/environment';
|
|
import { ActivatedRoute, Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
|
|
import { DomSanitizer } from '@angular/platform-browser';
|
|
import { MatDialog } from '@angular/material/dialog';
|
|
|
|
/** Custom Dialogs */
|
|
import { UnassignStaffDialogComponent } from './custom-dialogs/unassign-staff-dialog/unassign-staff-dialog.component';
|
|
import { UploadSignatureDialogComponent } from './custom-dialogs/upload-signature-dialog/upload-signature-dialog.component';
|
|
import { ViewSignatureDialogComponent } from './custom-dialogs/view-signature-dialog/view-signature-dialog.component';
|
|
import { DeleteSignatureDialogComponent } from './custom-dialogs/delete-signature-dialog/delete-signature-dialog.component';
|
|
import { DrawSignatureDialogComponent } from './custom-dialogs/draw-signature-dialog/draw-signature-dialog.component';
|
|
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
|
|
import { UploadImageDialogComponent } from './custom-dialogs/upload-image-dialog/upload-image-dialog.component';
|
|
import { CaptureImageDialogComponent } from './custom-dialogs/capture-image-dialog/capture-image-dialog.component';
|
|
import { WelcomeKitDialogComponent } from './custom-dialogs/welcome-kit-dialog/welcome-kit-dialog.component';
|
|
|
|
/** Custom Services */
|
|
import { ClientsService } from '../clients.service';
|
|
import { LegalFormId } from '../models/legal-form.enum';
|
|
import { familyAllowed, nomineeAllowed } from '../utils/client-rules';
|
|
import {
|
|
MatCard,
|
|
MatCardHeader,
|
|
MatCardTitleGroup,
|
|
MatCardMdImage,
|
|
MatCardTitle,
|
|
MatCardSubtitle,
|
|
MatCardContent
|
|
} from '@angular/material/card';
|
|
import { MatButton, MatIconButton } from '@angular/material/button';
|
|
import { MatTooltip } from '@angular/material/tooltip';
|
|
import { NgClass } from '@angular/common';
|
|
import { EntityNameComponent } from '../../shared/entity-name/entity-name.component';
|
|
import { MatMenuTrigger, MatMenu, MatMenuItem } from '@angular/material/menu';
|
|
import { MatIcon } from '@angular/material/icon';
|
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
|
import { AccountNumberComponent } from '../../shared/account-number/account-number.component';
|
|
import { ExternalIdentifierComponent } from '../../shared/external-identifier/external-identifier.component';
|
|
import { MatTabNav, MatTabLink, MatTabNavPanel } from '@angular/material/tabs';
|
|
import { StatusLookupPipe } from '../../pipes/status-lookup.pipe';
|
|
import { DateFormatPipe } from '../../pipes/date-format.pipe';
|
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
|
import { formatTabLabel } from 'app/shared/utils/format-tab-label.util';
|
|
|
|
@Component({
|
|
selector: 'mifosx-clients-view',
|
|
templateUrl: './clients-view.component.html',
|
|
styleUrls: ['./clients-view.component.scss'],
|
|
imports: [
|
|
...STANDALONE_SHARED_IMPORTS,
|
|
MatCardHeader,
|
|
MatCardTitleGroup,
|
|
MatCardMdImage,
|
|
MatTooltip,
|
|
MatCardTitle,
|
|
NgClass,
|
|
EntityNameComponent,
|
|
MatIconButton,
|
|
MatMenuTrigger,
|
|
MatIcon,
|
|
FaIconComponent,
|
|
MatCardSubtitle,
|
|
AccountNumberComponent,
|
|
ExternalIdentifierComponent,
|
|
MatMenu,
|
|
MatMenuItem,
|
|
MatTabNav,
|
|
MatTabLink,
|
|
RouterLinkActive,
|
|
MatTabNavPanel,
|
|
RouterOutlet,
|
|
StatusLookupPipe,
|
|
DateFormatPipe
|
|
]
|
|
})
|
|
export class ClientsViewComponent implements OnInit {
|
|
complianceHideClientData = environment.complianceHideClientData;
|
|
|
|
/**
|
|
* Whether the number's CURRENT DIP/MNRL status is a hit. Driven by the live lookup rather than
|
|
* the onboarding-time `dipVerified` flag, which never changes once the client is created.
|
|
*/
|
|
isDipFlagged(liveStatus: string | null | undefined): boolean {
|
|
return !!liveStatus && liveStatus !== 'CLEAN' && liveStatus !== 'ERROR';
|
|
}
|
|
|
|
/** True only when the number is affirmatively clean — not merely "not flagged". */
|
|
isDipClear(client: any): boolean {
|
|
if (client?.dipLiveStatus) {
|
|
return client.dipLiveStatus === 'CLEAN';
|
|
}
|
|
return client?.dipVerified === true || client?.dipVerified === 'true' || client?.dipVerified === 1;
|
|
}
|
|
|
|
/** True when onboarding screening cleared the name — the pre-alert baseline. */
|
|
isNameCleared(client: any): boolean {
|
|
return client?.nameVerified === true || client?.nameVerified === 'true' || client?.nameVerified === 1;
|
|
}
|
|
|
|
/**
|
|
* Header badge for sanctions. A live alert always wins over the onboarding flag: the flag says
|
|
* what was true the day the client joined, the alert says what is true now.
|
|
*/
|
|
sanctionsHeaderLabel(client: any): string {
|
|
switch (client?.sanctionsAlertStatus) {
|
|
case 'CONFIRMED_MATCH':
|
|
return 'Frozen — Sanctions Match';
|
|
case 'PENDING_REVIEW':
|
|
return 'Under Review';
|
|
case 'FALSE_POSITIVE':
|
|
return 'Cleared — False Positive';
|
|
default:
|
|
return this.isNameCleared(client) ? 'Clear' : 'Pending';
|
|
}
|
|
}
|
|
|
|
dipStatusLabel(client: any): string {
|
|
const live = client?.dipLiveStatus;
|
|
if (live === 'SUSPECTED') return 'Flagged — Suspected';
|
|
if (live === 'DISCONNECTED') return 'Flagged — Disconnected';
|
|
if (live === 'SUSPECTED_AND_DISCONNECTED') return 'Flagged — Suspected & Disconnected';
|
|
if (live === 'SERVICE_UNAVAILABLE') return 'Not Verified';
|
|
if (live === 'CLEAN') return 'Verified';
|
|
return this.isDipClear(client) ? 'Verified' : 'Pending';
|
|
}
|
|
/**
|
|
* Mask a string, keeping first and last letter, masking the rest with *
|
|
*/
|
|
maskName(name: string): string {
|
|
if (!name) return '';
|
|
return name
|
|
.trim()
|
|
.split(/(\s+)/)
|
|
.map((word) => {
|
|
if (!word.trim()) return word;
|
|
if (word.length <= 2) return word[0] + '*';
|
|
return word[0] + '*'.repeat(word.length - 2) + word[word.length - 1];
|
|
})
|
|
.join('');
|
|
}
|
|
|
|
/**
|
|
* Mask external id, mobile, etc (show only first char, rest as *)
|
|
*/
|
|
maskValue(val: string): string {
|
|
if (!val) return '';
|
|
if (val.length <= 2) return val[0] + '*';
|
|
return val[0] + '*'.repeat(val.length - 1);
|
|
}
|
|
|
|
/**
|
|
* Mask email: v********@f*******
|
|
*/
|
|
maskEmail(email: string): string {
|
|
if (!email) return '';
|
|
const [
|
|
user,
|
|
domain
|
|
] = email.split('@');
|
|
if (!user || !domain || user.length < 1) return this.maskValue(email);
|
|
let maskedUser = user.length > 1 ? user[0] + '*'.repeat(user.length - 1) : user[0] + '*';
|
|
const domainLabel = domain.split('.')[0] || '';
|
|
const domainMaskLen = Math.max(0, domainLabel.length - 1);
|
|
let maskedDomain = domainLabel.length > 0 ? domainLabel[0] + '*'.repeat(domainMaskLen) : '';
|
|
let domainRest = '';
|
|
if (domain.length > domainLabel.length) {
|
|
domainRest = domain.substring(domainLabel.length);
|
|
}
|
|
if (!maskedDomain) return this.maskValue(email);
|
|
return maskedUser + '@' + maskedDomain + domainRest;
|
|
}
|
|
formatTabLabel(label: string): string {
|
|
return formatTabLabel(label);
|
|
}
|
|
private route = inject(ActivatedRoute);
|
|
private router = inject(Router);
|
|
private clientsService = inject(ClientsService);
|
|
private _sanitizer = inject(DomSanitizer);
|
|
dialog = inject(MatDialog);
|
|
|
|
clientViewData: any;
|
|
clientDatatables: any;
|
|
clientImage: any;
|
|
clientTemplateData: any;
|
|
|
|
/** Constitution display name (entity clients only). Empty for individuals. */
|
|
get constitutionLabel(): string {
|
|
return this.clientViewData?.clientNonPersonDetails?.constitution?.name || '';
|
|
}
|
|
|
|
/** Whether the "Family Members" tab should appear. */
|
|
get showFamilyTab(): boolean {
|
|
return familyAllowed(this.clientViewData?.legalForm?.id, this.constitutionLabel);
|
|
}
|
|
|
|
/** Whether the "Nominees" tab should appear. */
|
|
get showNomineeTab(): boolean {
|
|
return nomineeAllowed(this.clientViewData?.legalForm?.id, this.constitutionLabel);
|
|
}
|
|
|
|
constructor() {
|
|
this.route.data.subscribe((data: { clientViewData: any; clientTemplateData: any; clientDatatables: any }) => {
|
|
this.clientViewData = data.clientViewData;
|
|
this.clientDatatables = this.filterDatatablesByClientSubtype(
|
|
data.clientDatatables,
|
|
data.clientViewData?.legalForm?.id
|
|
);
|
|
this.clientTemplateData = data.clientTemplateData;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Filters datatables based on the client's legal form (Person or Entity).
|
|
* Datatables without an entitySubType are kept visible for all client types.
|
|
*/
|
|
private filterDatatablesByClientSubtype(datatables: any[], legalFormId: number): any[] {
|
|
if (!datatables || !legalFormId) {
|
|
return datatables || [];
|
|
}
|
|
const subtype = legalFormId === LegalFormId.PERSON ? 'person' : 'entity';
|
|
return datatables.filter((dt: any) => !dt.entitySubType || dt.entitySubType.toLowerCase() === subtype);
|
|
}
|
|
|
|
ngOnInit() {
|
|
this.clientsService.getClientProfileImage(this.clientViewData.id).subscribe({
|
|
next: (base64Image: any) => {
|
|
// If base64Image is null, client has no profile image
|
|
if (base64Image) {
|
|
this.clientImage = this._sanitizer.bypassSecurityTrustResourceUrl(base64Image);
|
|
} else {
|
|
this.clientImage = null;
|
|
}
|
|
},
|
|
error: (error: any) => {
|
|
// Handle any unexpected errors
|
|
console.error('Error loading client profile image:', error);
|
|
this.clientImage = null;
|
|
}
|
|
});
|
|
}
|
|
|
|
isActive(): boolean {
|
|
return this.clientViewData.status.value === 'Active';
|
|
}
|
|
|
|
/**
|
|
* Performs action button/option action.
|
|
* @param {string} name action name.
|
|
*/
|
|
doAction(name: string) {
|
|
switch (name) {
|
|
case 'Assign Staff':
|
|
case 'Close':
|
|
case 'Survey':
|
|
case 'Reject':
|
|
case 'Activate':
|
|
case 'Withdraw':
|
|
case 'Update Default Savings':
|
|
case 'Transfer Client':
|
|
case 'Undo Transfer':
|
|
case 'Accept Transfer':
|
|
case 'Reject Transfer':
|
|
case 'Reactivate':
|
|
case 'Undo Rejection':
|
|
case 'Add Charge':
|
|
case 'Create Collateral':
|
|
case 'Client Screen Reports':
|
|
this.router.navigate([`actions/${name}`], { relativeTo: this.route });
|
|
break;
|
|
case 'Unassign Staff':
|
|
this.unassignStaff();
|
|
break;
|
|
case 'Delete':
|
|
this.deleteClient();
|
|
break;
|
|
case 'View Signature':
|
|
this.viewSignature();
|
|
break;
|
|
case 'Upload Signature':
|
|
this.uploadSignature();
|
|
break;
|
|
case 'Capture Signature':
|
|
this.captureSignatureImage();
|
|
break;
|
|
case 'Delete Signature':
|
|
this.deleteSignature();
|
|
break;
|
|
case 'Capture Image':
|
|
this.captureProfileImage();
|
|
break;
|
|
case 'Upload Image':
|
|
this.uploadProfileImage();
|
|
break;
|
|
case 'Delete Image':
|
|
this.deleteProfileImage();
|
|
break;
|
|
case 'Welcome Kit':
|
|
this.dispatchWelcomeKit();
|
|
break;
|
|
case 'Create Standing Instructions':
|
|
const createStandingInstructionsQueryParams: any = {
|
|
officeId: this.clientViewData.officeId,
|
|
accountType: 'fromsavings'
|
|
};
|
|
this.router.navigate(['standing-instructions/create-standing-instructions'], {
|
|
relativeTo: this.route,
|
|
queryParams: createStandingInstructionsQueryParams
|
|
});
|
|
break;
|
|
case 'View Standing Instructions':
|
|
const viewStandingInstructionsQueryParams: any = {
|
|
officeId: this.clientViewData.officeId,
|
|
accountType: 'fromsavings'
|
|
};
|
|
this.router.navigate(['standing-instructions/list-standing-instructions'], {
|
|
relativeTo: this.route,
|
|
queryParams: viewStandingInstructionsQueryParams
|
|
});
|
|
break;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Refetches data for the component
|
|
* TODO: Replace by a custom reload component instead of hard-coded back-routing.
|
|
*/
|
|
reload() {
|
|
const url: string = this.router.url;
|
|
this.router.navigateByUrl(`/clients`, { skipLocationChange: true }).then(() => this.router.navigate([url]));
|
|
}
|
|
|
|
/**
|
|
* Deletes the client
|
|
*/
|
|
private deleteClient() {
|
|
const deleteClientDialogRef = this.dialog.open(DeleteDialogComponent, {
|
|
data: { deleteContext: `client with id: ${this.clientViewData.id}` }
|
|
});
|
|
deleteClientDialogRef.afterClosed().subscribe((response: any) => {
|
|
if (response.delete) {
|
|
this.clientsService.deleteClient(this.clientViewData.id).subscribe(() => {
|
|
this.router.navigate(['/clients'], { relativeTo: this.route });
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Unassign's the client's staff.
|
|
*/
|
|
private unassignStaff() {
|
|
const unAssignStaffDialogRef = this.dialog.open(UnassignStaffDialogComponent);
|
|
unAssignStaffDialogRef.afterClosed().subscribe((response: { confirm: any }) => {
|
|
if (response.confirm) {
|
|
this.clientsService
|
|
.executeClientCommand(this.clientViewData.id, 'unassignStaff', { staffId: this.clientViewData.staffId })
|
|
.subscribe(() => {
|
|
this.reload();
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Shows client signature in a dialog
|
|
*/
|
|
private viewSignature() {
|
|
this.clientsService.getClientDocuments(this.clientViewData.id).subscribe((documents: any) => {
|
|
const viewSignatureDialogRef = this.dialog.open(ViewSignatureDialogComponent, {
|
|
data: {
|
|
documents: documents,
|
|
id: this.clientViewData.id
|
|
}
|
|
});
|
|
viewSignatureDialogRef.afterClosed().subscribe((response: any) => {
|
|
if (response.upload) {
|
|
this.uploadSignature();
|
|
} else if (response.draw) {
|
|
this.drawSignature();
|
|
} else if (response.capture) {
|
|
this.captureSignatureImage();
|
|
} else if (response.delete) {
|
|
this.deleteSignature();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Uploads client signature
|
|
*/
|
|
private uploadSignature() {
|
|
const uploadSignatureDialogRef = this.dialog.open(UploadSignatureDialogComponent);
|
|
uploadSignatureDialogRef.afterClosed().subscribe((signature: File) => {
|
|
if (signature) {
|
|
this.clientsService.uploadClientSignatureImage(this.clientViewData.id, signature).subscribe(() => {
|
|
this.reload();
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Opens draw pad for client signature
|
|
*/
|
|
private drawSignature() {
|
|
const drawSignatureDialogRef = this.dialog.open(DrawSignatureDialogComponent);
|
|
drawSignatureDialogRef.afterClosed().subscribe((signature: File) => {
|
|
if (signature) {
|
|
this.clientsService.uploadClientSignatureImage(this.clientViewData.id, signature).subscribe(() => {
|
|
this.reload();
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Deletes client signature
|
|
*/
|
|
private deleteSignature() {
|
|
this.clientsService.getClientDocuments(this.clientViewData.id).subscribe((documents: any) => {
|
|
const deleteSignatureDialogRef = this.dialog.open(DeleteSignatureDialogComponent, {
|
|
data: documents
|
|
});
|
|
deleteSignatureDialogRef.afterClosed().subscribe((response: any) => {
|
|
if (response.delete) {
|
|
this.clientsService.deleteClientDocument(this.clientViewData.id, response.id).subscribe(() => {
|
|
this.reload();
|
|
});
|
|
} else if (response.upload) {
|
|
this.uploadSignature();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Captures clients profile image.
|
|
*/
|
|
private captureProfileImage() {
|
|
const captureImageDialogRef = this.dialog.open(CaptureImageDialogComponent, {
|
|
data: { captureTarget: 'profile' }
|
|
});
|
|
captureImageDialogRef.afterClosed().subscribe((imageURL: string) => {
|
|
if (imageURL) {
|
|
this.clientsService.uploadCapturedClientProfileImage(this.clientViewData.id, imageURL).subscribe(() => {
|
|
this.reload();
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Captures client signature via webcam (photograph of a paper signature).
|
|
* Mirrors `captureProfileImage()` — same dialog, parametrized for signature capture,
|
|
* uploaded via `uploadCapturedClientSignature()` instead of the profile-image upload.
|
|
*/
|
|
private captureSignatureImage() {
|
|
const captureImageDialogRef = this.dialog.open(CaptureImageDialogComponent, {
|
|
data: { captureTarget: 'signature' }
|
|
});
|
|
captureImageDialogRef.afterClosed().subscribe((imageURL: string) => {
|
|
if (imageURL) {
|
|
this.clientsService.uploadCapturedClientSignature(this.clientViewData.id, imageURL).subscribe(() => {
|
|
this.reload();
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Uploads the clients image.
|
|
*/
|
|
private uploadProfileImage() {
|
|
const uploadImageDialogRef = this.dialog.open(UploadImageDialogComponent);
|
|
uploadImageDialogRef.afterClosed().subscribe((image: File) => {
|
|
if (image) {
|
|
this.clientsService.uploadClientProfileImage(this.clientViewData.id, image).subscribe(() => {
|
|
this.reload();
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Deletes the client image.
|
|
*/
|
|
private deleteProfileImage() {
|
|
const deleteClientImageDialogRef = this.dialog.open(DeleteDialogComponent, {
|
|
data: { deleteContext: `the profile image of ${this.clientViewData.displayName}` }
|
|
});
|
|
deleteClientImageDialogRef.afterClosed().subscribe((response: any) => {
|
|
if (response.delete) {
|
|
this.clientsService.deleteClientProfileImage(this.clientViewData.id).subscribe(() => {
|
|
this.reload();
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Opens the Welcome Kit Dispatch dialog. The kit is only posted once the client is active, so the dialog is
|
|
* unreachable before then — the menu entry is hidden and this guard covers any other route into the action.
|
|
*/
|
|
private dispatchWelcomeKit() {
|
|
if (!this.isActive()) {
|
|
return;
|
|
}
|
|
const welcomeKitDialogRef = this.dialog.open(WelcomeKitDialogComponent, {
|
|
data: {
|
|
clientId: this.clientViewData.id,
|
|
clientName: this.clientViewData.displayName,
|
|
accountNo: this.clientViewData.accountNo,
|
|
cifNumber: this.clientViewData.cifNumber,
|
|
mobileNo: this.clientViewData.mobileNo,
|
|
emailAddress: this.clientViewData.emailAddress,
|
|
officeName: this.clientViewData.officeName
|
|
},
|
|
width: '680px'
|
|
});
|
|
welcomeKitDialogRef.afterClosed().subscribe((response: any) => {
|
|
if (response) {
|
|
// the form was successfully submitted
|
|
this.reload();
|
|
}
|
|
});
|
|
}
|
|
}
|