diff --git a/src/app/clients/client-stepper/client-family-members-step/client-family-members-step.component.ts b/src/app/clients/client-stepper/client-family-members-step/client-family-members-step.component.ts index d324ac254..cf7209829 100644 --- a/src/app/clients/client-stepper/client-family-members-step/client-family-members-step.component.ts +++ b/src/app/clients/client-stepper/client-family-members-step/client-family-members-step.component.ts @@ -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 } }); } diff --git a/src/app/clients/client-stepper/client-family-members-step/family-member-added-dialog.component.ts b/src/app/clients/client-stepper/client-family-members-step/family-member-added-dialog.component.ts new file mode 100644 index 000000000..5a7679e80 --- /dev/null +++ b/src/app/clients/client-stepper/client-family-members-step/family-member-added-dialog.component.ts @@ -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: ` +

+ check_circle + Family Member Added +

+
+ @if (data.clientId) { +
+ Client ID + {{ data.clientId }} +
+ } +
+ Name + {{ data.name }} +
+ @if (data.relationship) { +
+ Relationship + {{ data.relationship }} +
+ } + @if (data.dateOfBirth) { +
+ Date of Birth + {{ data.dateOfBirth }} +
+ } + @if (data.isDependent) { +
+ Dependent + Yes +
+ } +
+ + + + `, + 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); + data: { + clientId?: string | number | null; + name: string; + relationship?: string; + dateOfBirth?: string; + isDependent?: boolean; + } = inject(MAT_DIALOG_DATA); +} diff --git a/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts b/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts index e6e642c66..5cbc3b024 100644 --- a/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts +++ b/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts @@ -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) => { diff --git a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.html b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.html index c5495da56..b8644c83a 100644 --- a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.html +++ b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.html @@ -1487,8 +1487,8 @@ Choose & Crop - @if (photoSigTouched) { diff --git a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts index b44b19958..708188704 100644 --- a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts +++ b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts @@ -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 { - 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 { 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; diff --git a/src/app/clients/clients.component.ts b/src/app/clients/clients.component.ts index fd99d30f7..98f9df007 100644 --- a/src/app/clients/clients.component.ts +++ b/src/app/clients/clients.component.ts @@ -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) { diff --git a/src/app/clients/clients.service.ts b/src/app/clients/clients.service.ts index b412ccc45..cbb893260 100644 --- a/src/app/clients/clients.service.ts +++ b/src/app/clients/clients.service.ts @@ -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 { - return this.http.get(`/clients/${clientId}/kyc`); + getClientKyc(clientId: string, silent = false): Observable { + return this.http.get( + `/clients/${clientId}/kyc`, + silent ? { context: new HttpContext().set(SILENT_HTTP_ERROR, true) } : {} + ); } /** diff --git a/src/app/core/http/error-handler.interceptor.ts b/src/app/core/http/error-handler.interceptor.ts index d83994a33..0d9413845 100644 --- a/src/app/core/http/error-handler.interceptor.ts +++ b/src/app/core/http/error-handler.interceptor.ts @@ -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)) { diff --git a/src/app/core/http/silent-error.token.ts b/src/app/core/http/silent-error.token.ts new file mode 100644 index 000000000..9623b800e --- /dev/null +++ b/src/app/core/http/silent-error.token.ts @@ -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(() => false);