Merge branch 'uate' of https://git.lincpay.tech/CBSNexora/cbsnexora into uate
This commit is contained in:
+17
@@ -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
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+145
@@ -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);
|
||||
}
|
||||
+10
-2
@@ -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 & 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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) } : {}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user