This commit is contained in:
2026-07-31 11:00:14 +05:30
9 changed files with 290 additions and 65 deletions
@@ -12,6 +12,7 @@ import { TranslateService } from '@ngx-translate/core';
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
import { ClientFamilyMemberDialogComponent } from './client-family-member-dialog/client-family-member-dialog.component';
import { ImageZoomDialogComponent } from './client-family-member-dialog/client-family-member-dialog.component';
import { FamilyMemberAddedDialogComponent } from './family-member-added-dialog.component';
import { ClientsService } from '../../clients.service';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatIcon } from '@angular/material/icon';
@@ -152,6 +153,22 @@ export class ClientFamilyMembersStepComponent implements OnInit {
ref.afterClosed().subscribe((response: any) => {
if (response?.member) {
this.clientFamilyMembers.push(response.member);
this.showFamilyMemberAddedConfirmation(response.member);
}
});
}
/** Confirms what was just recorded — in particular the linked client's own CBS Client ID,
* since the "Add Member" dialog closes silently otherwise and gives no acknowledgement. */
private showFamilyMemberAddedConfirmation(member: any): void {
this.dialog.open(FamilyMemberAddedDialogComponent, {
width: '360px',
data: {
clientId: member._linkedClient?.id ?? member.linkedClientId ?? null,
name: member._linkedClient?.displayName || member.firstName,
relationship: this.getRelName(member.relationshipId),
dateOfBirth: member.dateOfBirth,
isDependent: member.isDependent
}
});
}
@@ -0,0 +1,145 @@
/**
* 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, inject } from '@angular/core';
import { MatDialogRef, MAT_DIALOG_DATA, MatDialogTitle, MatDialogActions } from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { MatButton } from '@angular/material/button';
/**
* Confirmation popup shown right after a family member is added — surfaces the linked
* client's own CBS Client ID (when the member was linked to an existing client rather than
* entered manually) alongside name/relationship, since the "Add Member" dialog itself just
* closed silently before this with no acknowledgement of what got recorded.
*/
@Component({
selector: 'mifosx-family-member-added-dialog',
imports: [
MatDialogTitle,
MatDialogActions,
MatIcon,
MatButton
],
template: `
<h2 mat-dialog-title class="fma-title">
<mat-icon class="fma-title__icon">check_circle</mat-icon>
Family Member Added
</h2>
<div class="fma-body">
@if (data.clientId) {
<div class="fma-row fma-row--id">
<span class="fma-row__label">Client ID</span>
<span class="fma-row__value fma-row__value--id">{{ data.clientId }}</span>
</div>
}
<div class="fma-row">
<span class="fma-row__label">Name</span>
<span class="fma-row__value">{{ data.name }}</span>
</div>
@if (data.relationship) {
<div class="fma-row">
<span class="fma-row__label">Relationship</span>
<span class="fma-row__value">{{ data.relationship }}</span>
</div>
}
@if (data.dateOfBirth) {
<div class="fma-row">
<span class="fma-row__label">Date of Birth</span>
<span class="fma-row__value">{{ data.dateOfBirth }}</span>
</div>
}
@if (data.isDependent) {
<div class="fma-row">
<span class="fma-row__label">Dependent</span>
<span class="fma-row__value">Yes</span>
</div>
}
</div>
<mat-dialog-actions align="end">
<button mat-flat-button color="primary" (click)="dialogRef.close()">Done</button>
</mat-dialog-actions>
`,
styles: [
`
.fma-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 17px;
font-weight: 700;
color: #0f172a;
margin: 0;
padding: 20px 24px 4px;
}
.fma-title__icon {
color: #059669;
}
.fma-body {
display: flex;
flex-direction: column;
gap: 2px;
padding: 12px 24px 8px;
min-width: 280px;
}
.fma-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
padding: 8px 0;
border-bottom: 1px solid #f1f5f9;
&:last-child {
border-bottom: none;
}
}
.fma-row__label {
font-size: 12px;
color: #64748b;
font-weight: 500;
}
.fma-row__value {
font-size: 13px;
color: #0f172a;
font-weight: 600;
text-align: right;
}
.fma-row--id {
background: #eff6ff;
margin: 0 -24px;
padding: 10px 24px;
}
.fma-row__value--id {
font-family: 'Roboto Mono', monospace;
color: #1353a0;
font-size: 14px;
}
mat-dialog-actions {
padding: 8px 24px 16px;
}
`
]
})
export class FamilyMemberAddedDialogComponent {
dialogRef = inject<MatDialogRef<FamilyMemberAddedDialogComponent>>(MatDialogRef);
data: {
clientId?: string | number | null;
name: string;
relationship?: string;
dateOfBirth?: string;
isDependent?: boolean;
} = inject(MAT_DIALOG_DATA);
}
@@ -548,8 +548,16 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
this.maxDate = this.settingsService.businessDate;
this.setOptions();
this.clientService.getClientRiskRules().subscribe((rules) => {
this.riskRules = rules || [];
// Risk rules are an enhancement on top of the default low/medium-risk fallback in
// calculatedRisk — if the endpoint isn't available (e.g. not yet deployed on this
// backend), fail silently rather than surfacing a raw HTTP-error toast to the user.
this.clientService.getClientRiskRules().subscribe({
next: (rules) => {
this.riskRules = rules || [];
},
error: () => {
this.riskRules = [];
}
});
// Fetch marital status options from code values
this.clientService.getCodeValuesByName('MaritalStatus').subscribe((options) => {
@@ -1487,8 +1487,8 @@
<input type="file" accept="image/*" hidden (change)="onSignatureSelected($event)" />
<fa-icon icon="crop"></fa-icon> Choose &amp; Crop
</label>
<button type="button" class="upload-box__btn upload-box__btn--secondary" (click)="captureWebcamSignature()">
<fa-icon icon="video"></fa-icon> Use Webcam
<button type="button" class="upload-box__btn upload-box__btn--secondary" (click)="drawSignature()">
<fa-icon icon="pen"></fa-icon> Draw Signature
</button>
</div>
@if (photoSigTouched) {
@@ -31,6 +31,7 @@ import { Dates } from 'app/core/utils/dates';
import { ProofType, normalizeProofType, groupProofsByCategory } from '../../shared/kyc-proof-types';
import { KycUploadHelperService } from '../../shared/kyc-upload-helper.service';
import { CaptureImageDialogComponent } from '../../clients-view/custom-dialogs/capture-image-dialog/capture-image-dialog.component';
import { DrawSignatureDialogComponent } from '../../clients-view/custom-dialogs/draw-signature-dialog/draw-signature-dialog.component';
import { dataUrlToFile } from 'app/shared/utils/data-url.util';
import { DocumentPreviewDialogComponent } from '../../shared/document-preview-dialog/document-preview-dialog.component';
@@ -1711,20 +1712,6 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
await this.openSignatureCropDialog(file);
}
/** Webcam capture entry point for the specimen signature — photographs a paper
* signature, then reuses the same crop dialog + staging/upload path as the file-picker
* flow above (additive to the existing draw-pad / upload options, not a replacement). */
async captureWebcamSignature(): Promise<void> {
const dialogRef = this.dialog.open(CaptureImageDialogComponent, {
data: { captureTarget: 'signature' }
});
dialogRef.afterClosed().subscribe(async (imageDataUrl: string) => {
if (!imageDataUrl) return;
const file = dataUrlToFile(imageDataUrl, 'webcam-signature.jpg');
await this.openSignatureCropDialog(file);
});
}
private async openSignatureCropDialog(file: File): Promise<void> {
const { ImageCropDialogComponent } = await import('./image-crop-dialog/image-crop-dialog.component');
const dialogRef = this.dialog.open(ImageCropDialogComponent, {
@@ -1734,42 +1721,51 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
});
dialogRef.afterClosed().subscribe((result: any) => {
if (result) {
this.signatureFile = result.croppedFile;
this.signaturePreview = result.previewUrl;
// In edit mode, delete old signature doc then upload new one
if (this.clientId) {
if (this.existingSignatureDocId) {
this.clientsService.deleteClientDocument(this.clientId, this.existingSignatureDocId).subscribe({
next: () => {
this.clientsService.uploadClientSignatureImage(this.clientId, result.croppedFile).subscribe({
next: (res: any) => {
if (res?.resourceId) this.existingSignatureDocId = res.resourceId.toString();
},
error: () => {}
});
},
error: () => {
this.clientsService.uploadClientSignatureImage(this.clientId, result.croppedFile).subscribe({
next: (res: any) => {
if (res?.resourceId) this.existingSignatureDocId = res.resourceId.toString();
},
error: () => {}
});
}
});
} else {
this.clientsService.uploadClientSignatureImage(this.clientId, result.croppedFile).subscribe({
next: (res: any) => {
if (res?.resourceId) this.existingSignatureDocId = res.resourceId.toString();
},
error: () => {}
});
}
}
this.applySignatureFile(result.croppedFile, result.previewUrl);
}
});
}
/** Draw-with-mouse/touchpad entry point for the specimen signature — same staging/upload
* path as the file-picker and webcam flows, minus the crop step (the pad's canvas is
* already the right shape/size). */
drawSignature(): void {
const dialogRef = this.dialog.open(DrawSignatureDialogComponent);
dialogRef.afterClosed().subscribe((file: File) => {
if (file) {
this.applySignatureFile(file, URL.createObjectURL(file));
}
});
}
/** Stages the signature locally and, in edit mode, persists it — replacing any existing
* signature doc first so a client never ends up with two. Shared by the file-picker, webcam
* and draw-pad entry points. */
private applySignatureFile(file: File, previewUrl: string): void {
this.signatureFile = file;
this.signaturePreview = previewUrl;
if (!this.clientId) {
return;
}
if (this.existingSignatureDocId) {
this.clientsService.deleteClientDocument(this.clientId, this.existingSignatureDocId).subscribe({
next: () => this.uploadSignatureFile(file),
error: () => this.uploadSignatureFile(file)
});
} else {
this.uploadSignatureFile(file);
}
}
private uploadSignatureFile(file: File): void {
this.clientsService.uploadClientSignatureImage(this.clientId, file).subscribe({
next: (res: any) => {
if (res?.resourceId) this.existingSignatureDocId = res.resourceId.toString();
},
error: () => {}
});
}
removePhoto() {
this.photographFile = null;
this.photographPreview = null;
+13 -4
View File
@@ -137,7 +137,16 @@ export class ClientsComponent implements OnInit {
private getClients() {
this.isLoading = true;
this.clientService
.searchByText(this.filterText, this.currentPage, this.pageSize, this.sortAttribute, this.sortDirection)
.searchByText(
this.filterText,
this.currentPage,
this.pageSize,
this.sortAttribute,
this.sortDirection,
false,
'',
true
)
.subscribe(
(data: any) => {
this.allRows = data.content || [];
@@ -166,7 +175,7 @@ export class ClientsComponent implements OnInit {
// Use individual subscriptions instead of forkJoin to avoid EMPTY observable issues
clients.forEach((client, i) => {
this.clientService
.getClientData(client.id?.toString())
.getClientData(client.id?.toString(), true)
.pipe(catchError(() => of(null)))
.subscribe((data: any) => {
if (data?.legalForm) {
@@ -176,7 +185,7 @@ export class ClientsComponent implements OnInit {
});
this.clientService
.getClientKyc(client.id?.toString())
.getClientKyc(client.id?.toString(), true)
.pipe(catchError(() => of(null)))
.subscribe((kyc: any) => {
if (kyc) {
@@ -188,7 +197,7 @@ export class ClientsComponent implements OnInit {
});
this.clientService
.getClientAddressData(client.id?.toString())
.getClientAddressData(client.id?.toString(), true)
.pipe(catchError(() => of(null)))
.subscribe((addr: any) => {
if (addr && Array.isArray(addr) && addr.length > 0) {
+24 -9
View File
@@ -8,7 +8,8 @@
/** Angular Imports */
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams, HttpBackend, HttpHeaders } from '@angular/common/http';
import { HttpClient, HttpParams, HttpBackend, HttpHeaders, HttpContext } from '@angular/common/http';
import { SILENT_HTTP_ERROR } from 'app/core/http/silent-error.token';
/** rxjs Imports */
import { Observable, of, throwError } from 'rxjs';
@@ -66,8 +67,11 @@ export class ClientsService {
return this.http.get(`/clients/template?officeId=${officeId}&staffInSelectedOfficeOnly=true`);
}
getClientData(clientId: string) {
return this.http.get(`/clients/${clientId}`);
getClientData(clientId: string, silent = false) {
return this.http.get(
`/clients/${clientId}`,
silent ? { context: new HttpContext().set(SILENT_HTTP_ERROR, true) } : {}
);
}
createClient(client: any) {
@@ -153,8 +157,11 @@ export class ClientsService {
return this.http.get(`/clients/${clientId}/accounts`);
}
getClientAddressData(clientId: string) {
return this.http.get(`/client/${clientId}/addresses`);
getClientAddressData(clientId: string, silent = false) {
return this.http.get(
`/client/${clientId}/addresses`,
silent ? { context: new HttpContext().set(SILENT_HTTP_ERROR, true) } : {}
);
}
getClientChargesData(clientId: string) {
@@ -698,7 +705,8 @@ export class ClientsService {
sortAttribute: string = '',
sortDirection: string = '',
activeOnly: boolean = false,
legalFormType: string = ''
legalFormType: string = '',
silent: boolean = false
) {
const innerRequest: any = { text };
if (activeOnly) {
@@ -727,7 +735,11 @@ export class ClientsService {
]
};
}
return this.http.post(`/v2/clients/search`, request);
return this.http.post(
`/v2/clients/search`,
request,
silent ? { context: new HttpContext().set(SILENT_HTTP_ERROR, true) } : {}
);
}
/**
@@ -764,8 +776,11 @@ export class ClientsService {
* Gets KYC details for a client.
* @param clientId Client ID
*/
getClientKyc(clientId: string): Observable<any> {
return this.http.get(`/clients/${clientId}/kyc`);
getClientKyc(clientId: string, silent = false): Observable<any> {
return this.http.get(
`/clients/${clientId}/kyc`,
silent ? { context: new HttpContext().set(SILENT_HTTP_ERROR, true) } : {}
);
}
/**
+20 -2
View File
@@ -21,6 +21,7 @@ import { environment } from '../../../environments/environment';
import { Logger } from '../logger/logger.service';
import { AlertService } from '../alert/alert.service';
import { TranslateService } from '@ngx-translate/core'; // Added import for TranslateService
import { SILENT_HTTP_ERROR } from './silent-error.token';
/** Initialize Logger */
const log = new Logger('ErrorHandlerInterceptor');
@@ -57,13 +58,30 @@ export class ErrorHandlerInterceptor implements HttpInterceptor {
// "No pending edit for this client" is the normal/expected state — checked on every edit-page
// load, not a real error the user needs to see.
const isClientPendingEditRequest = request.url.match(/\/clients\/\d+\/edit\/pending$/);
// Risk rules are an enhancement on top of a default risk-category fallback — if this
// endpoint isn't available on a given backend deployment, the client-general-step
// component already falls back to riskRules = [] silently; no toast needed here either.
const isKycRiskRulesRequest = request.url.includes('/kyc/risk-rules');
const isSilentRequest =
isClientImageRequest || isClientKycRequest || isDirectorRequest || isClientPendingEditRequest;
isClientImageRequest ||
isClientKycRequest ||
isDirectorRequest ||
isClientPendingEditRequest ||
isKycRiskRulesRequest;
if (!environment.production && !isSilentRequest) {
// Explicit per-request opt-out (e.g. per-row enrichment fetches on a list page whose
// component already recovers via its own catchError) — silenced for every status, since
// the caller took responsibility for handling failure when it set this.
const isSilentOverride = request.context.get(SILENT_HTTP_ERROR);
if (!environment.production && !isSilentRequest && !isSilentOverride) {
log.error(`Request Error: ${errorMessage}`);
}
if (isSilentOverride) {
throw response;
}
// Silently handle expected 403/404 errors (no image, no KYC record)
// Throw instead of EMPTY so component-level catchError can handle it
if (isSilentRequest && (status === 403 || status === 404)) {
+17
View File
@@ -0,0 +1,17 @@
/**
* 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 { HttpContextToken } from '@angular/common/http';
/**
* Per-request opt-out of ErrorHandlerInterceptor's toast. Set via
* `{ context: new HttpContext().set(SILENT_HTTP_ERROR, true) }` on calls whose caller already
* handles failures itself (e.g. per-row enrichment fetches on a list page) — the interceptor
* still throws so the caller's own catchError/error callback runs, it just skips the alert.
*/
export const SILENT_HTTP_ERROR = new HttpContextToken<boolean>(() => false);