Compare commits
19
Commits
0389b1f7c3
..
uate
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c7d8287f9 | ||
|
|
a560cd6533 | ||
|
|
0bf337b962 | ||
|
|
1daa12a117 | ||
|
|
f687e1808d | ||
|
|
186a9057a1 | ||
|
|
387bd060e1 | ||
|
|
29f1cdc646 | ||
|
|
1efb520009 | ||
|
|
4ea833ff49 | ||
|
|
5dc284bd1c | ||
|
|
56f13ade67 | ||
|
|
dbd160e7e7 | ||
|
|
468eaa96ab | ||
|
|
f682cb17f6 | ||
|
|
79fedb66e5 | ||
|
|
c1fc95d9b7 | ||
|
|
b422ae6600 | ||
|
|
5fd7e1890e |
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* 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 { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
/**
|
||||
* AML transaction-monitoring API: live alerts raised by the rule engine, the
|
||||
* two-tier AML Officer / Principal Officer review workflow, STR cases (with
|
||||
* their 7-working-day FIU filing deadline), monthly CTR reports, and a
|
||||
* client's risk-category change history.
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class AmlService {
|
||||
private http = inject(HttpClient);
|
||||
|
||||
// ═══ Alerts (AML Officer) ═══
|
||||
|
||||
getAlerts(status?: string): Observable<any[]> {
|
||||
const params = status ? new HttpParams().set('status', status) : undefined;
|
||||
return this.http.get<any[]>('/aml/alerts', { params });
|
||||
}
|
||||
|
||||
/** disposition: 'WHITELIST' | 'ESCALATE' */
|
||||
reviewAlert(alertId: number, disposition: 'WHITELIST' | 'ESCALATE', notes: string): Observable<any> {
|
||||
return this.http.post(`/aml/alerts/${alertId}/review`, { disposition, notes });
|
||||
}
|
||||
|
||||
// ═══ STR cases (Principal Officer) ═══
|
||||
|
||||
getStrCases(): Observable<any[]> {
|
||||
return this.http.get<any[]>('/aml/str-cases');
|
||||
}
|
||||
|
||||
getStrCase(strCaseId: number): Observable<any> {
|
||||
return this.http.get(`/aml/str-cases/${strCaseId}`);
|
||||
}
|
||||
|
||||
getWeeklyDigest(): Observable<any[]> {
|
||||
return this.http.get<any[]>('/aml/str-cases/weekly-digest');
|
||||
}
|
||||
|
||||
assignPrincipalOfficer(strCaseId: number, principalOfficerUserId: number): Observable<any> {
|
||||
return this.http.post(`/aml/str-cases/${strCaseId}/assign`, { principalOfficerUserId });
|
||||
}
|
||||
|
||||
/** conclusion: 'NOT_SUSPICIOUS' | 'SUSPICIOUS' */
|
||||
concludeStrCase(
|
||||
strCaseId: number,
|
||||
conclusion: 'NOT_SUSPICIOUS' | 'SUSPICIOUS',
|
||||
investigationNotes: string,
|
||||
sourceOfFundsNotes: string
|
||||
): Observable<any> {
|
||||
return this.http.post(`/aml/str-cases/${strCaseId}/conclude`, {
|
||||
conclusion,
|
||||
investigationNotes,
|
||||
sourceOfFundsNotes
|
||||
});
|
||||
}
|
||||
|
||||
fileStr(strCaseId: number, referenceNo: string): Observable<any> {
|
||||
return this.http.post(`/aml/str-cases/${strCaseId}/file`, { referenceNo });
|
||||
}
|
||||
|
||||
// ═══ CTR reports (mechanical, threshold-based) ═══
|
||||
|
||||
getCtrReports(): Observable<any[]> {
|
||||
return this.http.get<any[]>('/aml/ctr-reports');
|
||||
}
|
||||
|
||||
/** month format: yyyy-MM */
|
||||
generateCtrReport(month: string): Observable<any> {
|
||||
return this.http.post('/aml/ctr-reports/generate', { month });
|
||||
}
|
||||
|
||||
// ═══ Client risk-category history ═══
|
||||
|
||||
getRiskCategoryHistory(clientId: number): Observable<any[]> {
|
||||
return this.http.get<any[]>(`/clients/${clientId}/risk-category-history`);
|
||||
}
|
||||
}
|
||||
+16
@@ -62,6 +62,22 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Mother requirement — shown independently of the Father/Caretaker check below -->
|
||||
@if (!nomineeOnly && clientFamilyMembers.length > 0 && !hasMother) {
|
||||
<div class="fm-step__empty fm-step__empty--required">
|
||||
<mat-icon class="fm-step__empty-icon">error_outline</mat-icon>
|
||||
<span class="fm-step__empty-required">Mother is required.</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Father-or-Caretaker requirement — shown independently of the Mother check above -->
|
||||
@if (!nomineeOnly && clientFamilyMembers.length > 0 && !hasFather && !hasCaretaker) {
|
||||
<div class="fm-step__empty fm-step__empty--required">
|
||||
<mat-icon class="fm-step__empty-icon">error_outline</mat-icon>
|
||||
<span class="fm-step__empty-required">Either Father or a Caretaker is required.</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Spouse hint for married clients -->
|
||||
@if (!nomineeOnly && isMarried && !hasSpouse) {
|
||||
<div class="fm-spouse-hint">
|
||||
|
||||
+44
@@ -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
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -223,6 +240,33 @@ export class ClientFamilyMembersStepComponent implements OnInit {
|
||||
return this.clientFamilyMembers.length > 0;
|
||||
}
|
||||
|
||||
/** True if a family member with the given relationship (case-insensitive) has been added */
|
||||
private hasRelationship(name: string): boolean {
|
||||
const target = name.toLowerCase();
|
||||
return this.clientFamilyMembers.some((m: any) => {
|
||||
const resolved = this.getRelName(m.relationshipId ?? m.relationship).toLowerCase();
|
||||
if (resolved === target) return true;
|
||||
return typeof m.relationship === 'string' && m.relationship.toLowerCase() === target;
|
||||
});
|
||||
}
|
||||
|
||||
get hasMother(): boolean {
|
||||
return this.hasRelationship('mother');
|
||||
}
|
||||
|
||||
get hasFather(): boolean {
|
||||
return this.hasRelationship('father');
|
||||
}
|
||||
|
||||
get hasCaretaker(): boolean {
|
||||
return this.hasRelationship('caretaker');
|
||||
}
|
||||
|
||||
/** Mother is always required; Father is required unless a Caretaker has been added instead. */
|
||||
get familyRelationshipRulesMet(): boolean {
|
||||
return this.hasMother && (this.hasFather || this.hasCaretaker);
|
||||
}
|
||||
|
||||
/** Check if client's marital status is "Married" */
|
||||
get isMarried(): boolean {
|
||||
if (!this.maritalStatusId || !this.maritalStatusOptions?.length) return false;
|
||||
|
||||
+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);
|
||||
}
|
||||
+117
-99
@@ -136,7 +136,7 @@
|
||||
| translate
|
||||
}}
|
||||
</mat-label>
|
||||
<input matInput required formControlName="fullname" />
|
||||
<input matInput required formControlName="fullname" autocomplete="off" />
|
||||
@if (createClientForm.controls.fullname.hasError('required')) {
|
||||
<mat-error>Client name is <strong>required</strong></mat-error>
|
||||
} @else if (createClientForm.controls.fullname.hasError('pattern')) {
|
||||
@@ -214,7 +214,7 @@
|
||||
<mat-form-field appearance="outline" class="name-field first-name">
|
||||
<mat-label>{{ 'labels.inputs.First Name' | translate }}</mat-label>
|
||||
<span matPrefix class="field-prefix-icon"><mat-icon>badge</mat-icon></span>
|
||||
<input matInput required formControlName="firstname" />
|
||||
<input matInput required formControlName="firstname" autocomplete="off" />
|
||||
@if (createClientForm.controls.firstname.hasError('required')) {
|
||||
<mat-error>First name is <strong>required</strong></mat-error>
|
||||
} @else if (createClientForm.controls.firstname.hasError('pattern')) {
|
||||
@@ -228,7 +228,7 @@
|
||||
<mat-form-field appearance="outline" class="name-field middle-name">
|
||||
<mat-label>{{ 'labels.inputs.Middle Name' | translate }}</mat-label>
|
||||
<span matPrefix class="field-prefix-icon"><mat-icon>person_outline</mat-icon></span>
|
||||
<input matInput formControlName="middlename" />
|
||||
<input matInput formControlName="middlename" autocomplete="off" />
|
||||
@if (
|
||||
createClientForm.controls.middlename?.hasError('pattern') &&
|
||||
createClientForm.controls.middlename?.touched
|
||||
@@ -241,7 +241,7 @@
|
||||
<mat-form-field appearance="outline" class="name-field last-name">
|
||||
<mat-label>{{ 'labels.inputs.Last Name' | translate }}</mat-label>
|
||||
<span matPrefix class="field-prefix-icon"><mat-icon>person</mat-icon></span>
|
||||
<input matInput required formControlName="lastname" />
|
||||
<input matInput required formControlName="lastname" autocomplete="off" />
|
||||
@if (createClientForm.controls.lastname.hasError('required')) {
|
||||
<mat-error>Last name is <strong>required</strong></mat-error>
|
||||
} @else if (createClientForm.controls.lastname.hasError('pattern')) {
|
||||
@@ -365,7 +365,7 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Minor / Guardian Mapping (mandatory if age < 10) -->
|
||||
<!-- Minor / Guardian Mapping (mandatory if age < 18) -->
|
||||
@if (isMinor && createClientForm.get('legalFormId')?.value === LegalFormId.PERSON) {
|
||||
<div class="guardian-section">
|
||||
<!-- Header bar -->
|
||||
@@ -1035,69 +1035,67 @@
|
||||
Education & Social Details
|
||||
</h4>
|
||||
<div class="form-grid">
|
||||
<!-- Education Level (non-minor) -->
|
||||
@if (!isMinor) {
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Education Level</mat-label>
|
||||
<mat-select formControlName="educationLevelId">
|
||||
<mat-select-trigger>
|
||||
@if (getSelectedEducationLevel()) {
|
||||
<span class="icon-option">
|
||||
<mat-icon class="icon-option-icon">{{
|
||||
getEducationLevelIcon(getSelectedEducationLevel().name)
|
||||
}}</mat-icon>
|
||||
{{ getSelectedEducationLevel().name }}
|
||||
</span>
|
||||
}
|
||||
</mat-select-trigger>
|
||||
@for (el of educationLevelOptions; track el) {
|
||||
<mat-option [value]="el.id">
|
||||
<span class="icon-option">
|
||||
<mat-icon class="icon-option-icon">{{ getEducationLevelIcon(el.name) }}</mat-icon>
|
||||
{{ el.name }}
|
||||
</span>
|
||||
</mat-option>
|
||||
<!-- Education Level -->
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Education Level</mat-label>
|
||||
<mat-select formControlName="educationLevelId">
|
||||
<mat-select-trigger>
|
||||
@if (getSelectedEducationLevel()) {
|
||||
<span class="icon-option">
|
||||
<mat-icon class="icon-option-icon">{{
|
||||
getEducationLevelIcon(getSelectedEducationLevel().name)
|
||||
}}</mat-icon>
|
||||
{{ getSelectedEducationLevel().name }}
|
||||
</span>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</mat-select-trigger>
|
||||
@for (el of educationLevelOptions; track el) {
|
||||
<mat-option [value]="el.id">
|
||||
<span class="icon-option">
|
||||
<mat-icon class="icon-option-icon">{{ getEducationLevelIcon(el.name) }}</mat-icon>
|
||||
{{ el.name }}
|
||||
</span>
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Degree / Qualification (depends on Education Level) -->
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Degree / Qualification</mat-label>
|
||||
<mat-select
|
||||
formControlName="degreeQualificationId"
|
||||
[placeholder]="getSelectedEducationLevel() ? 'Select Degree' : 'Select Education Level first'"
|
||||
>
|
||||
<mat-select-trigger>
|
||||
@if (getSelectedDegreeQualification()) {
|
||||
<span class="icon-option">
|
||||
<mat-icon class="icon-option-icon">{{
|
||||
getDegreeQualificationIcon(getSelectedDegreeQualification().name)
|
||||
}}</mat-icon>
|
||||
{{ getSelectedDegreeQualification().name }}
|
||||
</span>
|
||||
}
|
||||
</mat-select-trigger>
|
||||
@for (dq of filteredDegreeQualificationOptions; track dq) {
|
||||
<mat-option [value]="dq.id">
|
||||
<span class="icon-option">
|
||||
<mat-icon class="icon-option-icon">{{ getDegreeQualificationIcon(dq.name) }}</mat-icon>
|
||||
{{ dq.name }}
|
||||
</span>
|
||||
</mat-option>
|
||||
<!-- Degree / Qualification (depends on Education Level) -->
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Degree / Qualification</mat-label>
|
||||
<mat-select
|
||||
formControlName="degreeQualificationId"
|
||||
[placeholder]="getSelectedEducationLevel() ? 'Select Degree' : 'Select Education Level first'"
|
||||
>
|
||||
<mat-select-trigger>
|
||||
@if (getSelectedDegreeQualification()) {
|
||||
<span class="icon-option">
|
||||
<mat-icon class="icon-option-icon">{{
|
||||
getDegreeQualificationIcon(getSelectedDegreeQualification().name)
|
||||
}}</mat-icon>
|
||||
{{ getSelectedDegreeQualification().name }}
|
||||
</span>
|
||||
}
|
||||
@if (!getSelectedEducationLevel()) {
|
||||
<mat-option disabled>
|
||||
<em>Please select Education Level first</em>
|
||||
</mat-option>
|
||||
} @else if (filteredDegreeQualificationOptions.length === 0) {
|
||||
<mat-option disabled>
|
||||
<em>No options available</em>
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
}
|
||||
</mat-select-trigger>
|
||||
@for (dq of filteredDegreeQualificationOptions; track dq) {
|
||||
<mat-option [value]="dq.id">
|
||||
<span class="icon-option">
|
||||
<mat-icon class="icon-option-icon">{{ getDegreeQualificationIcon(dq.name) }}</mat-icon>
|
||||
{{ dq.name }}
|
||||
</span>
|
||||
</mat-option>
|
||||
}
|
||||
@if (!getSelectedEducationLevel()) {
|
||||
<mat-option disabled>
|
||||
<em>Please select Education Level first</em>
|
||||
</mat-option>
|
||||
} @else if (filteredDegreeQualificationOptions.length === 0) {
|
||||
<mat-option disabled>
|
||||
<em>No options available</em>
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Religion -->
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
@@ -1234,38 +1232,6 @@
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Monthly Turnover</mat-label>
|
||||
<span matPrefix class="rupee-prefix">₹</span>
|
||||
<input matInput type="number" formControlName="monthlyTurnover" min="0" />
|
||||
@if (
|
||||
createClientForm.get('monthlyTurnover')?.hasError('pattern') &&
|
||||
createClientForm.get('monthlyTurnover')?.touched
|
||||
) {
|
||||
<mat-error>Enter a valid amount</mat-error>
|
||||
} @else if (
|
||||
createClientForm.get('monthlyTurnover')?.hasError('min') && createClientForm.get('monthlyTurnover')?.touched
|
||||
) {
|
||||
<mat-error>Amount cannot be negative</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Yearly Turnover</mat-label>
|
||||
<span matPrefix class="rupee-prefix">₹</span>
|
||||
<input matInput type="number" formControlName="annualTurnover" min="0" />
|
||||
@if (
|
||||
createClientForm.get('annualTurnover')?.hasError('pattern') &&
|
||||
createClientForm.get('annualTurnover')?.touched
|
||||
) {
|
||||
<mat-error>Enter a valid amount</mat-error>
|
||||
} @else if (
|
||||
createClientForm.get('annualTurnover')?.hasError('min') && createClientForm.get('annualTurnover')?.touched
|
||||
) {
|
||||
<mat-error>Amount cannot be negative</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<div class="full-width subsection-title">
|
||||
<fa-icon icon="coins" class="subsection-icon"></fa-icon>
|
||||
Other Income
|
||||
@@ -1305,6 +1271,38 @@
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Monthly Turnover</mat-label>
|
||||
<span matPrefix class="rupee-prefix">₹</span>
|
||||
<input matInput type="number" formControlName="monthlyTurnover" min="0" />
|
||||
@if (
|
||||
createClientForm.get('monthlyTurnover')?.hasError('pattern') &&
|
||||
createClientForm.get('monthlyTurnover')?.touched
|
||||
) {
|
||||
<mat-error>Enter a valid amount</mat-error>
|
||||
} @else if (
|
||||
createClientForm.get('monthlyTurnover')?.hasError('min') && createClientForm.get('monthlyTurnover')?.touched
|
||||
) {
|
||||
<mat-error>Amount cannot be negative</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Yearly Turnover</mat-label>
|
||||
<span matPrefix class="rupee-prefix">₹</span>
|
||||
<input matInput type="number" formControlName="annualTurnover" min="0" />
|
||||
@if (
|
||||
createClientForm.get('annualTurnover')?.hasError('pattern') &&
|
||||
createClientForm.get('annualTurnover')?.touched
|
||||
) {
|
||||
<mat-error>Enter a valid amount</mat-error>
|
||||
} @else if (
|
||||
createClientForm.get('annualTurnover')?.hasError('min') && createClientForm.get('annualTurnover')?.touched
|
||||
) {
|
||||
<mat-error>Amount cannot be negative</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<div class="full-width subsection-title">
|
||||
<fa-icon icon="user-shield" class="subsection-icon"></fa-icon>
|
||||
Senior Citizen / Person with Disability
|
||||
@@ -1322,6 +1320,8 @@
|
||||
<fa-icon icon="file-upload"></fa-icon>
|
||||
@if (seniorCitizenProofFile) {
|
||||
<span>{{ seniorCitizenProofFile.name }}</span>
|
||||
} @else if (existingSeniorCitizenProofDoc) {
|
||||
<span>{{ existingSeniorCitizenProofDoc.name }} (uploaded)</span>
|
||||
} @else {
|
||||
<span>Upload Senior Citizen Proof (Age / ID Proof) — optional</span>
|
||||
}
|
||||
@@ -1330,6 +1330,13 @@
|
||||
<button type="button" mat-icon-button (click)="removeSeniorCitizenProof()" matTooltip="Remove">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
} @else if (existingSeniorCitizenProofDoc) {
|
||||
<button type="button" mat-icon-button (click)="viewExistingSeniorCitizenProof()" matTooltip="View">
|
||||
<mat-icon>visibility</mat-icon>
|
||||
</button>
|
||||
<button type="button" mat-icon-button (click)="removeExistingSeniorCitizenProof()" matTooltip="Remove">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -1354,12 +1361,16 @@
|
||||
<div class="full-width doc-upload-field">
|
||||
<label
|
||||
class="doc-upload-label"
|
||||
[class.doc-upload-label--missing]="seniorPwdDocsTouched && !pwdCertificateFile"
|
||||
[class.doc-upload-label--missing]="
|
||||
seniorPwdDocsTouched && !pwdCertificateFile && !existingPwdCertificateDoc
|
||||
"
|
||||
>
|
||||
<input type="file" accept=".jpg,.jpeg,.png,.pdf" hidden (change)="onPwdCertificateSelected($event)" />
|
||||
<fa-icon icon="file-upload"></fa-icon>
|
||||
@if (pwdCertificateFile) {
|
||||
<span>{{ pwdCertificateFile.name }}</span>
|
||||
} @else if (existingPwdCertificateDoc) {
|
||||
<span>{{ existingPwdCertificateDoc.name }} (uploaded)</span>
|
||||
} @else {
|
||||
<span>Upload Disability Certificate <span class="doc-upload-req">*</span></span>
|
||||
}
|
||||
@@ -1368,8 +1379,15 @@
|
||||
<button type="button" mat-icon-button (click)="removePwdCertificate()" matTooltip="Remove">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
} @else if (existingPwdCertificateDoc) {
|
||||
<button type="button" mat-icon-button (click)="viewExistingPwdCertificate()" matTooltip="View">
|
||||
<mat-icon>visibility</mat-icon>
|
||||
</button>
|
||||
<button type="button" mat-icon-button (click)="removeExistingPwdCertificate()" matTooltip="Remove">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
}
|
||||
@if (seniorPwdDocsTouched && !pwdCertificateFile) {
|
||||
@if (seniorPwdDocsTouched && !pwdCertificateFile && !existingPwdCertificateDoc) {
|
||||
<span class="doc-upload-error">Required when PWD is checked</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
+226
-3
@@ -46,6 +46,7 @@ import { MatSelectTrigger } from '@angular/material/select';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { MatDialog, MAT_DIALOG_DATA, MatDialogContent, MatDialogClose } from '@angular/material/dialog';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { DocumentPreviewDialogComponent } from '../../shared/document-preview-dialog/document-preview-dialog.component';
|
||||
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
|
||||
import { SanctionsVerifyDialogComponent } from './sanctions-verify-dialog.component';
|
||||
import { DipVerifyDialogComponent } from './dip-verify-dialog.component';
|
||||
@@ -98,10 +99,16 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
seniorCitizenProofFile: File | null = null;
|
||||
pwdCertificateFile: File | null = null;
|
||||
|
||||
/** Previously-uploaded documents, loaded in edit mode so the certificate doesn't appear to
|
||||
* "disappear" on reopening — the raw File properties above only ever hold a fresh selection. */
|
||||
existingSeniorCitizenProofDoc: { id: string; name: string } | null = null;
|
||||
existingPwdCertificateDoc: { id: string; name: string } | null = null;
|
||||
|
||||
/** Disability categories (per the Rights of Persons with Disabilities Act, 2016) — fetched from
|
||||
* m_code_value (code "PwdCategory") via the client template, same as occupationCategoryOptions.
|
||||
* Admins can add/reorder/deactivate entries from System -> Manage Codes without a code change. */
|
||||
pwdCategoryOptions: any[] = [];
|
||||
riskRules: any[] = [];
|
||||
|
||||
/** Set true after a save attempt so the UI can highlight a missing required document */
|
||||
seniorPwdDocsTouched = false;
|
||||
@@ -111,7 +118,8 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
get seniorPwdDocsComplete(): boolean {
|
||||
const pwdOk =
|
||||
!this.createClientForm.get('isPwd')?.value ||
|
||||
(!!this.pwdCertificateFile && !!this.createClientForm.get('pwdCategoryId')?.value);
|
||||
((!!this.pwdCertificateFile || !!this.existingPwdCertificateDoc) &&
|
||||
!!this.createClientForm.get('pwdCategoryId')?.value);
|
||||
return pwdOk;
|
||||
}
|
||||
|
||||
@@ -141,6 +149,93 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
this.pwdCertificateFile = null;
|
||||
}
|
||||
|
||||
/** Loads previously-uploaded senior-citizen-proof / PWD-certificate documents in edit mode —
|
||||
* mirrors the KYC step's loadExistingSignature() pattern so re-opening edit shows what's
|
||||
* already on file instead of an empty upload prompt. */
|
||||
private loadExistingSeniorPwdDocuments(): void {
|
||||
const clientId = this.clientData?.id;
|
||||
if (!clientId) {
|
||||
return;
|
||||
}
|
||||
this.clientService.getClientDocuments(clientId.toString()).subscribe({
|
||||
next: (documents: any[]) => {
|
||||
const seniorDoc = documents
|
||||
?.filter((d: any) => d.name === 'seniorCitizenProof')
|
||||
?.sort((a: any, b: any) => b.id - a.id)?.[0];
|
||||
if (seniorDoc?.id) {
|
||||
this.existingSeniorCitizenProofDoc = {
|
||||
id: seniorDoc.id.toString(),
|
||||
name: seniorDoc.fileName || 'Senior Citizen Proof'
|
||||
};
|
||||
}
|
||||
const pwdDoc = documents
|
||||
?.filter((d: any) => d.name === 'pwdCertificate')
|
||||
?.sort((a: any, b: any) => b.id - a.id)?.[0];
|
||||
if (pwdDoc?.id) {
|
||||
this.existingPwdCertificateDoc = {
|
||||
id: pwdDoc.id.toString(),
|
||||
name: pwdDoc.fileName || 'Disability Certificate'
|
||||
};
|
||||
}
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
}
|
||||
|
||||
viewExistingSeniorCitizenProof(): void {
|
||||
const clientId = this.clientData?.id;
|
||||
if (!clientId || !this.existingSeniorCitizenProofDoc) {
|
||||
return;
|
||||
}
|
||||
this.clientService.downloadClientDocument(clientId.toString(), this.existingSeniorCitizenProofDoc.id).subscribe({
|
||||
next: (blob: any) =>
|
||||
this.dialog.open(DocumentPreviewDialogComponent, {
|
||||
data: {
|
||||
blob,
|
||||
title: this.existingSeniorCitizenProofDoc?.name,
|
||||
fileName: this.existingSeniorCitizenProofDoc?.name
|
||||
}
|
||||
}),
|
||||
error: () => {}
|
||||
});
|
||||
}
|
||||
|
||||
removeExistingSeniorCitizenProof(): void {
|
||||
const clientId = this.clientData?.id;
|
||||
if (!clientId || !this.existingSeniorCitizenProofDoc) {
|
||||
return;
|
||||
}
|
||||
this.clientService.deleteClientDocument(clientId.toString(), this.existingSeniorCitizenProofDoc.id).subscribe({
|
||||
next: () => (this.existingSeniorCitizenProofDoc = null),
|
||||
error: () => {}
|
||||
});
|
||||
}
|
||||
|
||||
viewExistingPwdCertificate(): void {
|
||||
const clientId = this.clientData?.id;
|
||||
if (!clientId || !this.existingPwdCertificateDoc) {
|
||||
return;
|
||||
}
|
||||
this.clientService.downloadClientDocument(clientId.toString(), this.existingPwdCertificateDoc.id).subscribe({
|
||||
next: (blob: any) =>
|
||||
this.dialog.open(DocumentPreviewDialogComponent, {
|
||||
data: { blob, title: this.existingPwdCertificateDoc?.name, fileName: this.existingPwdCertificateDoc?.name }
|
||||
}),
|
||||
error: () => {}
|
||||
});
|
||||
}
|
||||
|
||||
removeExistingPwdCertificate(): void {
|
||||
const clientId = this.clientData?.id;
|
||||
if (!clientId || !this.existingPwdCertificateDoc) {
|
||||
return;
|
||||
}
|
||||
this.clientService.deleteClientDocument(clientId.toString(), this.existingPwdCertificateDoc.id).subscribe({
|
||||
next: () => (this.existingPwdCertificateDoc = null),
|
||||
error: () => {}
|
||||
});
|
||||
}
|
||||
|
||||
/** Minimum date allowed. */
|
||||
minDate = new Date(2000, 0, 1);
|
||||
/** Maximum date allowed. */
|
||||
@@ -203,7 +298,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
];
|
||||
/** Calculated age detail string (Y/M/D) */
|
||||
calculatedAgeDetail: string | null = null;
|
||||
/** Whether client is a minor (age < 10) */
|
||||
/** Whether client is a minor (age < 18) */
|
||||
isMinor = false;
|
||||
/** Whether client is a senior citizen (age >= 60) — auto-detected from Date of Birth */
|
||||
isSeniorCitizen = false;
|
||||
@@ -452,6 +547,18 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
ngOnInit() {
|
||||
this.maxDate = this.settingsService.businessDate;
|
||||
this.setOptions();
|
||||
|
||||
// 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) => {
|
||||
this.maritalStatusOptions = options || [];
|
||||
@@ -600,6 +707,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
*/
|
||||
private prefillForEdit() {
|
||||
const data = this.clientData;
|
||||
this.loadExistingSeniorPwdDocuments();
|
||||
|
||||
// Patch all base controls that exist on the form
|
||||
this.createClientForm.patchValue({
|
||||
@@ -980,7 +1088,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
this.calculatedAgeDetail = `${years} yr${years !== 1 ? 's' : ''} ${months} mo${months !== 1 ? 's' : ''} ${days} day${days !== 1 ? 's' : ''}`;
|
||||
this.ageBreakdown = { years, months, days };
|
||||
const wasMinor = this.isMinor;
|
||||
this.isMinor = years < 10;
|
||||
this.isMinor = years < 18;
|
||||
this.isSeniorCitizen = years >= 60;
|
||||
// Add/remove guardian form group based on minor status
|
||||
if (this.isMinor && !wasMinor) {
|
||||
@@ -2025,6 +2133,121 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
|
||||
return generalDetails;
|
||||
}
|
||||
|
||||
get calculatedRisk(): {
|
||||
category: number;
|
||||
reason: string;
|
||||
amlCheckStatus?: string;
|
||||
bannedEntityMatch?: string;
|
||||
willfulDefaulter?: string;
|
||||
ecgcSalFlag?: string;
|
||||
dormantFlag?: string;
|
||||
} {
|
||||
const legalFormId = this.createClientForm.get('legalFormId')?.value || 1; // 1 = Person, 2 = Entity
|
||||
const occCatId = this.createClientForm.get('occupationCategoryCvId')?.value;
|
||||
const occId = this.createClientForm.get('occupationCvId')?.value;
|
||||
|
||||
const monthlyIncome = parseFloat(this.createClientForm.get('monthlyIncome')?.value) || 0;
|
||||
const annualIncome = parseFloat(this.createClientForm.get('annualIncome')?.value) || 0;
|
||||
const monthlyTurnover = parseFloat(this.createClientForm.get('monthlyTurnover')?.value) || 0;
|
||||
const annualTurnover = parseFloat(this.createClientForm.get('annualTurnover')?.value) || 0;
|
||||
const agricultureIncome = parseFloat(this.createClientForm.get('agricultureIncome')?.value) || 0;
|
||||
const nonAgricultureIncome = parseFloat(this.createClientForm.get('nonAgricultureIncome')?.value) || 0;
|
||||
|
||||
const totalAnnualIncome = annualIncome + agricultureIncome + nonAgricultureIncome;
|
||||
|
||||
// Evaluate rules from DB (ordered by orderPosition ASC)
|
||||
if (this.riskRules && this.riskRules.length > 0) {
|
||||
for (const rule of this.riskRules) {
|
||||
// Skip transaction/AML rules that have no KYC criteria
|
||||
if (
|
||||
rule.legalFormId == null &&
|
||||
rule.occupationCategoryCvId == null &&
|
||||
rule.occupationCvId == null &&
|
||||
rule.minMonthlyIncome == null &&
|
||||
rule.maxMonthlyIncome == null &&
|
||||
rule.minAnnualIncome == null &&
|
||||
rule.maxAnnualIncome == null &&
|
||||
rule.minAnnualTurnover == null &&
|
||||
rule.maxAnnualTurnover == null
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Match legal form
|
||||
if (rule.legalFormId != null && rule.legalFormId !== legalFormId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Match occupation category
|
||||
if (rule.occupationCategoryCvId != null && rule.occupationCategoryCvId !== occCatId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Match occupation
|
||||
if (rule.occupationCvId != null && rule.occupationCvId !== occId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Match monthly income ranges
|
||||
if (rule.minMonthlyIncome != null && monthlyIncome < rule.minMonthlyIncome) {
|
||||
continue;
|
||||
}
|
||||
if (rule.maxMonthlyIncome != null && monthlyIncome > rule.maxMonthlyIncome) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Match annual income ranges (evaluated against totalAnnualIncome)
|
||||
if (rule.minAnnualIncome != null && totalAnnualIncome < rule.minAnnualIncome) {
|
||||
continue;
|
||||
}
|
||||
if (rule.maxAnnualIncome != null && totalAnnualIncome > rule.maxAnnualIncome) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Match turnover ranges
|
||||
if (rule.minAnnualTurnover != null && annualTurnover < rule.minAnnualTurnover) {
|
||||
continue;
|
||||
}
|
||||
if (rule.maxAnnualTurnover != null && annualTurnover > rule.maxAnnualTurnover) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// All matched!
|
||||
return {
|
||||
category: rule.riskCategory,
|
||||
reason: rule.reason,
|
||||
amlCheckStatus: rule.amlCheckStatus || (rule.riskCategory === 3 ? 'FLAGGED' : 'CLEAR'),
|
||||
bannedEntityMatch: rule.bannedEntityMatch || 'NO',
|
||||
willfulDefaulter: rule.willfulDefaulter || 'NO',
|
||||
ecgcSalFlag: rule.ecgcSalFlag || 'NO',
|
||||
dormantFlag: rule.dormantFlag || 'ACTIVE'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Default Fallbacks
|
||||
if (legalFormId !== LegalFormId.PERSON) {
|
||||
return {
|
||||
category: 2,
|
||||
reason: 'Non-Individual / Entity profile (Default Medium Risk)',
|
||||
amlCheckStatus: 'CLEAR',
|
||||
bannedEntityMatch: 'NO',
|
||||
willfulDefaulter: 'NO',
|
||||
ecgcSalFlag: 'NO',
|
||||
dormantFlag: 'ACTIVE'
|
||||
};
|
||||
}
|
||||
return {
|
||||
category: 1,
|
||||
reason: 'Default Low Risk profile (Salary / Service / Agriculture / Others)',
|
||||
amlCheckStatus: 'CLEAR',
|
||||
bannedEntityMatch: 'NO',
|
||||
willfulDefaulter: 'NO',
|
||||
ecgcSalFlag: 'NO',
|
||||
dormantFlag: 'ACTIVE'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+35
@@ -42,6 +42,7 @@ import { ClientsService } from 'app/clients/clients.service';
|
||||
<strong>UN Security Council</strong> &
|
||||
<strong>Financial Intelligence Unit - India (FIU-IND under UNSC Resolution 2231)</strong>
|
||||
</p>
|
||||
<p class="regulatory-note">{{ regulatoryBasis }}</p>
|
||||
</div>
|
||||
}
|
||||
@if (!verifying && !matchFound && !errorOccurred) {
|
||||
@@ -69,7 +70,14 @@ import { ClientsService } from 'app/clients/clients.service';
|
||||
<span class="meta-label">Database</span>
|
||||
<span class="meta-value">UN SC Consolidated List</span>
|
||||
</div>
|
||||
@if (listDataAsOf) {
|
||||
<div class="meta-row">
|
||||
<span class="meta-label">List Data As Of</span>
|
||||
<span class="meta-value">{{ listDataAsOf }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<p class="regulatory-note">{{ regulatoryBasis }}</p>
|
||||
</div>
|
||||
}
|
||||
@if (!verifying && matchFound) {
|
||||
@@ -96,11 +104,18 @@ import { ClientsService } from 'app/clients/clients.service';
|
||||
<span class="meta-label">Database</span>
|
||||
<span class="meta-value">UN SC Consolidated List</span>
|
||||
</div>
|
||||
@if (listDataAsOf) {
|
||||
<div class="meta-row">
|
||||
<span class="meta-label">List Data As Of</span>
|
||||
<span class="meta-value">{{ listDataAsOf }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="escalate-banner">
|
||||
<fa-icon icon="exclamation-circle" class="escalate-icon"></fa-icon>
|
||||
<span><strong>Escalate to Principal Officer immediately.</strong> Account opening blocked.</span>
|
||||
</div>
|
||||
<p class="regulatory-note">{{ regulatoryBasis }}</p>
|
||||
</div>
|
||||
}
|
||||
@if (!verifying && errorOccurred) {
|
||||
@@ -207,6 +222,15 @@ import { ClientsService } from 'app/clients/clients.service';
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.regulatory-note {
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
margin: 8px 0 0;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.approve-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -524,6 +548,10 @@ export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
|
||||
errorOccurred = false;
|
||||
errorMessage = '';
|
||||
screeningLogId: number | null = null;
|
||||
listDataAsOf: string | null = null;
|
||||
|
||||
regulatoryBasis =
|
||||
'Screened as per Section 51-A of the Unlawful Activities (Prevention) Act, 1967 and the RBI Master Direction on KYC.';
|
||||
|
||||
screenedDate = new Date().toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
progressValue = 0;
|
||||
@@ -624,6 +652,13 @@ export class SanctionsVerifyDialogComponent implements OnInit, OnDestroy {
|
||||
'Unable to connect to sanctions screening service.';
|
||||
} else {
|
||||
this.screeningLogId = this.apiResult?.screeningLogId || null;
|
||||
this.listDataAsOf = this.apiResult?.listDataAsOf
|
||||
? new Date(this.apiResult.listDataAsOf).toLocaleDateString('en-IN', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
})
|
||||
: null;
|
||||
if (this.apiResult?.matchFound) {
|
||||
this.matchFound = true;
|
||||
this.matchCount = this.apiResult.matchCount || 0;
|
||||
|
||||
+3
-2
@@ -173,8 +173,9 @@
|
||||
@if (selectedAddrProof.requiresDate) {
|
||||
<mat-form-field appearance="outline" class="addr-ff">
|
||||
<mat-label>Issue / bill date</mat-label>
|
||||
<span matPrefix class="addr-prefix"><mat-icon>event</mat-icon></span>
|
||||
<input matInput type="date" formControlName="issueDate" />
|
||||
<input matInput [matDatepicker]="addrIssueDatePicker" formControlName="issueDate" />
|
||||
<mat-datepicker-toggle matSuffix [for]="addrIssueDatePicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #addrIssueDatePicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
}
|
||||
</div>
|
||||
|
||||
+48
-26
@@ -15,9 +15,12 @@ import { MatCheckbox } from '@angular/material/checkbox';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatIconButton, MatButton } from '@angular/material/button';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { ClientsService } from '../../clients.service';
|
||||
import { ProofType, normalizeProofType, groupProofsByCategory } from '../../shared/kyc-proof-types';
|
||||
import { KycUploadHelperService } from '../../shared/kyc-upload-helper.service';
|
||||
import { DocumentPreviewDialogComponent } from '../../shared/document-preview-dialog/document-preview-dialog.component';
|
||||
import { Dates } from 'app/core/utils/dates';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-client-india-address-step',
|
||||
@@ -38,6 +41,8 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
||||
private http = inject(HttpClient);
|
||||
private clientsService = inject(ClientsService);
|
||||
private kycUpload = inject(KycUploadHelperService);
|
||||
private dialog = inject(MatDialog);
|
||||
private dates = inject(Dates);
|
||||
|
||||
/** Client ID — when set, loads existing addresses for edit mode */
|
||||
@Input() clientId: string = '';
|
||||
@@ -46,6 +51,10 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
||||
/** Minor clients can't hold utility bills, bank statements or an adult-only OVD in their own
|
||||
* name — restricts the address-proof picker to Aadhaar/Passport when true. */
|
||||
@Input() isMinor = false;
|
||||
/** Non-individual (entity) clients — hides personal OVDs (Aadhaar/Passport/DL/Voter ID/NREGA/
|
||||
* NPR/Birth Certificate/School ID) from the proof-of-address picker; a company's registered
|
||||
* office address is evidenced by utility bills, GST/rent documents etc., not a person's ID. */
|
||||
@Input() isNonIndividual = false;
|
||||
|
||||
activeTab = 0;
|
||||
sameAsPermanent = false;
|
||||
@@ -279,8 +288,8 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
||||
this.clientsService.getKycProofTypes().subscribe({
|
||||
next: (res: any) => {
|
||||
const activeFilter = (p: any) => p?.active !== false;
|
||||
this.addressProofs = this.filterAddressProofsForMinor(
|
||||
(res?.addressProofs || []).filter(activeFilter).map(normalizeProofType)
|
||||
this.addressProofs = this.filterAddressProofsForNonIndividual(
|
||||
this.filterAddressProofsForMinor((res?.addressProofs || []).filter(activeFilter).map(normalizeProofType))
|
||||
);
|
||||
this.addressGroups = groupProofsByCategory(this.addressProofs);
|
||||
this.loadingProofs = false;
|
||||
@@ -306,6 +315,26 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
||||
);
|
||||
}
|
||||
|
||||
/** Personal OVDs — not valid proof of a registered office/entity address. */
|
||||
private static readonly NON_INDIVIDUAL_EXCLUDED_ADDRESS_PROOF_CODES = new Set([
|
||||
'AADHAAR',
|
||||
'PASSPORT',
|
||||
'VOTER_ID',
|
||||
'DRIVING_LICENSE',
|
||||
'NREGA_JOB_CARD',
|
||||
'NPR_LETTER',
|
||||
'BIRTH_CERTIFICATE',
|
||||
'SCHOOL_ID_CARD'
|
||||
]);
|
||||
|
||||
private filterAddressProofsForNonIndividual(proofs: ProofType[]): ProofType[] {
|
||||
if (!this.isNonIndividual) return proofs;
|
||||
return proofs.filter(
|
||||
(p) =>
|
||||
!ClientIndiaAddressStepComponent.NON_INDIVIDUAL_EXCLUDED_ADDRESS_PROOF_CODES.has((p.code || '').toUpperCase())
|
||||
);
|
||||
}
|
||||
|
||||
selectAddrProof(proof: ProofType): void {
|
||||
this.selectedAddrProof = proof;
|
||||
this.permanentForm.get('proofId')?.setValue(proof.id);
|
||||
@@ -386,11 +415,12 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
||||
files.push({ slot: 'Back', file: this.addrFiles.back });
|
||||
}
|
||||
}
|
||||
const rawIssueDate = this.permanentForm.get('issueDate')?.value;
|
||||
return {
|
||||
proof: this.selectedAddrProof,
|
||||
proofId: this.permanentForm.get('proofId')?.value || '',
|
||||
documentKey: this.permanentForm.get('documentKey')?.value || '',
|
||||
issueDate: this.permanentForm.get('issueDate')?.value || '',
|
||||
issueDate: rawIssueDate ? this.dates.getDate(rawIssueDate) : '',
|
||||
files
|
||||
};
|
||||
}
|
||||
@@ -422,6 +452,14 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Legal form toggled on an already-initialized instance — re-fetch rather than filter
|
||||
// `addressProofs` in place, since the non-individual filter only ever removes entries and
|
||||
// toggling back to individual could never recover what a prior pass had stripped out.
|
||||
if (changes['isNonIndividual'] && !changes['isNonIndividual'].firstChange) {
|
||||
this.selectedAddrProof = null;
|
||||
this.permanentForm.get('proofId')?.setValue('');
|
||||
this.loadProofTypes();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -525,13 +563,13 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
||||
proofId: matchedProof.id,
|
||||
documentKey: identifier.documentKey || ''
|
||||
});
|
||||
// The description carries `Issue date: dd/mm/yyyy` when set on create (see
|
||||
// The description carries `Issue date: yyyy-MM-dd` when set on create (see
|
||||
// create-client.saveAddressProof) — pull it out so the form's issueDate field
|
||||
// shows what was previously entered.
|
||||
const desc: string = identifier.description || '';
|
||||
const issueMatch = desc.match(/Issue date:\s*(.+)$/i);
|
||||
if (issueMatch) {
|
||||
this.permanentForm.patchValue({ issueDate: issueMatch[1].trim() });
|
||||
this.permanentForm.patchValue({ issueDate: this.dates.parseDate(issueMatch[1].trim()) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -633,33 +671,17 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the saved address proof in a new browser tab.
|
||||
*
|
||||
* For images we already hold a base64 data URL in `previewUrl` from
|
||||
* `loadExistingAddressProof`, so we can hand it straight to `window.open`.
|
||||
* For PDFs / other types we need to fetch the binary blob and create a
|
||||
* temporary object URL so the browser's PDF viewer can render it.
|
||||
*/
|
||||
/** Open the saved address proof in an in-app preview dialog. */
|
||||
viewExistingAddressProof(): void {
|
||||
if (!this.existingAddressProofIdentifierId || this.existingAddressProofDocuments.length === 0) {
|
||||
return;
|
||||
}
|
||||
const doc = this.existingAddressProofDocuments[0];
|
||||
if (doc.previewUrl) {
|
||||
window.open(doc.previewUrl, '_blank', 'noopener');
|
||||
return;
|
||||
}
|
||||
this.clientsService.downloadClientIdentificationDocument(this.existingAddressProofIdentifierId, doc.id).subscribe({
|
||||
next: (blob: Blob) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const w = window.open(url, '_blank', 'noopener');
|
||||
// Revoke after the tab has had time to load — browsers vary, so
|
||||
// err on the side of a generous wait.
|
||||
if (w) {
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
}
|
||||
},
|
||||
next: (blob: Blob) =>
|
||||
this.dialog.open(DocumentPreviewDialogComponent, {
|
||||
data: { blob, title: doc.fileName || doc.name, fileName: doc.fileName || doc.name }
|
||||
}),
|
||||
error: () => {
|
||||
// Silent fail — UI shows the saved name so user knows it exists.
|
||||
}
|
||||
|
||||
@@ -1049,6 +1049,12 @@
|
||||
</h4>
|
||||
|
||||
<div class="form-grid">
|
||||
<div class="checkbox-field override-checkbox-wrapper full-width">
|
||||
<mat-checkbox formControlName="allowRiskOverride" [disabled]="readOnly">
|
||||
Override Auto-calculated Risk Category
|
||||
</mat-checkbox>
|
||||
</div>
|
||||
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>Risk Category</mat-label>
|
||||
<mat-select formControlName="riskCategory">
|
||||
@@ -1086,6 +1092,64 @@
|
||||
<mat-hint>Auto: Low 10yr, Medium 8yr, High 2yr</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
@if (riskCalculationReason && !kycForm.get('allowRiskOverride')?.value && !readOnly) {
|
||||
<div
|
||||
class="risk-auto-calc-info full-width"
|
||||
[class.risk-auto-calc-info--low]="kycForm.get('riskCategory')?.value === 1"
|
||||
[class.risk-auto-calc-info--medium]="kycForm.get('riskCategory')?.value === 2"
|
||||
[class.risk-auto-calc-info--high]="kycForm.get('riskCategory')?.value === 3"
|
||||
>
|
||||
<mat-icon class="info-icon">info</mat-icon>
|
||||
<span class="info-text"
|
||||
>Auto-determined: <strong>{{ riskCalculationReason }}</strong></span
|
||||
>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="risk-rules-toggle-wrapper full-width">
|
||||
<button mat-button type="button" class="rules-toggle-btn" (click)="showRiskRulesTable = !showRiskRulesTable">
|
||||
<mat-icon class="rules-toggle-icon">{{ showRiskRulesTable ? 'expand_less' : 'expand_more' }}</mat-icon>
|
||||
{{
|
||||
showRiskRulesTable ? 'Hide Risk Classification Criteria Table' : 'View Risk Classification Criteria Table'
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (showRiskRulesTable) {
|
||||
<div class="risk-rules-card mat-elevation-z1 full-width">
|
||||
<table class="risk-rules-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Risk Category</th>
|
||||
<th>Primary Criteria / Basis</th>
|
||||
<th>Examples / Thresholds</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="rule-row rule-row--high">
|
||||
<td><span class="risk-badge risk-badge--high">High Risk</span></td>
|
||||
<td>PEPs, High-Risk Industries, High Income / Turnovers</td>
|
||||
<td>
|
||||
Politicians; Real Estate, Import/Export; Commission Agents; Income > ₹20L/yr; Turnover > ₹1 Crore
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="rule-row rule-row--medium">
|
||||
<td><span class="risk-badge risk-badge--medium">Medium Risk</span></td>
|
||||
<td>Self-Employed, Professionals, Cross-Border, Moderate Income</td>
|
||||
<td>
|
||||
Business Owners, Traders; Doctors, Lawyers, CAs; NRI, PIO, OCI; Income > ₹6L/yr; Turnover > ₹20L
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="rule-row rule-row--low">
|
||||
<td><span class="risk-badge risk-badge--low">Low Risk</span></td>
|
||||
<td>Non-Earning, Government Employment, Agriculture / Manual</td>
|
||||
<td>Students, Minors, Homemakers; Govt/PSU Employees; Farmers, Daily Wage; Income <= ₹6L/yr</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
<mat-form-field appearance="outline" class="form-field full-width">
|
||||
<mat-label>{{ isKycVerified ? 'Reason for Re-edit *' : 'Remarks' }}</mat-label>
|
||||
<textarea
|
||||
@@ -1187,6 +1251,12 @@
|
||||
</h4>
|
||||
|
||||
<div class="form-grid">
|
||||
<div class="checkbox-field override-checkbox-wrapper full-width">
|
||||
<mat-checkbox formControlName="allowAmlOverride" [disabled]="readOnly">
|
||||
Override Auto-calculated AML & Compliance Checks
|
||||
</mat-checkbox>
|
||||
</div>
|
||||
|
||||
<mat-form-field appearance="outline" class="form-field">
|
||||
<mat-label>AML Check Status</mat-label>
|
||||
<mat-select required formControlName="amlCheckStatus">
|
||||
@@ -1417,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) {
|
||||
|
||||
@@ -314,8 +314,24 @@
|
||||
.toggle-vis-btn {
|
||||
width: 28px !important;
|
||||
height: 28px !important;
|
||||
padding: 0 !important;
|
||||
font-size: 13px !important;
|
||||
color: #64748b;
|
||||
|
||||
/* Material reserves a 48x48 invisible touch-target + ripple around every
|
||||
mat-icon-button by default. Shrinking the button to 28px above doesn't
|
||||
shrink that touch-target/ripple with it, so — since the suffix
|
||||
container doesn't clip overflow — it visually spills out past the
|
||||
field's own border, making the icon look misplaced/half-outside the box. */
|
||||
::ng-deep .mat-mdc-button-touch-target {
|
||||
width: 28px !important;
|
||||
height: 28px !important;
|
||||
}
|
||||
|
||||
::ng-deep .mat-mdc-button-persistent-ripple,
|
||||
::ng-deep .mat-ripple {
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Verification Info Banner */
|
||||
@@ -2257,3 +2273,161 @@
|
||||
width: 95px;
|
||||
}
|
||||
}
|
||||
|
||||
.override-checkbox-wrapper {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.risk-auto-calc-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
margin-top: -8px;
|
||||
margin-bottom: 12px;
|
||||
border: 1px solid transparent;
|
||||
|
||||
.info-icon {
|
||||
font-size: 18px !important;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.info-text strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&--low {
|
||||
background: #ecfdf5;
|
||||
color: #065f46;
|
||||
border-color: #a7f3d0;
|
||||
|
||||
.info-icon {
|
||||
color: #10b981;
|
||||
}
|
||||
}
|
||||
|
||||
&--medium {
|
||||
background: #fffbeb;
|
||||
color: #92400e;
|
||||
border-color: #fde68a;
|
||||
|
||||
.info-icon {
|
||||
color: #f59e0b;
|
||||
}
|
||||
}
|
||||
|
||||
&--high {
|
||||
background: #fef2f2;
|
||||
color: #991b1b;
|
||||
border-color: #fecaca;
|
||||
|
||||
.info-icon {
|
||||
color: #ef4444;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.risk-rules-toggle-wrapper {
|
||||
margin-top: 4px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.rules-toggle-btn {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1e6bb8;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
transition: all 0.2s ease-in-out;
|
||||
background: #f1f5f9;
|
||||
|
||||
&:hover {
|
||||
background: #e2e8f0;
|
||||
color: #0f172a;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.risk-rules-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: 0 1px 3px rgb(0 0 0 / 5%);
|
||||
}
|
||||
|
||||
.risk-rules-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
|
||||
th {
|
||||
padding: 10px 12px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
border-bottom: 2px solid #cbd5e1;
|
||||
background: #f8fafc;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
color: #334155;
|
||||
vertical-align: top;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.rule-row {
|
||||
transition: background 0.15s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
}
|
||||
|
||||
.risk-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
text-align: center;
|
||||
width: 80px;
|
||||
|
||||
&--high {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
&--medium {
|
||||
background: #fffbeb;
|
||||
color: #d97706;
|
||||
border: 1px solid #fde68a;
|
||||
}
|
||||
|
||||
&--low {
|
||||
background: #ecfdf5;
|
||||
color: #16a34a;
|
||||
border: 1px solid #a7f3d0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,9 @@ 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';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-client-kyc-step',
|
||||
@@ -69,6 +71,15 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
@Input() entityName: string = '';
|
||||
/** Read-only mode — disables form editing until user clicks edit icon */
|
||||
@Input() readOnly = false;
|
||||
@Input() calculatedRisk: {
|
||||
category: number;
|
||||
reason: string;
|
||||
amlCheckStatus?: string;
|
||||
bannedEntityMatch?: string;
|
||||
willfulDefaulter?: string;
|
||||
ecgcSalFlag?: string;
|
||||
dormantFlag?: string;
|
||||
} | null = null;
|
||||
|
||||
today = new Date();
|
||||
kycId: string = '';
|
||||
@@ -169,6 +180,17 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
{ name: 'Birth Certificate', code: 'BIRTH_CERTIFICATE' },
|
||||
{ name: 'School ID Card', code: 'SCHOOL_ID_CARD' }
|
||||
];
|
||||
/** Generic RBI-prescribed KYC documents for legal entities (Master Direction on KYC,
|
||||
* Annexure I) — shown for non-individual clients before a specific constitution type is
|
||||
* picked. Individual OVDs (Aadhaar/Passport/DL/Voter ID/NREGA/NPR) don't apply to entities. */
|
||||
private nonIndividualDefaultDocTypes = [
|
||||
{ name: 'Certificate of Incorporation / Registration Certificate', code: 'CERT_INCORPORATION' },
|
||||
{ name: 'PAN Card of Entity', code: 'ENTITY_PAN' },
|
||||
{ name: 'Memorandum & Articles of Association / Partnership Deed / Trust Deed', code: 'CONSTITUTIVE_DOC' },
|
||||
{ name: 'Board Resolution / Authorization Letter', code: 'BOARD_RESOLUTION' },
|
||||
{ name: 'KYC of Authorized Signatory / Power of Attorney Holder', code: 'AUTH_SIGNATORY_KYC' },
|
||||
{ name: 'GST Registration Certificate', code: 'GST_CERTIFICATE' }
|
||||
];
|
||||
/** Constitution-based KYC document types for non-individual clients */
|
||||
private constitutionDocTypes: Record<string, { name: string; mandatory: boolean }[]> = {
|
||||
HUF: [
|
||||
@@ -249,6 +271,9 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
{ value: 3, label: 'High Risk', years: 2, color: 'red' }
|
||||
];
|
||||
|
||||
riskCalculationReason = '';
|
||||
showRiskRulesTable = false;
|
||||
|
||||
/** PAN verification */
|
||||
panVerifyStatus: 'NONE' | 'VERIFIED' | 'FAILED' = 'NONE';
|
||||
panVerifying = false;
|
||||
@@ -440,6 +465,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
kycDocIssueDate: [''],
|
||||
kycDocExpiryDate: [''],
|
||||
riskCategory: [1],
|
||||
allowRiskOverride: [false],
|
||||
allowAmlOverride: [false],
|
||||
remarks: [''],
|
||||
amlCheckStatus: [
|
||||
'CLEAR',
|
||||
@@ -487,6 +514,14 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
return docs.filter((d) => !ClientKycStepComponent.MINOR_ONLY_DOC_CODES.has((d.code || '').toUpperCase()));
|
||||
}
|
||||
|
||||
/** Individual-only OVDs vs. entity-only RBI docs — never both, and minor filtering only
|
||||
* ever applies to individuals. */
|
||||
private getDefaultDocTypes(): { name: string; code: string }[] {
|
||||
return this.isNonIndividual
|
||||
? this.nonIndividualDefaultDocTypes
|
||||
: this.filterDocTypesForMinor(this.defaultDocumentTypes);
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.seedDefaultDocTypes();
|
||||
this.loadExistingKycIfNeeded();
|
||||
@@ -494,20 +529,62 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
if (this.isNonIndividual && this.constitutionMeta) {
|
||||
this.updateDocTypesForConstitution();
|
||||
}
|
||||
|
||||
this.kycForm.get('allowRiskOverride')?.valueChanges.subscribe((allowed: boolean) => {
|
||||
const riskControl = this.kycForm.get('riskCategory');
|
||||
if (allowed) {
|
||||
if (!this.readOnly) {
|
||||
riskControl?.enable();
|
||||
}
|
||||
} else {
|
||||
riskControl?.disable();
|
||||
if (this.calculatedRisk) {
|
||||
riskControl?.setValue(this.calculatedRisk.category);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.kycForm.get('allowAmlOverride')?.valueChanges.subscribe((allowed: boolean) => {
|
||||
const amlControls = [
|
||||
this.kycForm.get('amlCheckStatus'),
|
||||
this.kycForm.get('bannedEntityMatch'),
|
||||
this.kycForm.get('willfulDefaulter'),
|
||||
this.kycForm.get('ecgcSalFlag'),
|
||||
this.kycForm.get('dormantFlag')
|
||||
];
|
||||
if (allowed) {
|
||||
if (!this.readOnly) {
|
||||
amlControls.forEach((ctrl) => ctrl?.enable());
|
||||
}
|
||||
} else {
|
||||
amlControls.forEach((ctrl) => ctrl?.disable());
|
||||
this.applyAutoCalculatedAmlFlags();
|
||||
}
|
||||
});
|
||||
|
||||
if (!this.kycForm.get('allowRiskOverride')?.value) {
|
||||
this.kycForm.get('riskCategory')?.disable();
|
||||
}
|
||||
|
||||
if (!this.kycForm.get('allowAmlOverride')?.value) {
|
||||
this.kycForm.get('amlCheckStatus')?.disable();
|
||||
this.kycForm.get('bannedEntityMatch')?.disable();
|
||||
this.kycForm.get('willfulDefaulter')?.disable();
|
||||
this.kycForm.get('ecgcSalFlag')?.disable();
|
||||
this.kycForm.get('dormantFlag')?.disable();
|
||||
}
|
||||
}
|
||||
|
||||
/** Seed the document-type picker from defaults, filtered by the current isMinor input.
|
||||
* Only seeds when the list is empty — the template API (loadKycTemplate) takes over once
|
||||
* a clientId is available. */
|
||||
/** Seed the document-type picker from defaults (entity RBI docs vs. individual OVDs,
|
||||
* filtered by the current isMinor input for individuals). Only seeds when the list is
|
||||
* empty — the template API (loadKycTemplate) takes over once a clientId is available. */
|
||||
private seedDefaultDocTypes(): void {
|
||||
if (this.kycDocumentTypes.length) return;
|
||||
this.kycDocumentTypes = this.filterDocTypesForMinor(
|
||||
this.defaultDocumentTypes.map((d) => ({
|
||||
name: d.name,
|
||||
code: d.code,
|
||||
mandatory: false
|
||||
}))
|
||||
);
|
||||
this.kycDocumentTypes = this.getDefaultDocTypes().map((d) => ({
|
||||
name: d.name,
|
||||
code: d.code,
|
||||
mandatory: false
|
||||
}));
|
||||
this.rebuildKycDocPicker();
|
||||
}
|
||||
|
||||
@@ -525,6 +602,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
this.kycForm.reset({
|
||||
hasPan: true,
|
||||
riskCategory: 1,
|
||||
allowRiskOverride: false,
|
||||
amlCheckStatus: 'CLEAR',
|
||||
bannedEntityMatch: 'NO',
|
||||
willfulDefaulter: 'NO',
|
||||
@@ -545,6 +623,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
this.kycDocMergedFile = null;
|
||||
this.kycDocMergedPreview = null;
|
||||
this.existingKycIdentifierId = null;
|
||||
this.riskCalculationReason = '';
|
||||
this.showRiskRulesTable = false;
|
||||
this.existingKycDocuments = [];
|
||||
this.photoSigTouched = false;
|
||||
this.panVerifyStatus = 'NONE';
|
||||
@@ -597,21 +677,70 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
this.kycForm.disable({ emitEvent: false });
|
||||
} else {
|
||||
this.kycForm.enable({ emitEvent: false });
|
||||
if (!this.kycForm.get('allowRiskOverride')?.value) {
|
||||
this.kycForm.get('riskCategory')?.disable({ emitEvent: false });
|
||||
}
|
||||
if (!this.kycForm.get('allowAmlOverride')?.value) {
|
||||
this.kycForm.get('amlCheckStatus')?.disable({ emitEvent: false });
|
||||
this.kycForm.get('bannedEntityMatch')?.disable({ emitEvent: false });
|
||||
this.kycForm.get('willfulDefaulter')?.disable({ emitEvent: false });
|
||||
this.kycForm.get('ecgcSalFlag')?.disable({ emitEvent: false });
|
||||
this.kycForm.get('dormantFlag')?.disable({ emitEvent: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (changes['calculatedRisk']) {
|
||||
const newRisk = changes['calculatedRisk'].currentValue;
|
||||
if (newRisk) {
|
||||
this.riskCalculationReason = newRisk.reason;
|
||||
const allowOverride = this.kycForm.get('allowRiskOverride')?.value;
|
||||
const riskControl = this.kycForm.get('riskCategory');
|
||||
if (!allowOverride && riskControl && riskControl.value !== newRisk.category) {
|
||||
riskControl.setValue(newRisk.category);
|
||||
riskControl.markAsDirty();
|
||||
}
|
||||
|
||||
const allowAmlOverride = this.kycForm.get('allowAmlOverride')?.value;
|
||||
if (!allowAmlOverride) {
|
||||
this.applyAutoCalculatedAmlFlags(newRisk);
|
||||
}
|
||||
} else {
|
||||
this.riskCalculationReason = '';
|
||||
}
|
||||
}
|
||||
if (changes['constitutionMeta'] && this.isNonIndividual) {
|
||||
this.updateDocTypesForConstitution();
|
||||
}
|
||||
if (changes['isMinor']) {
|
||||
// Legal form toggled on an already-initialized instance (route-reuse keeps this
|
||||
// component alive across create sessions) — the individual OVD list and the entity RBI
|
||||
// doc list are mutually exclusive, so re-seed from scratch rather than filtering in place.
|
||||
if (changes['isNonIndividual'] && !changes['isNonIndividual'].firstChange && !this.kycTemplateLoaded) {
|
||||
this.kycDocumentTypes = [];
|
||||
this.selectedKycDoc = null;
|
||||
this.kycForm.get('kycDocumentName')?.setValue('');
|
||||
this.seedDefaultDocTypes();
|
||||
if (this.isNonIndividual && this.constitutionMeta) {
|
||||
this.updateDocTypesForConstitution();
|
||||
}
|
||||
}
|
||||
if (changes['isMinor'] && !this.isNonIndividual) {
|
||||
if (this.isMinor) {
|
||||
this.kycForm.get('hasPan')?.setValue(false);
|
||||
this.kycForm.get('panNumber')?.setValue('');
|
||||
}
|
||||
const filtered = this.filterDocTypesForMinor(this.kycDocumentTypes);
|
||||
if (filtered.length !== this.kycDocumentTypes.length) {
|
||||
this.kycDocumentTypes = filtered;
|
||||
// Re-derive from the full default list, not by re-filtering the already-narrowed
|
||||
// `kycDocumentTypes` — filterDocTypesForMinor() only ever REMOVES entries, so once the
|
||||
// initial (pre-DOB, isMinor=false) seed has already stripped out Birth Certificate /
|
||||
// School ID Card, re-filtering that same shortened list can never bring them back once
|
||||
// the client turns out to be a minor. Once the backend KYC template has loaded
|
||||
// (kycTemplateLoaded), that list is already minor-aware and authoritative — leave it alone.
|
||||
if (!this.kycTemplateLoaded) {
|
||||
const rebuilt = this.filterDocTypesForMinor(
|
||||
this.defaultDocumentTypes.map((d) => ({ name: d.name, code: d.code, mandatory: false }))
|
||||
);
|
||||
this.kycDocumentTypes = rebuilt;
|
||||
const currentName = this.kycForm.get('kycDocumentName')?.value;
|
||||
if (currentName && !filtered.some((d) => d.name === currentName)) {
|
||||
if (currentName && !rebuilt.some((d) => d.name === currentName)) {
|
||||
this.kycForm.get('kycDocumentName')?.setValue('');
|
||||
}
|
||||
this.rebuildKycDocPicker();
|
||||
@@ -687,30 +816,17 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
this.kycDocMergedPreview = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the saved KYC ID-proof document in a new browser tab.
|
||||
*
|
||||
* Same flow as the address-step variant: images use the already-loaded
|
||||
* base64 data URL; PDFs (or anything without a cached preview) are fetched
|
||||
* fresh and rendered via a temporary blob URL.
|
||||
*/
|
||||
/** Open the saved KYC ID-proof document in an in-app preview dialog. */
|
||||
viewExistingKycDoc(): void {
|
||||
if (!this.existingKycIdentifierId || this.existingKycDocuments.length === 0) {
|
||||
return;
|
||||
}
|
||||
const doc = this.existingKycDocuments[0];
|
||||
if (doc.previewUrl) {
|
||||
window.open(doc.previewUrl, '_blank', 'noopener');
|
||||
return;
|
||||
}
|
||||
this.clientsService.downloadClientIdentificationDocument(this.existingKycIdentifierId, doc.id).subscribe({
|
||||
next: (blob: Blob) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const w = window.open(url, '_blank', 'noopener');
|
||||
if (w) {
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
}
|
||||
},
|
||||
next: (blob: Blob) =>
|
||||
this.dialog.open(DocumentPreviewDialogComponent, {
|
||||
data: { blob, title: doc.fileName || doc.name, fileName: doc.fileName || doc.name }
|
||||
}),
|
||||
error: () => {
|
||||
/* silent — UI still shows the saved filename */
|
||||
}
|
||||
@@ -801,6 +917,14 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
kycDocExpiryDate: parseDate(data.kycDocExpiryDate),
|
||||
// Risk & Remarks
|
||||
riskCategory: data.riskCategory || 1,
|
||||
allowRiskOverride: this.calculatedRisk ? this.calculatedRisk.category !== (data.riskCategory || 1) : false,
|
||||
allowAmlOverride: this.calculatedRisk
|
||||
? this.calculatedRisk.amlCheckStatus !== (data.amlCheckStatus || 'CLEAR') ||
|
||||
this.calculatedRisk.bannedEntityMatch !== (data.bannedEntityMatch || 'NO') ||
|
||||
this.calculatedRisk.willfulDefaulter !== (data.willfulDefaulter || 'NO') ||
|
||||
this.calculatedRisk.ecgcSalFlag !== (data.ecgcSalFlag || 'NO') ||
|
||||
this.calculatedRisk.dormantFlag !== (data.dormantFlag || 'ACTIVE')
|
||||
: false,
|
||||
remarks: data.remarks || '',
|
||||
hasPan: !!data.panNumber,
|
||||
// AML & Compliance
|
||||
@@ -886,12 +1010,17 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
const n = i?.documentType?.name?.toLowerCase();
|
||||
return !!n && this.kycDocProofs.some((p) => p.name?.toLowerCase() === n);
|
||||
};
|
||||
// Re-uploading a KYC document creates a new identifier row rather than updating the
|
||||
// old one in place (same pattern as OVD identifiers), so sort newest-first before
|
||||
// picking — otherwise a repeat upload of the same document type would keep showing
|
||||
// the ORIGINAL file forever, since .find() would always hit the oldest matching row.
|
||||
const sorted = [...identifiers].sort((a: any, b: any) => (b?.id ?? 0) - (a?.id ?? 0));
|
||||
// Pick by document-type name match first; otherwise the first identifier whose
|
||||
// type lives in the KYC proof list (avoid bleeding the address proof in here).
|
||||
const match = kycDocumentName
|
||||
? identifiers.find((i: any) => i?.documentType?.name?.toLowerCase() === kycDocumentName.toLowerCase())
|
||||
? sorted.find((i: any) => i?.documentType?.name?.toLowerCase() === kycDocumentName.toLowerCase())
|
||||
: null;
|
||||
const identifier = match || identifiers.find(isKycType);
|
||||
const identifier = match || sorted.find(isKycType);
|
||||
if (!identifier?.id) return;
|
||||
|
||||
this.existingKycIdentifierId = identifier.id.toString();
|
||||
@@ -1019,13 +1148,11 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
error: () => {
|
||||
// Fallback to defaults if template API unavailable
|
||||
this.kycTemplateLoaded = true;
|
||||
this.kycDocumentTypes = this.filterDocTypesForMinor(
|
||||
this.defaultDocumentTypes.map((d) => ({
|
||||
name: d.name,
|
||||
code: d.code,
|
||||
mandatory: false
|
||||
}))
|
||||
);
|
||||
this.kycDocumentTypes = this.getDefaultDocTypes().map((d) => ({
|
||||
name: d.name,
|
||||
code: d.code,
|
||||
mandatory: false
|
||||
}));
|
||||
this.rebuildKycDocPicker();
|
||||
}
|
||||
});
|
||||
@@ -1585,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, {
|
||||
@@ -1608,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;
|
||||
@@ -1668,7 +1790,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
}
|
||||
|
||||
get kycData(): any {
|
||||
const formVal = this.readOnly ? this.kycForm.getRawValue() : this.kycForm.value;
|
||||
const formVal = this.kycForm.getRawValue();
|
||||
const aadhaarRaw = formVal.aadhaarNumber || '';
|
||||
const aadhaarMasked = aadhaarRaw.length === 12 ? 'XXXXXXXX' + aadhaarRaw.slice(-4) : null;
|
||||
const dateFormat = this.settingsService.dateFormat;
|
||||
@@ -1737,6 +1859,21 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
v.form15Applicable
|
||||
);
|
||||
}
|
||||
|
||||
private applyAutoCalculatedAmlFlags(risk = this.calculatedRisk): void {
|
||||
if (!risk) return;
|
||||
this.kycForm.get('amlCheckStatus')?.setValue(risk.amlCheckStatus || 'CLEAR');
|
||||
this.kycForm.get('bannedEntityMatch')?.setValue(risk.bannedEntityMatch || 'NO');
|
||||
this.kycForm.get('willfulDefaulter')?.setValue(risk.willfulDefaulter || 'NO');
|
||||
this.kycForm.get('ecgcSalFlag')?.setValue(risk.ecgcSalFlag || 'NO');
|
||||
this.kycForm.get('dormantFlag')?.setValue(risk.dormantFlag || 'ACTIVE');
|
||||
|
||||
this.kycForm.get('amlCheckStatus')?.markAsDirty();
|
||||
this.kycForm.get('bannedEntityMatch')?.markAsDirty();
|
||||
this.kycForm.get('willfulDefaulter')?.markAsDirty();
|
||||
this.kycForm.get('ecgcSalFlag')?.markAsDirty();
|
||||
this.kycForm.get('dormantFlag')?.markAsDirty();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+1
-1
@@ -195,7 +195,7 @@
|
||||
client.isMinor ? 'preview-badge preview-badge--danger' : 'preview-badge preview-badge--verified'
|
||||
"
|
||||
>
|
||||
{{ client.isMinor ? 'Yes (Age < 10)' : 'No' }}
|
||||
{{ client.isMinor ? 'Yes (Age < 18)' : 'No' }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -410,6 +410,9 @@
|
||||
<button mat-menu-item (click)="doAction('Add Charge')" *mifosxHasPermission="'CREATE_CLIENTCHARGE'">
|
||||
{{ 'labels.buttons.Add Charge' | translate }}
|
||||
</button>
|
||||
<button mat-menu-item (click)="doAction('Welcome Kit')" *mifosxHasPermission="'READ_WELCOMEKIT'">
|
||||
Welcome Kit
|
||||
</button>
|
||||
<button mat-menu-item (click)="doAction('Create Collateral')">
|
||||
{{ 'labels.buttons.Create Collateral' | translate }}
|
||||
</button>
|
||||
|
||||
@@ -22,6 +22,7 @@ import { DrawSignatureDialogComponent } from './custom-dialogs/draw-signature-di
|
||||
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';
|
||||
@@ -255,6 +256,9 @@ export class ClientsViewComponent implements OnInit {
|
||||
case 'Delete Image':
|
||||
this.deleteProfileImage();
|
||||
break;
|
||||
case 'Welcome Kit':
|
||||
this.dispatchWelcomeKit();
|
||||
break;
|
||||
case 'Create Standing Instructions':
|
||||
const createStandingInstructionsQueryParams: any = {
|
||||
officeId: this.clientViewData.officeId,
|
||||
@@ -455,4 +459,20 @@ export class ClientsViewComponent implements OnInit {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the Welcome Kit Dispatch dialog.
|
||||
*/
|
||||
private dispatchWelcomeKit() {
|
||||
const welcomeKitDialogRef = this.dialog.open(WelcomeKitDialogComponent, {
|
||||
data: { clientId: this.clientViewData.id },
|
||||
width: '600px'
|
||||
});
|
||||
welcomeKitDialogRef.afterClosed().subscribe((response: any) => {
|
||||
if (response) {
|
||||
// the form was successfully submitted
|
||||
this.reload();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+12
-3
@@ -347,9 +347,18 @@
|
||||
<i class="fa fa-calendar"></i> Issue / statement date
|
||||
<span class="kyc-opt">(not older than 3 months)</span>
|
||||
</label>
|
||||
<div class="kyc-input-wrap kyc-input-wrap--date">
|
||||
<i class="fa fa-calendar-o kyc-input-wrap__icon"></i>
|
||||
<input type="date" class="kyc-input kyc-input--date" formControlName="issueDate" />
|
||||
<div class="kyc-input-wrap kyc-input-wrap--date" (click)="uploadIssueDatePicker.open()">
|
||||
<input
|
||||
class="kyc-input kyc-input--date"
|
||||
[matDatepicker]="uploadIssueDatePicker"
|
||||
formControlName="issueDate"
|
||||
readonly
|
||||
/>
|
||||
<mat-datepicker-toggle
|
||||
class="kyc-input-wrap__calendar-toggle"
|
||||
[for]="uploadIssueDatePicker"
|
||||
></mat-datepicker-toggle>
|
||||
<mat-datepicker #uploadIssueDatePicker></mat-datepicker>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
+18
-28
@@ -388,42 +388,32 @@ $red-900: #993c1d;
|
||||
padding-left: 38px;
|
||||
}
|
||||
|
||||
/* Date input — match styling of other kyc inputs */
|
||||
/* Date input — opens the Material calendar picker; styled to match other kyc inputs */
|
||||
.kyc-input--date {
|
||||
font-family: inherit;
|
||||
color: $gray-900;
|
||||
cursor: pointer;
|
||||
min-height: 40px;
|
||||
padding-right: 14px !important;
|
||||
padding-left: 12px !important;
|
||||
padding-right: 40px !important;
|
||||
}
|
||||
|
||||
&::-webkit-calendar-picker-indicator {
|
||||
opacity: 0.55;
|
||||
cursor: pointer;
|
||||
filter: grayscale(1);
|
||||
transition: opacity 0.15s;
|
||||
.kyc-input-wrap--date .kyc-input-wrap__calendar-toggle {
|
||||
position: absolute;
|
||||
right: 2px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: $gray-500;
|
||||
|
||||
::ng-deep .mat-mdc-icon-button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
&:hover::-webkit-calendar-picker-indicator {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
&::-webkit-datetime-edit {
|
||||
color: $gray-900;
|
||||
padding-top: 1px;
|
||||
}
|
||||
|
||||
&::-webkit-datetime-edit-fields-wrapper {
|
||||
color: $gray-900;
|
||||
}
|
||||
|
||||
&::-webkit-datetime-edit-text {
|
||||
color: $gray-500;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
&:invalid::-webkit-datetime-edit,
|
||||
&:not(:focus):placeholder-shown::-webkit-datetime-edit {
|
||||
color: $gray-500;
|
||||
::ng-deep .mat-mdc-icon-button svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
<!--
|
||||
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/.
|
||||
-->
|
||||
<h2 mat-dialog-title>Welcome Kit Dispatch</h2>
|
||||
|
||||
<mat-dialog-content class="mat-typography">
|
||||
<div fxLayout="row" fxLayoutAlign="center center" style="padding: 2rem" *ngIf="isLoading">
|
||||
<mat-spinner diameter="40"></mat-spinner>
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="!isLoading">
|
||||
<!-- Tracking view if dispatches already exist -->
|
||||
<div
|
||||
class="dispatch-tracking-panel"
|
||||
*ngFor="let dispatch of activeDispatches; let i = index"
|
||||
style="margin-bottom: 16px; padding: 16px; border: 1px solid #e0e0e0; border-radius: 4px; background: #fafafa"
|
||||
>
|
||||
<div fxLayout="row" fxLayoutAlign="space-between start" style="margin-bottom: 8px">
|
||||
<h3 style="margin: 0">{{ i === 0 ? 'Current' : 'Previous' }} Welcome Kit Status</h3>
|
||||
<button mat-stroked-button color="primary" type="button" (click)="downloadPdf(dispatch)">
|
||||
Download Details
|
||||
</button>
|
||||
</div>
|
||||
<p style="margin: 4px 0"><strong>Delivery Address:</strong> {{ getDisplayAddress(dispatch) }}</p>
|
||||
<p style="margin: 4px 0"><strong>Tracking ID:</strong> {{ dispatch.trackingId }}</p>
|
||||
<p style="margin: 4px 0">
|
||||
<strong>Delivery Mode:</strong> {{ dispatch.deliveryMode }}
|
||||
<ng-container *ngIf="dispatch.deliveryMode === 'OTHER'">({{ dispatch.deliveryModeOtherText }})</ng-container>
|
||||
</p>
|
||||
<p style="margin: 4px 0"><strong>Dispatched On:</strong> {{ dispatch.dispatchedOnDate | date: 'mediumDate' }}</p>
|
||||
<p style="margin: 4px 0">
|
||||
<strong>Status:</strong>
|
||||
<span class="status-badge status-{{ dispatch.status | lowercase }}">{{ dispatch.status }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<hr *ngIf="activeDispatches.length > 0 && welcomeKitsData?.canDispatchNew" />
|
||||
|
||||
<!-- Create form if they can dispatch a new one -->
|
||||
<div class="dispatch-create-form" [formGroup]="dispatchForm" *ngIf="welcomeKitsData?.canDispatchNew">
|
||||
<h3>Create New Dispatch</h3>
|
||||
<div fxLayout="column" fxLayoutGap="16px">
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Delivery Address</mat-label>
|
||||
<textarea matInput formControlName="deliveryAddress" rows="3" required></textarea>
|
||||
<mat-error *ngIf="dispatchForm.get('deliveryAddress')?.hasError('required')">
|
||||
Delivery Address is required
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Delivery Mode</mat-label>
|
||||
<mat-select formControlName="deliveryMode" required>
|
||||
<mat-option *ngFor="let mode of deliveryModes" [value]="mode">
|
||||
{{ mode }}
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
<mat-error *ngIf="dispatchForm.get('deliveryMode')?.hasError('required')">
|
||||
Delivery Mode is required
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field
|
||||
appearance="outline"
|
||||
class="full-width"
|
||||
*ngIf="dispatchForm.get('deliveryMode')?.value === 'OTHER'"
|
||||
>
|
||||
<mat-label>Specify Other Mode</mat-label>
|
||||
<input matInput formControlName="deliveryModeOtherText" required />
|
||||
<mat-error *ngIf="dispatchForm.get('deliveryModeOtherText')?.hasError('required')">
|
||||
Please specify the delivery mode
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button mat-dialog-close>Close</button>
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
*ngIf="welcomeKitsData?.canDispatchNew && !isLoading"
|
||||
(click)="submit()"
|
||||
[disabled]="dispatchForm.invalid || isLoading"
|
||||
>
|
||||
Dispatch
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
.dispatch-tracking-panel {
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 12px;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: white;
|
||||
|
||||
&.status-dispatched {
|
||||
background-color: #1976d2;
|
||||
}
|
||||
|
||||
&.status-in_transit {
|
||||
background-color: #f57c00;
|
||||
}
|
||||
|
||||
&.status-delivered {
|
||||
background-color: #388e3c;
|
||||
}
|
||||
|
||||
&.status-returned {
|
||||
background-color: #d32f2f;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dispatch-create-form {
|
||||
h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 12px;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
+347
@@ -0,0 +1,347 @@
|
||||
/**
|
||||
* 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 { FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import {
|
||||
MAT_DIALOG_DATA,
|
||||
MatDialogRef,
|
||||
MatDialogTitle,
|
||||
MatDialogContent,
|
||||
MatDialogActions,
|
||||
MatDialogClose
|
||||
} from '@angular/material/dialog';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { ClientsService } from '../../../clients.service';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { jsPDF } from 'jspdf';
|
||||
import autoTable from 'jspdf-autotable';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-welcome-kit-dialog',
|
||||
templateUrl: './welcome-kit-dialog.component.html',
|
||||
styleUrls: ['./welcome-kit-dialog.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
MatDialogTitle,
|
||||
MatDialogContent,
|
||||
MatDialogActions,
|
||||
MatDialogClose,
|
||||
MatProgressSpinnerModule
|
||||
],
|
||||
providers: [DatePipe]
|
||||
})
|
||||
export class WelcomeKitDialogComponent implements OnInit {
|
||||
private dialogRef = inject(MatDialogRef<WelcomeKitDialogComponent>);
|
||||
data: { clientId: string } = inject(MAT_DIALOG_DATA);
|
||||
private clientsService = inject(ClientsService);
|
||||
private formBuilder = inject(FormBuilder);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
private datePipe = inject(DatePipe);
|
||||
|
||||
isLoading = true;
|
||||
welcomeKitsData: any = null;
|
||||
dispatchForm: FormGroup = this.formBuilder.group({
|
||||
deliveryAddress: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
deliveryMode: [
|
||||
'POSTMAN',
|
||||
Validators.required
|
||||
],
|
||||
deliveryModeOtherText: ['']
|
||||
});
|
||||
deliveryModes = [
|
||||
'POSTMAN',
|
||||
'COURIER',
|
||||
'OTHER'
|
||||
];
|
||||
|
||||
constructor() {
|
||||
this.dispatchForm.get('deliveryMode')?.valueChanges.subscribe((mode) => {
|
||||
const otherControl = this.dispatchForm.get('deliveryModeOtherText');
|
||||
if (mode === 'OTHER') {
|
||||
otherControl?.setValidators([Validators.required]);
|
||||
} else {
|
||||
otherControl?.clearValidators();
|
||||
}
|
||||
otherControl?.updateValueAndValidity();
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.fetchData();
|
||||
}
|
||||
|
||||
fetchData() {
|
||||
this.isLoading = true;
|
||||
this.clientsService.getWelcomeKits(this.data.clientId).subscribe({
|
||||
next: (response: any) => {
|
||||
this.welcomeKitsData = response;
|
||||
if (this.welcomeKitsData && this.welcomeKitsData.canDispatchNew) {
|
||||
this.fetchPrimaryAddress();
|
||||
} else {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
this.isLoading = false;
|
||||
this.snackBar.open('Failed to load welcome kits data', 'Close', { duration: 3000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fetchPrimaryAddress() {
|
||||
this.clientsService.getClientAddressData(this.data.clientId).subscribe({
|
||||
next: (addresses: any) => {
|
||||
this.isLoading = false;
|
||||
if (addresses && addresses.length > 0) {
|
||||
// Attempt to build a readable address from the first address returned
|
||||
const primary = addresses[0];
|
||||
const parts = [];
|
||||
if (primary.addressLine1) parts.push(primary.addressLine1);
|
||||
if (primary.addressLine2) parts.push(primary.addressLine2);
|
||||
if (primary.city) parts.push(primary.city);
|
||||
if (primary.stateName) parts.push(primary.stateName);
|
||||
if (primary.postalCode) parts.push(primary.postalCode);
|
||||
if (primary.countryName) parts.push(primary.countryName);
|
||||
|
||||
const clientNameStr = this.welcomeKitsData?.clientName ? this.welcomeKitsData.clientName + ' — ' : '';
|
||||
this.dispatchForm.patchValue({
|
||||
deliveryAddress: clientNameStr + parts.join(', ')
|
||||
});
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
this.isLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get activeDispatches() {
|
||||
if (this.welcomeKitsData && this.welcomeKitsData.dispatches && this.welcomeKitsData.dispatches.length > 0) {
|
||||
return this.welcomeKitsData.dispatches;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
submit() {
|
||||
if (this.dispatchForm.invalid) {
|
||||
this.dispatchForm.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
this.isLoading = true;
|
||||
const formValue = this.dispatchForm.value;
|
||||
|
||||
const today = new Date();
|
||||
const formattedDate = this.datePipe.transform(today, 'dd MMMM yyyy');
|
||||
|
||||
const payload = {
|
||||
deliveryAddress: formValue.deliveryAddress,
|
||||
deliveryMode: formValue.deliveryMode,
|
||||
deliveryModeOtherText: formValue.deliveryMode === 'OTHER' ? formValue.deliveryModeOtherText : null,
|
||||
dispatchedOnDate: formattedDate,
|
||||
locale: 'en',
|
||||
dateFormat: 'dd MMMM yyyy'
|
||||
};
|
||||
|
||||
this.clientsService.dispatchWelcomeKit(this.data.clientId, payload).subscribe({
|
||||
next: () => {
|
||||
this.snackBar.open('Welcome Kit Dispatched successfully', 'Close', { duration: 3000 });
|
||||
this.fetchData();
|
||||
},
|
||||
error: () => {
|
||||
this.isLoading = false;
|
||||
this.snackBar.open('Failed to dispatch welcome kit', 'Close', { duration: 3000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getDisplayAddress(dispatch: any): string {
|
||||
const address = dispatch.deliveryAddress || '';
|
||||
const name = this.welcomeKitsData?.clientName || '';
|
||||
if (name && !address.startsWith(name)) {
|
||||
return `${name} — ${address}`;
|
||||
}
|
||||
return address;
|
||||
}
|
||||
|
||||
downloadPdf(dispatch: any) {
|
||||
const doc = new jsPDF();
|
||||
const clientName = this.welcomeKitsData?.clientName || dispatch.clientName || 'N/A';
|
||||
const address = this.getDisplayAddress(dispatch);
|
||||
const mode =
|
||||
dispatch.deliveryMode === 'OTHER'
|
||||
? `${dispatch.deliveryMode} (${dispatch.deliveryModeOtherText || ''})`
|
||||
: dispatch.deliveryMode;
|
||||
const trackingId = dispatch.trackingId || 'N/A';
|
||||
const status = dispatch.status || 'N/A';
|
||||
const dispatchedOn = this.datePipe.transform(dispatch.dispatchedOnDate, 'mediumDate') || 'N/A';
|
||||
|
||||
const primaryColor: [number, number, number] = [
|
||||
31,
|
||||
73,
|
||||
125
|
||||
]; // Navy Blue
|
||||
const grayColor: [number, number, number] = [
|
||||
100,
|
||||
100,
|
||||
100
|
||||
];
|
||||
const lightGray: [number, number, number] = [
|
||||
230,
|
||||
230,
|
||||
230
|
||||
];
|
||||
const pageWidth = doc.internal.pageSize.getWidth();
|
||||
const pageHeight = doc.internal.pageSize.getHeight();
|
||||
|
||||
// --- 1. Header Banner ---
|
||||
doc.setFillColor(...primaryColor);
|
||||
doc.rect(0, 0, pageWidth, 40, 'F');
|
||||
|
||||
// Decorative circles
|
||||
doc.setFillColor(255, 255, 255);
|
||||
doc.circle(pageWidth - 20, 15, 3, 'F');
|
||||
doc.circle(pageWidth - 30, 25, 2, 'F');
|
||||
doc.circle(pageWidth - 10, 30, 1.5, 'F');
|
||||
|
||||
doc.setFontSize(28);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(255, 255, 255);
|
||||
doc.text('CBSNexora', 20, 22);
|
||||
|
||||
doc.setFontSize(11);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text('Core Banking Solution', 22, 30);
|
||||
|
||||
// --- 2. Title ---
|
||||
let currentY = 55;
|
||||
doc.setFontSize(16);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(...primaryColor);
|
||||
doc.text('Welcome Kit Dispatch Confirmation', pageWidth / 2, currentY, { align: 'center' });
|
||||
currentY += 12;
|
||||
|
||||
// --- 3. Date Line ---
|
||||
doc.setFontSize(11);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(0, 0, 0);
|
||||
const today = this.datePipe.transform(new Date(), 'longDate');
|
||||
doc.text(`Date: ${today}`, pageWidth - 20, currentY, { align: 'right' });
|
||||
currentY += 15;
|
||||
|
||||
// --- 4. Greeting ---
|
||||
doc.text(`Dear ${clientName},`, 20, currentY);
|
||||
currentY += 10;
|
||||
|
||||
// --- 5. Opening welcome paragraph ---
|
||||
const p1 =
|
||||
'Welcome to the CBSNexora family! We are delighted to have you on board as a valued client. As part of our onboarding process, we have dispatched your Welcome Kit to your registered address, which includes your account documentation and essential banking materials to help you get started with us.';
|
||||
const splitP1 = doc.splitTextToSize(p1, pageWidth - 40);
|
||||
doc.text(splitP1, 20, currentY);
|
||||
currentY += splitP1.length * 6 + 8;
|
||||
|
||||
// --- 6. Short paragraph about the company ---
|
||||
const p2 =
|
||||
'CBSNexora is committed to providing secure, reliable, and customer-first banking services. Our team is always available to assist you with any banking needs, from account management to digital services, ensuring a seamless banking experience.';
|
||||
const splitP2 = doc.splitTextToSize(p2, pageWidth - 40);
|
||||
doc.text(splitP2, 20, currentY);
|
||||
currentY += splitP2.length * 6 + 8;
|
||||
|
||||
// --- 7. Transition line before details ---
|
||||
doc.text('Please find your Welcome Kit dispatch details below for your reference:', 20, currentY);
|
||||
currentY += 8;
|
||||
|
||||
// --- 8. Details Section ---
|
||||
autoTable(doc, {
|
||||
startY: currentY,
|
||||
theme: 'plain',
|
||||
margin: { left: 20, right: 20 },
|
||||
styles: { fontSize: 11, cellPadding: 6, textColor: [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
] },
|
||||
columnStyles: {
|
||||
0: { fontStyle: 'bold', cellWidth: 50 },
|
||||
1: { cellWidth: 'auto' }
|
||||
},
|
||||
didDrawCell: (data) => {
|
||||
if (data.row.index >= 0) {
|
||||
doc.setDrawColor(...lightGray);
|
||||
doc.setLineWidth(0.2);
|
||||
doc.line(
|
||||
data.cell.x,
|
||||
data.cell.y + data.cell.height,
|
||||
data.cell.x + data.cell.width,
|
||||
data.cell.y + data.cell.height
|
||||
);
|
||||
}
|
||||
},
|
||||
body: [
|
||||
[
|
||||
'Delivery Address',
|
||||
address
|
||||
],
|
||||
[
|
||||
'Delivery Mode',
|
||||
mode
|
||||
],
|
||||
[
|
||||
'Tracking ID',
|
||||
trackingId
|
||||
],
|
||||
[
|
||||
'Status',
|
||||
status
|
||||
],
|
||||
[
|
||||
'Dispatched On',
|
||||
dispatchedOn
|
||||
]
|
||||
]
|
||||
});
|
||||
|
||||
// --- 9. Closing paragraph ---
|
||||
currentY = (doc as any).lastAutoTable.finalY + 12;
|
||||
const p3 =
|
||||
'Should you have any questions about your Welcome Kit or need any assistance, please do not hesitate to reach out to our support team. We look forward to serving you and building a long-term relationship with you.';
|
||||
const splitP3 = doc.splitTextToSize(p3, pageWidth - 40);
|
||||
doc.text(splitP3, 20, currentY);
|
||||
currentY += splitP3.length * 6 + 12;
|
||||
|
||||
// --- 10. Sign-off ---
|
||||
doc.text('Warm regards,', 20, currentY);
|
||||
currentY += 7;
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('CBSNexora Operations Team', 20, currentY);
|
||||
|
||||
// --- 11. Footer ---
|
||||
// Ensure footer doesn't float too high on short pages, but pushes down if content goes long
|
||||
let footerY = currentY + 30;
|
||||
if (footerY < pageHeight - 30) {
|
||||
footerY = pageHeight - 30;
|
||||
}
|
||||
|
||||
doc.setDrawColor(...primaryColor);
|
||||
doc.setLineWidth(0.3);
|
||||
doc.line(20, footerY - 5, pageWidth - 20, footerY - 5);
|
||||
|
||||
doc.setFontSize(9);
|
||||
doc.setFont('helvetica', 'italic');
|
||||
doc.setTextColor(...grayColor);
|
||||
const timestamp = this.datePipe.transform(new Date(), 'medium');
|
||||
doc.text(`This is a system-generated document. Generated on: ${timestamp}`, 20, footerY + 2);
|
||||
|
||||
doc.save(`WelcomeKit_Dispatch_${trackingId !== 'N/A' ? trackingId : new Date().getTime()}.pdf`);
|
||||
}
|
||||
}
|
||||
@@ -105,7 +105,7 @@
|
||||
[class.pd-verify-badge--verified]="!clientViewData.isMinor"
|
||||
[class.pd-verify-badge--pending]="clientViewData.isMinor"
|
||||
>
|
||||
{{ clientViewData.isMinor ? 'Yes (Age < 10)' : 'No' }}
|
||||
{{ clientViewData.isMinor ? 'Yes (Age < 18)' : 'No' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -98,12 +98,10 @@
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Account no Column -->
|
||||
<!-- Client ID Column -->
|
||||
<ng-container matColumnDef="accountNumber">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.Account No' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let row" class="cell-mono">
|
||||
<mifosx-account-number display="left" accountNo="{{ row.accountNumber }}"></mifosx-account-number>
|
||||
</td>
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Client ID</th>
|
||||
<td mat-cell *matCellDef="let row" class="cell-mono">{{ row.id }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- CIF Number Column -->
|
||||
|
||||
@@ -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) {
|
||||
@@ -82,6 +86,43 @@ export class ClientsService {
|
||||
return this.http.delete(`/clients/${clientId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Maker-checker for Client edits — stages the change in m_client_pending_edit instead of
|
||||
* applying it immediately; a different user must call approveClientEdit() before it takes
|
||||
* effect. See ClientEditApiResource on the backend.
|
||||
*/
|
||||
submitClientEdit(clientId: string, client: any) {
|
||||
return this.http.post(`/clients/${clientId}/edit`, client);
|
||||
}
|
||||
|
||||
getPendingClientEdit(clientId: string) {
|
||||
return this.http.get(`/clients/${clientId}/edit/pending`);
|
||||
}
|
||||
|
||||
getAllPendingClientEdits(
|
||||
status?: string,
|
||||
clientName?: string,
|
||||
fromDate?: string,
|
||||
toDate?: string,
|
||||
offset = 0,
|
||||
limit = 20
|
||||
): Observable<any> {
|
||||
let params = new HttpParams().set('offset', offset).set('limit', limit);
|
||||
if (status) params = params.set('status', status);
|
||||
if (clientName) params = params.set('clientName', clientName);
|
||||
if (fromDate) params = params.set('fromDate', fromDate);
|
||||
if (toDate) params = params.set('toDate', toDate);
|
||||
return this.http.get(`/clients/edit/pending`, { params });
|
||||
}
|
||||
|
||||
approveClientEdit(clientId: string) {
|
||||
return this.http.post(`/clients/${clientId}/edit/approve`, {});
|
||||
}
|
||||
|
||||
rejectClientEdit(clientId: string, rejectReason?: string) {
|
||||
return this.http.post(`/clients/${clientId}/edit/reject`, { rejectReason: rejectReason || '' });
|
||||
}
|
||||
|
||||
getClientDataAndTemplate(clientId: string) {
|
||||
const httpParams = new HttpParams().set('template', 'true').set('staffInSelectedOfficeOnly', 'true');
|
||||
return this.http.get(`/clients/${clientId}`, { params: httpParams });
|
||||
@@ -116,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) {
|
||||
@@ -661,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) {
|
||||
@@ -690,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) } : {}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -727,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) } : {}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -739,6 +791,10 @@ export class ClientsService {
|
||||
return this.http.get(`/clients/${clientId}/kyc/template`);
|
||||
}
|
||||
|
||||
getClientRiskRules(): Observable<any[]> {
|
||||
return this.http.get<any[]>('/kyc/risk-rules');
|
||||
}
|
||||
|
||||
/** Fetch code values by code ID */
|
||||
getCodeValuesById(codeId: number): Observable<any[]> {
|
||||
return this.http.get<any[]>(`/codes/${codeId}/codevalues`).pipe(catchError(() => of([])));
|
||||
@@ -894,4 +950,12 @@ export class ClientsService {
|
||||
submitClientForm60(clientId: string, data: any): Observable<any> {
|
||||
return this.http.post(`/clients/${clientId}/form60`, data);
|
||||
}
|
||||
|
||||
getWelcomeKits(clientId: string | number): Observable<any> {
|
||||
return this.http.get(`/clients/${clientId}/welcomekit`);
|
||||
}
|
||||
|
||||
dispatchWelcomeKit(clientId: string | number, data: any): Observable<any> {
|
||||
return this.http.post(`/clients/${clientId}/welcomekit`, data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
[constitutionMeta]="clientGeneralStep?.selectedConstitution"
|
||||
[entityName]="clientGeneralStep?.createClientForm?.get('fullname')?.value || ''"
|
||||
[readOnly]="isTabReadOnly(1)"
|
||||
[calculatedRisk]="clientGeneralStep?.calculatedRisk"
|
||||
></mifosx-client-kyc-step>
|
||||
</div>
|
||||
|
||||
@@ -148,6 +149,7 @@
|
||||
<mifosx-client-india-address-step
|
||||
#clientIndiaAddress
|
||||
[isMinor]="clientGeneralStep?.isMinor || false"
|
||||
[isNonIndividual]="legalFormType === 2"
|
||||
[readOnly]="isTabReadOnly(addressTabIdx)"
|
||||
></mifosx-client-india-address-step>
|
||||
</div>
|
||||
|
||||
@@ -240,8 +240,8 @@ export class CreateClientComponent {
|
||||
return this.isAddressStepValid();
|
||||
}
|
||||
if (idx === 3) {
|
||||
// Family tab — relations are optional, always allow.
|
||||
return true;
|
||||
// Family tab — Mother is required, and either Father or a Caretaker.
|
||||
return !this.clientFamilyMembersStep || this.clientFamilyMembersStep.familyRelationshipRulesMet;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
@@ -275,6 +275,11 @@ export class CreateClientComponent {
|
||||
return `Please fill/correct: ${labels.join(', ')}.`;
|
||||
}
|
||||
}
|
||||
if (idx === 3 && this.legalFormType === 1 && this.clientFamilyMembersStep) {
|
||||
const step = this.clientFamilyMembersStep;
|
||||
if (!step.hasMother) return 'Mother is required as a family member.';
|
||||
if (!step.hasFather && !step.hasCaretaker) return 'Either Father or a Caretaker is required as a family member.';
|
||||
}
|
||||
return 'Please fill all mandatory fields on this tab to continue.';
|
||||
}
|
||||
|
||||
@@ -571,6 +576,21 @@ export class CreateClientComponent {
|
||||
this.selectedTabIndex = 3;
|
||||
return;
|
||||
}
|
||||
// Validate: Mother required, and either Father or a Caretaker (Individual clients)
|
||||
if (
|
||||
this.legalFormType === 1 &&
|
||||
this.clientFamilyMembersStep &&
|
||||
!this.clientFamilyMembersStep.familyRelationshipRulesMet
|
||||
) {
|
||||
const step = this.clientFamilyMembersStep;
|
||||
if (!step.hasMother) {
|
||||
this.validationError = 'Mother is required as a family member.';
|
||||
} else {
|
||||
this.validationError = 'Either Father or a Caretaker is required as a family member.';
|
||||
}
|
||||
this.selectedTabIndex = 3;
|
||||
return;
|
||||
}
|
||||
this.validationError = '';
|
||||
|
||||
if (this.createdClientId) {
|
||||
|
||||
@@ -25,9 +25,22 @@
|
||||
{{ 'labels.inputs.GENERAL' | translate }}
|
||||
</ng-template>
|
||||
<div class="tab-content" [class.tab-content--readonly]="isTabReadOnly(0)">
|
||||
@if (hasPendingGeneralEdit) {
|
||||
<div class="pending-edit-banner">
|
||||
<mat-icon>hourglass_top</mat-icon>
|
||||
<span
|
||||
>An edit is awaiting approval from another user. You can't submit another edit until it's resolved.</span
|
||||
>
|
||||
</div>
|
||||
}
|
||||
<div class="tab-mode-bar">
|
||||
@if (isTabReadOnly(0)) {
|
||||
<button mat-flat-button class="tab-mode-btn tab-mode-btn--edit" (click)="enableTabEdit(0)">
|
||||
<button
|
||||
mat-flat-button
|
||||
class="tab-mode-btn tab-mode-btn--edit"
|
||||
[disabled]="hasPendingGeneralEdit"
|
||||
(click)="enableTabEdit(0)"
|
||||
>
|
||||
<mat-icon>edit</mat-icon> Edit
|
||||
</button>
|
||||
} @else {
|
||||
@@ -152,6 +165,7 @@
|
||||
[isMinor]="clientGeneralStep?.isMinor || false"
|
||||
[clientId]="clientId"
|
||||
[readOnly]="isTabReadOnly(1)"
|
||||
[calculatedRisk]="clientGeneralStep?.calculatedRisk"
|
||||
></mifosx-client-kyc-step>
|
||||
</div>
|
||||
</div>
|
||||
@@ -208,6 +222,7 @@
|
||||
#clientIndiaAddress
|
||||
[clientId]="clientId"
|
||||
[isMinor]="clientGeneralStep?.isMinor || false"
|
||||
[isNonIndividual]="legalFormType === 2"
|
||||
[readOnly]="isTabReadOnly(addressTabIdx)"
|
||||
></mifosx-client-india-address-step>
|
||||
</div>
|
||||
|
||||
@@ -217,6 +217,26 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Pending Edit Approval Banner ─── */
|
||||
.pending-edit-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
margin: 0 0 16px;
|
||||
background: #eff6ff;
|
||||
border: 1px solid #93c5fd;
|
||||
border-radius: 8px;
|
||||
color: #1e40af;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
|
||||
mat-icon {
|
||||
color: #2563eb;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Re-KYC Alert Banner ─── */
|
||||
.rekyc-alert {
|
||||
display: flex;
|
||||
|
||||
@@ -38,6 +38,7 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { ORIGINAL_VALUES_PAYLOAD_KEY, buildOriginalValuesSnapshot } from '../shared/client-edit-diff.util';
|
||||
|
||||
/**
|
||||
* Edit Client Component — uses the same wizard/stepper layout as Create Client.
|
||||
@@ -116,6 +117,9 @@ export class EditClientComponent {
|
||||
clientDataAndTemplate: any;
|
||||
/** Client ID */
|
||||
clientId: string;
|
||||
/** True while a General-tab edit is staged awaiting checker approval — blocks submitting
|
||||
* another one (the backend enforces this too; this just avoids a round-trip error toast). */
|
||||
hasPendingGeneralEdit = false;
|
||||
|
||||
constructor() {
|
||||
this.route.data.subscribe((data: { clientDataAndTemplate: any }) => {
|
||||
@@ -124,6 +128,15 @@ export class EditClientComponent {
|
||||
if (this.clientDataAndTemplate.legalForm) {
|
||||
this.legalFormType = this.clientDataAndTemplate.legalForm.id;
|
||||
}
|
||||
this.loadPendingGeneralEdit();
|
||||
});
|
||||
}
|
||||
|
||||
private loadPendingGeneralEdit(): void {
|
||||
if (!this.clientId) return;
|
||||
this.clientsService.getPendingClientEdit(this.clientId).subscribe({
|
||||
next: (edit: any) => (this.hasPendingGeneralEdit = !!edit && edit.status === 'PENDING'),
|
||||
error: () => (this.hasPendingGeneralEdit = false)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -287,14 +300,20 @@ export class EditClientComponent {
|
||||
if (this.legalFormType === 2) {
|
||||
if (idx === 2) return true; // Scheme & Sector — opt-in
|
||||
if (idx === 3) return this.isAddressStepValid();
|
||||
if (idx === 4) return true; // Family — optional
|
||||
if (idx === 4) return true; // Family — not applicable to non-individual entities
|
||||
} else {
|
||||
if (idx === 2) return this.isAddressStepValid();
|
||||
if (idx === 3) return true; // Family — optional
|
||||
if (idx === 3) return this.isFamilyStepValid(); // Mother required, plus Father or a Caretaker
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Mother is required, and either Father or a Caretaker, for individual clients. */
|
||||
private isFamilyStepValid(): boolean {
|
||||
const step = this.clientFamilyMembersStep;
|
||||
return !step || step.familyRelationshipRulesMet;
|
||||
}
|
||||
|
||||
private isAddressStepValid(): boolean {
|
||||
const step = this.clientIndiaAddressStep;
|
||||
if (!step) return true;
|
||||
@@ -318,6 +337,12 @@ export class EditClientComponent {
|
||||
return `Please fill/correct: ${labels.join(', ')}.`;
|
||||
}
|
||||
}
|
||||
const familyTabIndex = this.legalFormType === 2 ? 4 : 3;
|
||||
if (this.selectedTabIndex === familyTabIndex && this.legalFormType !== 2 && this.clientFamilyMembersStep) {
|
||||
const step = this.clientFamilyMembersStep;
|
||||
if (!step.hasMother) return 'Mother is required as a family member.';
|
||||
if (!step.hasFather && !step.hasCaretaker) return 'Either Father or a Caretaker is required as a family member.';
|
||||
}
|
||||
return 'Please fill all mandatory fields on this tab to continue.';
|
||||
}
|
||||
|
||||
@@ -422,46 +447,51 @@ export class EditClientComponent {
|
||||
clientData.emailNewOtpId = this.newEmailOtpId;
|
||||
}
|
||||
|
||||
// Point-in-time "before" snapshot for the checker's diff view — captured now, from the
|
||||
// client record loaded when this edit page opened, rather than relying on comparing against
|
||||
// the LIVE client later (which would show nothing changed once a checker approves the edit,
|
||||
// since by then current == proposed).
|
||||
clientData[ORIGINAL_VALUES_PAYLOAD_KEY] = buildOriginalValuesSnapshot(originalData);
|
||||
|
||||
this.savingClient = true;
|
||||
this.clientsService.updateClient(this.clientId, clientData).subscribe({
|
||||
// Maker-checker: stage the edit instead of applying it immediately. The customer-facing
|
||||
// side effects that a direct update would have had immediately — SMS/Email contact-change
|
||||
// notification, re-KYC flagging, PAN re-verification reset — are all deferred until a
|
||||
// different user actually approves the edit (approval replays this exact payload through
|
||||
// the same updateClient() the direct PUT used to call, so the backend-driven notification
|
||||
// still fires correctly, just at approval time instead of now).
|
||||
this.clientsService.submitClientEdit(this.clientId, clientData).subscribe({
|
||||
next: () => {
|
||||
this.savingClient = false;
|
||||
this.tabEditMode[tabIndex] = false;
|
||||
this.hasPendingGeneralEdit = true;
|
||||
|
||||
// Log mobile/email changes as client notes for risk audit
|
||||
this.logFieldChangeNotes(changedFields);
|
||||
|
||||
// If name or DOB changed, flag re-KYC requirement and reset PAN verification
|
||||
if (changedFields.nameChanged || changedFields.dobChanged) {
|
||||
this.dobChanged = true;
|
||||
this.tabEditMode[1] = true; // auto-enable KYC tab for re-verification
|
||||
this.flagReKycRequired(changedFields);
|
||||
// Senior Citizen / PWD supporting documents — separate upload endpoints, not part of
|
||||
// the staged JSON edit, so these still apply immediately as before.
|
||||
if (this.clientGeneralStep?.seniorCitizenProofFile) {
|
||||
const seniorCitizenProofForm = new FormData();
|
||||
seniorCitizenProofForm.append('file', this.clientGeneralStep.seniorCitizenProofFile);
|
||||
seniorCitizenProofForm.append('name', 'seniorCitizenProof');
|
||||
seniorCitizenProofForm.append('description', 'Senior Citizen Proof (Age/ID Proof)');
|
||||
this.clientsService
|
||||
.uploadClientDocument(this.clientId, seniorCitizenProofForm)
|
||||
.subscribe({ error: () => {} });
|
||||
}
|
||||
if (this.clientGeneralStep?.pwdCertificateFile) {
|
||||
const pwdCertificateForm = new FormData();
|
||||
pwdCertificateForm.append('file', this.clientGeneralStep.pwdCertificateFile);
|
||||
pwdCertificateForm.append('name', 'pwdCertificate');
|
||||
pwdCertificateForm.append('description', 'Disability Certificate');
|
||||
this.clientsService.uploadClientDocument(this.clientId, pwdCertificateForm).subscribe({ error: () => {} });
|
||||
}
|
||||
|
||||
// Update original data snapshot so subsequent edits compare against latest
|
||||
if (changedFields.mobileChanged) {
|
||||
originalData.mobileNo = changedFields.newMobile;
|
||||
}
|
||||
if (changedFields.emailChanged) {
|
||||
originalData.emailAddress = changedFields.newEmail;
|
||||
}
|
||||
if (changedFields.dobChanged) {
|
||||
originalData.dateOfBirth = changedFields.newDob;
|
||||
}
|
||||
if (changedFields.nameChanged) {
|
||||
const form = this.clientGeneralForm;
|
||||
originalData.firstname = form?.get('firstname')?.value ?? originalData.firstname;
|
||||
originalData.middlename = form?.get('middlename')?.value ?? originalData.middlename;
|
||||
originalData.lastname = form?.get('lastname')?.value ?? originalData.lastname;
|
||||
originalData.fullname = form?.get('fullname')?.value ?? originalData.fullname;
|
||||
}
|
||||
|
||||
// Notification is backend-driven (SMS + Email) — just reassure staff it happened.
|
||||
if (changedFields.mobileChanged || changedFields.emailChanged) {
|
||||
this.snackBar.open('Customer notified via SMS/Email of the contact change.', 'Close', {
|
||||
duration: 4000
|
||||
});
|
||||
}
|
||||
this.snackBar.open(
|
||||
'Edit submitted for approval — a different user must review it before it takes effect.',
|
||||
'Close',
|
||||
{
|
||||
duration: 5000
|
||||
}
|
||||
);
|
||||
this.resetContactVerificationState();
|
||||
|
||||
this.selectedTabIndex = this.selectedTabIndex + 1;
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Shared between the maker side (edit-client, which captures a point-in-time snapshot of the
|
||||
* client's current values right before staging an edit) and the checker side (pending-client-edits,
|
||||
* which diffs the staged payload against that snapshot). Keeping both in one place means the two
|
||||
* sides can never drift out of sync on which fields are captured/compared/labelled.
|
||||
*
|
||||
* Extracts a payload key's CURRENT value from a client record (as returned by GET /clients/{id}) —
|
||||
* CodeValue fields come back as nested {id, name} objects, so most extractors pull the nested .id
|
||||
* to compare against the payload's raw id.
|
||||
*/
|
||||
export const PENDING_EDIT_FIELD_EXTRACTORS: Record<string, (c: any) => any> = {
|
||||
officeId: (c) => c.officeId,
|
||||
legalFormId: (c) => c.legalForm?.id,
|
||||
nationalityId: (c) => c.nationality,
|
||||
isStaff: (c) => c.isStaff,
|
||||
active: (c) => c.active,
|
||||
accountNo: (c) => c.accountNo,
|
||||
salutationId: (c) => c.salutation?.id,
|
||||
firstname: (c) => c.firstname,
|
||||
middlename: (c) => c.middlename,
|
||||
lastname: (c) => c.lastname,
|
||||
fullname: (c) => c.fullname,
|
||||
genderId: (c) => c.gender?.id,
|
||||
maritalStatusId: (c) => c.maritalStatus?.id,
|
||||
educationLevelId: (c) => c.educationLevel?.id,
|
||||
degreeQualificationId: (c) => c.degreeQualification?.id,
|
||||
religionId: (c) => c.religion?.id,
|
||||
socialCategoryId: (c) => c.socialCategory?.id,
|
||||
mobileNo: (c) => c.mobileNo,
|
||||
alternateMobileNo: (c) => c.alternateMobileNo,
|
||||
emailAddress: (c) => c.emailAddress,
|
||||
dateOfBirth: (c) => c.dateOfBirth,
|
||||
occupationCategoryId: (c) => c.occupationCategory?.id,
|
||||
occupationId: (c) => c.occupation?.id,
|
||||
monthlyIncome: (c) => c.monthlyIncome,
|
||||
annualIncome: (c) => c.annualIncome,
|
||||
annualTurnover: (c) => c.annualTurnover,
|
||||
monthlyTurnover: (c) => c.monthlyTurnover,
|
||||
isPwd: (c) => c.isPwd,
|
||||
pwdCategoryId: (c) => c.pwdCategory?.id,
|
||||
isSeniorCitizen: (c) => c.isSeniorCitizen,
|
||||
agricultureIncome: (c) => c.agricultureIncome,
|
||||
nonAgricultureIncome: (c) => c.nonAgricultureIncome,
|
||||
savingsProductId: (c) => c.savingsProductId,
|
||||
staffId: (c) => c.staffId,
|
||||
guardianClientId: (c) => c.guardianClientId,
|
||||
guardianRelation: (c) => c.guardianRelation
|
||||
};
|
||||
|
||||
/** Payload keys that are computed, internal, or otherwise never meaningfully "changed" from a
|
||||
* checker's point of view — always sent by the form (age breakdown, OTP tracking ids, the
|
||||
* business-date default on submittedOnDate) but with no comparable "current" value. */
|
||||
export const NON_COMPARABLE_KEYS = new Set([
|
||||
'dateFormat',
|
||||
'locale',
|
||||
'ageYears',
|
||||
'ageMonths',
|
||||
'ageDays',
|
||||
'isMinor',
|
||||
'addSavings',
|
||||
'submittedOnDate',
|
||||
'activationDate',
|
||||
// Flattened onto top-level guardianClientId/guardianRelation right after this in the payload —
|
||||
// that flattened pair already has real comparators above, so the nested group is redundant.
|
||||
'guardianDetails',
|
||||
// Mobile/email DIP verification + name-match + sanctions-screening results — carried along on
|
||||
// every save to re-affirm the client's EXISTING verification state, not something the maker
|
||||
// edited on this pass. dipTimestamp especially would almost never string-match exactly even
|
||||
// when functionally unchanged, so these were never real "proposed changes" to review.
|
||||
'dipVerified',
|
||||
'dipLookupLogId',
|
||||
'dipTimestamp',
|
||||
'alternateDipVerified',
|
||||
'alternateDipLookupLogId',
|
||||
'alternateDipTimestamp',
|
||||
'nameVerified',
|
||||
'screeningLogId',
|
||||
'mobileNewOtpId',
|
||||
'mobileOldOtpId',
|
||||
'emailNewOtpId',
|
||||
'emailOldOtpId'
|
||||
]);
|
||||
|
||||
export const PENDING_EDIT_FIELD_LABELS: Record<string, string> = {
|
||||
officeId: 'Office',
|
||||
legalFormId: 'Legal Form',
|
||||
nationalityId: 'Nationality',
|
||||
isStaff: 'Is Staff',
|
||||
active: 'Active',
|
||||
accountNo: 'Account Number',
|
||||
salutationId: 'Salutation',
|
||||
firstname: 'First Name',
|
||||
middlename: 'Middle Name',
|
||||
lastname: 'Last Name',
|
||||
fullname: 'Full Name',
|
||||
genderId: 'Gender',
|
||||
maritalStatusId: 'Marital Status',
|
||||
educationLevelId: 'Education Level',
|
||||
degreeQualificationId: 'Degree / Qualification',
|
||||
religionId: 'Religion',
|
||||
socialCategoryId: 'Social Category',
|
||||
mobileNo: 'Mobile Number',
|
||||
alternateMobileNo: 'Alternate Mobile Number',
|
||||
emailAddress: 'Email Address',
|
||||
dateOfBirth: 'Date of Birth',
|
||||
occupationCategoryId: 'Occupation Category',
|
||||
occupationId: 'Occupation',
|
||||
monthlyIncome: 'Monthly Income',
|
||||
annualIncome: 'Annual Income',
|
||||
annualTurnover: 'Yearly Turnover',
|
||||
monthlyTurnover: 'Monthly Turnover',
|
||||
isPwd: 'Person with Disability',
|
||||
pwdCategoryId: 'Disability Type',
|
||||
isSeniorCitizen: 'Senior Citizen',
|
||||
agricultureIncome: 'Agriculture Income',
|
||||
nonAgricultureIncome: 'Non-Agriculture Income',
|
||||
submittedOnDate: 'Submitted On',
|
||||
savingsProductId: 'Linked Savings Product',
|
||||
staffId: 'Staff'
|
||||
};
|
||||
|
||||
/** Key under which the maker's point-in-time "before" snapshot rides along in the submit payload —
|
||||
* stripped out by the backend before replaying the rest through updateClient(), and stored
|
||||
* separately as original_json for the checker's diff. Never a real Client field name, so it can't
|
||||
* collide with anything ClientDataValidator actually expects. */
|
||||
export const ORIGINAL_VALUES_PAYLOAD_KEY = '_originalValues';
|
||||
|
||||
/** Builds the maker-side "before" snapshot from the client record loaded when the edit page
|
||||
* opened — flat key names matching the payload's own, so the checker side can diff two plain
|
||||
* objects directly without needing to know Client's nested response shape at all. */
|
||||
export function buildOriginalValuesSnapshot(clientData: any): Record<string, any> {
|
||||
const snapshot: Record<string, any> = {};
|
||||
if (!clientData) return snapshot;
|
||||
for (const key of Object.keys(PENDING_EDIT_FIELD_EXTRACTORS)) {
|
||||
const value = PENDING_EDIT_FIELD_EXTRACTORS[key](clientData);
|
||||
if (value !== undefined) {
|
||||
snapshot[key] = value;
|
||||
}
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<!--
|
||||
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/.
|
||||
-->
|
||||
|
||||
<h1 mat-dialog-title>{{ data.title || data.fileName || 'Document Preview' }}</h1>
|
||||
|
||||
<div mat-dialog-content class="doc-preview-content">
|
||||
@if (isImage) {
|
||||
<img [src]="previewUrl" [alt]="data.fileName || 'Document'" class="doc-preview-image" />
|
||||
} @else if (isPdf) {
|
||||
<iframe [src]="safeUrl" class="doc-preview-pdf" title="Document preview"></iframe>
|
||||
} @else {
|
||||
<p>Preview isn't available for this file type — use Download to view it.</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-raised-button (click)="download()">Download</button>
|
||||
<button mat-raised-button mat-dialog-close color="primary">Close</button>
|
||||
</mat-dialog-actions>
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
.doc-preview-content {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-width: 320px;
|
||||
max-width: 80vw;
|
||||
}
|
||||
|
||||
.doc-preview-image {
|
||||
max-width: 100%;
|
||||
max-height: 70vh;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.doc-preview-pdf {
|
||||
width: 70vw;
|
||||
height: 70vh;
|
||||
border: none;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* 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, OnDestroy, inject } from '@angular/core';
|
||||
import {
|
||||
MatDialogRef,
|
||||
MAT_DIALOG_DATA,
|
||||
MatDialogTitle,
|
||||
MatDialogContent,
|
||||
MatDialogActions,
|
||||
MatDialogClose
|
||||
} from '@angular/material/dialog';
|
||||
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
|
||||
import { CdkScrollable } from '@angular/cdk/scrolling';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
export interface DocumentPreviewDialogData {
|
||||
blob: Blob;
|
||||
title?: string;
|
||||
fileName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic in-app document preview dialog — shows an image inline or a PDF in an embedded
|
||||
* viewer, with a Download fallback for anything else. Used in place of window.open() for
|
||||
* saved KYC/address/senior-citizen/PWD documents: opening a new browser tab is subject to
|
||||
* popup blockers (fires only after the async download resolves) and, for images, Chrome/Edge
|
||||
* frequently fail to paint a huge base64 data: URL until the tab is manually refreshed.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-document-preview-dialog',
|
||||
templateUrl: './document-preview-dialog.component.html',
|
||||
styleUrls: ['./document-preview-dialog.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
MatDialogTitle,
|
||||
CdkScrollable,
|
||||
MatDialogContent,
|
||||
MatDialogActions,
|
||||
MatDialogClose
|
||||
]
|
||||
})
|
||||
export class DocumentPreviewDialogComponent implements OnDestroy {
|
||||
dialogRef = inject<MatDialogRef<DocumentPreviewDialogComponent>>(MatDialogRef);
|
||||
private sanitizer = inject(DomSanitizer);
|
||||
data = inject<DocumentPreviewDialogData>(MAT_DIALOG_DATA);
|
||||
|
||||
private readonly objectUrl: string;
|
||||
readonly safeUrl: SafeResourceUrl;
|
||||
readonly isImage: boolean;
|
||||
readonly isPdf: boolean;
|
||||
|
||||
constructor() {
|
||||
this.objectUrl = URL.createObjectURL(this.data.blob);
|
||||
this.safeUrl = this.sanitizer.bypassSecurityTrustResourceUrl(this.objectUrl);
|
||||
const type = this.data.blob.type || '';
|
||||
this.isImage = type.startsWith('image/');
|
||||
this.isPdf = type === 'application/pdf';
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
URL.revokeObjectURL(this.objectUrl);
|
||||
}
|
||||
|
||||
get previewUrl(): string {
|
||||
return this.objectUrl;
|
||||
}
|
||||
|
||||
download(): void {
|
||||
const a = document.createElement('a');
|
||||
a.href = this.objectUrl;
|
||||
a.download = this.data.fileName || 'document';
|
||||
a.click();
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,7 @@ export const PROOF_META: Record<string, Partial<ProofType>> = {
|
||||
sides: 1,
|
||||
documentKeyLabel: 'School ID / admission number',
|
||||
documentKeyPlaceholder: 'Student ID number',
|
||||
category: 'Government-issued ID',
|
||||
info: 'School identity card bearing photograph — accepted proof of identity for minor clients as per RBI KYC norms.'
|
||||
},
|
||||
UTILITY_BILL: {
|
||||
@@ -144,7 +145,46 @@ export const PROOF_META: Record<string, Partial<ProofType>> = {
|
||||
PENSION_PAYMENT_ORDER: {
|
||||
sides: 1,
|
||||
category: 'Financial & other',
|
||||
requiresDate: true,
|
||||
info: 'Pension payment order — upload full document issued by pension authority.'
|
||||
},
|
||||
CERT_INCORPORATION: {
|
||||
sides: 1,
|
||||
category: 'Entity KYC documents',
|
||||
documentKeyLabel: 'Registration / incorporation number',
|
||||
documentKeyPlaceholder: 'CIN / registration number',
|
||||
info: 'Certificate of Incorporation (companies/LLPs) or Registration Certificate (firms/trusts/societies) — as prescribed under the RBI Master Direction on KYC, Annexure I.'
|
||||
},
|
||||
ENTITY_PAN: {
|
||||
sides: 1,
|
||||
category: 'Entity KYC documents',
|
||||
documentKeyLabel: 'PAN of entity',
|
||||
documentKeyPlaceholder: 'e.g. ABCDE1234F',
|
||||
info: 'PAN card issued in the name of the entity.'
|
||||
},
|
||||
CONSTITUTIVE_DOC: {
|
||||
sides: 1,
|
||||
category: 'Entity KYC documents',
|
||||
documentKeyLabel: 'Document reference number',
|
||||
documentKeyPlaceholder: 'Reference / registration number',
|
||||
info: 'Constitutive document — Memorandum & Articles of Association (companies), Partnership Deed (firms), Trust Deed (trusts), or By-laws (societies) — as prescribed under the RBI Master Direction on KYC.'
|
||||
},
|
||||
BOARD_RESOLUTION: {
|
||||
sides: 1,
|
||||
category: 'Entity KYC documents',
|
||||
info: 'Board Resolution / Authorization Letter naming the officers, managers or employees authorised to transact on the entity’s behalf.'
|
||||
},
|
||||
AUTH_SIGNATORY_KYC: {
|
||||
sides: 1,
|
||||
category: 'Entity KYC documents',
|
||||
info: 'OVD-based KYC of the person(s) holding Power of Attorney / authorised to transact on the entity’s behalf.'
|
||||
},
|
||||
GST_CERTIFICATE: {
|
||||
sides: 1,
|
||||
category: 'Entity KYC documents',
|
||||
documentKeyLabel: 'GSTIN',
|
||||
documentKeyPlaceholder: 'e.g. 22AAAAA0000A1Z5',
|
||||
info: 'GST Registration Certificate, where the entity is GST-registered.'
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -392,6 +392,22 @@ export class AuthenticationService {
|
||||
return roleNames.some((n) => n === 'clerk');
|
||||
}
|
||||
|
||||
/** Returns true when the logged-in user has the "Principal Officer" role (STR conclusion/filing authority). */
|
||||
isPrincipalOfficerUser(): boolean {
|
||||
const creds = this.getCredentials();
|
||||
if (!creds || !creds.roles) return false;
|
||||
const roleNames: string[] = (creds.roles as any[]).map((r) => (r.name || r.role || '').toLowerCase().trim());
|
||||
return roleNames.some((n) => n === 'principal officer');
|
||||
}
|
||||
|
||||
/** Returns true when the logged-in user has the "AML Officer" role (first-level alert review). */
|
||||
isAmlOfficerUser(): boolean {
|
||||
const creds = this.getCredentials();
|
||||
if (!creds || !creds.roles) return false;
|
||||
const roleNames: string[] = (creds.roles as any[]).map((r) => (r.name || r.role || '').toLowerCase().trim());
|
||||
return roleNames.some((n) => n === 'aml officer');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true ONLY for regular cashier role (not Head Cashier).
|
||||
* Regular cashier → full-screen transaction entry, no nav.
|
||||
|
||||
@@ -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');
|
||||
@@ -54,12 +55,33 @@ export class ErrorHandlerInterceptor implements HttpInterceptor {
|
||||
const isClientImageRequest = request.url.includes('/clients/') && request.url.includes('/images');
|
||||
const isClientKycRequest = request.url.match(/\/clients\/\d+\/kyc$/);
|
||||
const isDirectorRequest = request.url.includes('/election/directors');
|
||||
const isSilentRequest = isClientImageRequest || isClientKycRequest || isDirectorRequest;
|
||||
// "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 ||
|
||||
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);
|
||||
@@ -1121,7 +1121,7 @@
|
||||
<button mat-menu-item tabindex="0">
|
||||
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Share Issued
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/shares/share-holders']">
|
||||
<fa-icon icon="users" class="menu-item-icon"></fa-icon> Share Holder
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
@@ -1167,10 +1167,6 @@
|
||||
<button mat-menu-item tabindex="0">
|
||||
<fa-icon icon="chart-bar" class="menu-item-icon"></fa-icon> Depreciation Register
|
||||
</button>
|
||||
<button mat-menu-item routerLink="/fiu" tabindex="0">
|
||||
<fa-icon icon="shield-alt" class="menu-item-icon" style="color: #dc2626"></fa-icon> Financial Intelligence
|
||||
Unit (FIU-IND)
|
||||
</button>
|
||||
</div>
|
||||
<!-- Investment -->
|
||||
<div class="deposit-menu-group">
|
||||
@@ -1568,8 +1564,13 @@
|
||||
<a class="gl-link" [class.hidden]="!matchesRbiSearch('Balance Sheet')" (click)="rbiMenuOpen = false">
|
||||
<fa-icon icon="file-alt" class="gl-icon"></fa-icon> Balance Sheet (BR Act & Others)
|
||||
</a>
|
||||
<a class="gl-link" [class.hidden]="!matchesRbiSearch('CRR / SLR')" (click)="rbiMenuOpen = false">
|
||||
<fa-icon icon="calculator" class="gl-icon"></fa-icon> CRR / SLR
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesRbiSearch('CTR / STR')"
|
||||
routerLink="/fiu"
|
||||
(click)="rbiMenuOpen = false"
|
||||
>
|
||||
<fa-icon icon="calculator" class="gl-icon"></fa-icon> CTR / STR
|
||||
</a>
|
||||
<a class="gl-link" [class.hidden]="!matchesRbiSearch('Annexure')" (click)="rbiMenuOpen = false">
|
||||
<fa-icon icon="paperclip" class="gl-icon"></fa-icon> Annexure
|
||||
@@ -1664,8 +1665,13 @@
|
||||
>
|
||||
<fa-icon icon="bell" class="gl-icon"></fa-icon> Daily Position Notice
|
||||
</a>
|
||||
<a class="gl-link" [class.hidden]="!matchesRbiSearch('R/SLR')" (click)="rbiMenuOpen = false">
|
||||
<fa-icon icon="lock" class="gl-icon"></fa-icon> R/SLR
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesRbiSearch('AML')"
|
||||
routerLink="/rbi/aml"
|
||||
(click)="rbiMenuOpen = false"
|
||||
>
|
||||
<fa-icon icon="shield-alt" class="gl-icon"></fa-icon> AML
|
||||
</a>
|
||||
</div>
|
||||
<!-- OSS Reports -->
|
||||
|
||||
@@ -129,9 +129,130 @@
|
||||
<!-- CTR -->
|
||||
@if (activeTab === 'ctr') {
|
||||
<div class="fiu-section">
|
||||
<!-- LIVE: cash-related AML alerts awaiting AML Officer review -->
|
||||
<div class="fiu-form-meta">
|
||||
<div class="fiu-form-title">
|
||||
<h3>CTR — Cash Transaction Report</h3>
|
||||
<h3>Live Cash Alerts</h3>
|
||||
<p>
|
||||
Raised by the transaction-monitoring rule engine — review and whitelist or escalate to Principal Officer
|
||||
</p>
|
||||
</div>
|
||||
<select class="fiu-inp" [(ngModel)]="amlAlertStatusFilter" (ngModelChange)="loadAmlAlerts()">
|
||||
<option value="">All statuses</option>
|
||||
<option value="RAISED">Raised</option>
|
||||
<option value="UNDER_REVIEW">Under Review</option>
|
||||
<option value="WHITELISTED">Whitelisted</option>
|
||||
<option value="ESCALATED">Escalated</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="fiu-tbl-wrap">
|
||||
<table class="fiu-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Client</th>
|
||||
<th>Type</th>
|
||||
<th>Rule</th>
|
||||
<th class="ta-r">Amount</th>
|
||||
<th>Raised On</th>
|
||||
<th>Status</th>
|
||||
<th>Notes</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (alert of amlAlerts; track alert.id) {
|
||||
<tr>
|
||||
<td>{{ alert.id }}</td>
|
||||
<td>{{ alert.clientDisplayName }}</td>
|
||||
<td>{{ alert.transactionType }}</td>
|
||||
<td>{{ alert.ruleCode }} — {{ alert.ruleDescription }}</td>
|
||||
<td class="ta-r mono">₹{{ alert.amount | number: '1.0-0' }}</td>
|
||||
<td>{{ alert.raisedOn | date: 'medium' }}</td>
|
||||
<td>
|
||||
<span [class]="amlStatusClass(alert.status)">{{ alert.status }}</span>
|
||||
</td>
|
||||
<td>
|
||||
@if (alert.status === 'RAISED' || alert.status === 'UNDER_REVIEW') {
|
||||
<input
|
||||
type="text"
|
||||
class="fiu-inp"
|
||||
placeholder="Review notes"
|
||||
[(ngModel)]="amlReviewNotes[alert.id]"
|
||||
/>
|
||||
} @else {
|
||||
<span class="muted">{{ alert.reviewNotes }}</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (alert.status === 'RAISED' || alert.status === 'UNDER_REVIEW') {
|
||||
<div class="fiu-action-group" *mifosxHasPermission="'REVIEW_AML_ALERT'">
|
||||
<button class="fiu-btn fiu-btn-g" (click)="whitelistAlert(alert)">Whitelist</button>
|
||||
<button class="fiu-btn fiu-btn-xml" (click)="escalateAlert(alert)">Escalate</button>
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="9" class="fiu-empty">No live alerts.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- LIVE: monthly CTR reports (mechanical, threshold-based) -->
|
||||
<div class="fiu-form-meta">
|
||||
<div class="fiu-form-title">
|
||||
<h3>Monthly CTR Reports</h3>
|
||||
<p>
|
||||
Threshold: <strong>₹{{ bank.cashThreshold | number }}</strong> per single txn / connected aggregate
|
||||
per month
|
||||
</p>
|
||||
</div>
|
||||
<div class="fiu-action-group">
|
||||
<input class="fiu-inp" type="month" [(ngModel)]="ctrGenerateMonth" />
|
||||
<button class="fiu-btn fiu-btn-g" *mifosxHasPermission="'REVIEW_AML_ALERT'" (click)="generateCtrReport()">
|
||||
Generate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fiu-tbl-wrap">
|
||||
<table class="fiu-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Month</th>
|
||||
<th>Generated On</th>
|
||||
<th class="ta-c">Records</th>
|
||||
<th>Status</th>
|
||||
<th>Submitted On</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of amlCtrReports; track r.id) {
|
||||
<tr>
|
||||
<td>{{ r.reportMonth }}</td>
|
||||
<td>{{ r.generatedOn | date: 'medium' }}</td>
|
||||
<td class="ta-c">{{ r.recordCount }}</td>
|
||||
<td>
|
||||
<span [class]="amlStatusClass(r.status)">{{ r.status }}</span>
|
||||
</td>
|
||||
<td>{{ r.submittedOn ? (r.submittedOn | date: 'medium') : '—' }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="5" class="fiu-empty">No CTR reports generated yet.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- FINnet export template (separate concern — TC1/TC2 file-format generation) -->
|
||||
<div class="fiu-form-meta">
|
||||
<div class="fiu-form-title">
|
||||
<h3>FINnet TC2 Export Template</h3>
|
||||
<p>
|
||||
All cash transactions ≥ ₹{{ bank.cashThreshold | number }} (single or aggregate per month per account)
|
||||
</p>
|
||||
@@ -221,9 +342,107 @@
|
||||
<!-- STR -->
|
||||
@if (activeTab === 'str') {
|
||||
<div class="fiu-section">
|
||||
<!-- LIVE: STR cases with full Principal Officer actions -->
|
||||
<div class="fiu-form-meta">
|
||||
<div class="fiu-form-title">
|
||||
<h3>STR — Suspicious Transaction Report</h3>
|
||||
<h3>Live STR Cases</h3>
|
||||
<p>
|
||||
Escalated by an AML Officer; Principal Officer investigates, concludes and files with FIU-IND within 7
|
||||
working days
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fiu-tbl-wrap">
|
||||
<table class="fiu-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Client</th>
|
||||
<th class="ta-c">Cumulative Cases</th>
|
||||
<th>Assigned PO</th>
|
||||
<th>Conclusion</th>
|
||||
<th>FIU Due Date</th>
|
||||
<th>Status</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (sc of amlStrCases; track sc.id) {
|
||||
<tr [class.selected-row]="selectedStrCase?.id === sc.id">
|
||||
<td>{{ sc.id }}</td>
|
||||
<td>{{ sc.clientDisplayName }}</td>
|
||||
<td class="ta-c" [class.escalation-warning]="sc.cumulativeStrCaseCountForClient >= 3">
|
||||
{{ sc.cumulativeStrCaseCountForClient }}
|
||||
</td>
|
||||
<td>{{ sc.assignedPoUsername || '—' }}</td>
|
||||
<td>{{ sc.conclusion || '—' }}</td>
|
||||
<td>{{ sc.fiuDueDate || '—' }}</td>
|
||||
<td>
|
||||
<span [class]="amlStatusClass(sc.status)">{{ sc.status }}</span>
|
||||
</td>
|
||||
<td><button class="fiu-btn fiu-btn-g" (click)="selectStrCase(sc)">Investigate</button></td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="8" class="fiu-empty">No STR cases yet.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@if (selectedStrCase) {
|
||||
<div class="str-case-detail">
|
||||
<div class="fiu-form-meta">
|
||||
<div class="fiu-form-title">
|
||||
<h3>STR Case #{{ selectedStrCase.id }} — {{ selectedStrCase.clientDisplayName }}</h3>
|
||||
<p>
|
||||
Alert #{{ selectedStrCase.alertId }} ·
|
||||
{{ selectedStrCase.cumulativeStrCaseCountForClient }} cumulative STR case(s) for this client
|
||||
@if (selectedStrCase.cumulativeStrCaseCountForClient >= 3) {
|
||||
<span class="escalation-badge">Risk category auto-escalated</span>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<button class="fiu-btn fiu-btn-g" (click)="closeStrCaseDetail()">Close</button>
|
||||
</div>
|
||||
|
||||
<div class="case-form" *mifosxHasPermission="'CONCLUDE_AML_STR'">
|
||||
<div class="form-row">
|
||||
<label for="fiuInvestigationNotes">Investigation notes (source-of-transaction history reviewed)</label>
|
||||
<textarea id="fiuInvestigationNotes" rows="3" [(ngModel)]="concludeInvestigationNotes"></textarea>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="fiuSourceOfFundsNotes">Source-of-funds notes</label>
|
||||
<textarea id="fiuSourceOfFundsNotes" rows="2" [(ngModel)]="concludeSourceOfFundsNotes"></textarea>
|
||||
</div>
|
||||
<div class="form-row conclusion-row">
|
||||
<label>
|
||||
<input type="radio" name="fiuConclusion" value="NOT_SUSPICIOUS" [(ngModel)]="concludeConclusion" />
|
||||
Not suspicious (whitelist)
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="fiuConclusion" value="SUSPICIOUS" [(ngModel)]="concludeConclusion" />
|
||||
Suspicious (file STR)
|
||||
</label>
|
||||
</div>
|
||||
<button class="fiu-btn fiu-btn-xml" (click)="concludeStrCase()">Conclude</button>
|
||||
|
||||
@if (selectedStrCase.status === 'CONCLUDED_SUSPICIOUS') {
|
||||
<div class="file-str-row">
|
||||
<label for="fiuFileReferenceNo">FIU-IND acknowledgement / reference number</label>
|
||||
<input id="fiuFileReferenceNo" class="fiu-inp" type="text" [(ngModel)]="fileReferenceNo" />
|
||||
<button class="fiu-btn fiu-btn-xml" (click)="fileStrCase()">Mark STR Filed</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- FINnet export template (separate concern — STR XML file-format generation) -->
|
||||
<div class="fiu-form-meta">
|
||||
<div class="fiu-form-title">
|
||||
<h3>FINnet STR XML Export Template</h3>
|
||||
<p>Detected by AML rules engine + branch compliance officers; filed within 7 days</p>
|
||||
</div>
|
||||
<button class="fiu-btn fiu-btn-xml" (click)="exportStrXml()">
|
||||
|
||||
@@ -579,6 +579,41 @@
|
||||
background: var(--f-red-l);
|
||||
color: var(--f-red);
|
||||
}
|
||||
|
||||
&.status-raised {
|
||||
background: var(--f-amber-l);
|
||||
color: var(--f-amber);
|
||||
}
|
||||
|
||||
&.status-under-review {
|
||||
background: var(--f-blue-l);
|
||||
color: var(--f-blue);
|
||||
}
|
||||
|
||||
&.status-whitelisted,
|
||||
&.status-concluded-clear,
|
||||
&.status-generated,
|
||||
&.status-submitted {
|
||||
background: var(--f-green-l);
|
||||
color: var(--f-green);
|
||||
}
|
||||
|
||||
&.status-escalated,
|
||||
&.status-open,
|
||||
&.status-concluded-suspicious {
|
||||
background: var(--f-red-l);
|
||||
color: var(--f-red);
|
||||
}
|
||||
|
||||
&.status-filed {
|
||||
background: var(--f-purple-l);
|
||||
color: var(--f-purple);
|
||||
}
|
||||
|
||||
&.status-draft {
|
||||
background: var(--f-g100);
|
||||
color: var(--f-g500);
|
||||
}
|
||||
}
|
||||
|
||||
.risk-pill {
|
||||
@@ -892,3 +927,85 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.selected-row {
|
||||
background: var(--f-blue-l);
|
||||
}
|
||||
|
||||
.escalation-warning {
|
||||
color: var(--f-red);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.escalation-badge {
|
||||
display: inline-block;
|
||||
margin-left: 8px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
background: var(--f-amber-l);
|
||||
color: var(--f-amber);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.str-case-detail {
|
||||
border: 1px solid var(--f-g200);
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.case-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--f-g300);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--f-g500);
|
||||
}
|
||||
}
|
||||
|
||||
.conclusion-row {
|
||||
flex-direction: row;
|
||||
gap: 24px;
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.file-str-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px dashed var(--f-g300);
|
||||
|
||||
label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--f-g500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,12 @@
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component } from '@angular/core';
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { AmlService } from 'app/aml/aml.service';
|
||||
import { AuthenticationService } from 'app/core/authentication/authentication.service';
|
||||
import {
|
||||
CtrTransaction,
|
||||
CTR_TXNS,
|
||||
@@ -33,7 +35,9 @@ import {
|
||||
FormsModule
|
||||
]
|
||||
})
|
||||
export class FiuComponent {
|
||||
export class FiuComponent implements OnInit {
|
||||
private amlService = inject(AmlService);
|
||||
private authenticationService = inject(AuthenticationService);
|
||||
/** Active tab */
|
||||
activeTab: 'dashboard' | 'ctr' | 'str' | 'customers' | 'files' = 'dashboard';
|
||||
|
||||
@@ -57,6 +61,126 @@ export class FiuComponent {
|
||||
toastVisible = false;
|
||||
private toastTimer: any;
|
||||
|
||||
// -------------------- Live AML monitoring (real backend, replaces mock CTR/STR tables) --------------------
|
||||
amlAlerts: any[] = [];
|
||||
amlAlertStatusFilter = '';
|
||||
amlReviewNotes: Record<number, string> = {};
|
||||
|
||||
amlCtrReports: any[] = [];
|
||||
ctrGenerateMonth = '';
|
||||
|
||||
amlStrCases: any[] = [];
|
||||
selectedStrCase: any = null;
|
||||
concludeConclusion: 'NOT_SUSPICIOUS' | 'SUSPICIOUS' = 'NOT_SUSPICIOUS';
|
||||
concludeInvestigationNotes = '';
|
||||
concludeSourceOfFundsNotes = '';
|
||||
fileReferenceNo = '';
|
||||
|
||||
get isPrincipalOfficer(): boolean {
|
||||
return this.authenticationService.isPrincipalOfficerUser();
|
||||
}
|
||||
|
||||
get isAmlOfficer(): boolean {
|
||||
return this.authenticationService.isAmlOfficerUser();
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadAmlAlerts();
|
||||
this.loadAmlCtrReports();
|
||||
this.loadAmlStrCases();
|
||||
}
|
||||
|
||||
loadAmlAlerts(): void {
|
||||
this.amlService
|
||||
.getAlerts(this.amlAlertStatusFilter || undefined)
|
||||
.subscribe((data) => (this.amlAlerts = data || []));
|
||||
}
|
||||
|
||||
loadAmlCtrReports(): void {
|
||||
this.amlService.getCtrReports().subscribe((data) => (this.amlCtrReports = data || []));
|
||||
}
|
||||
|
||||
loadAmlStrCases(): void {
|
||||
this.amlService.getStrCases().subscribe((data) => (this.amlStrCases = data || []));
|
||||
}
|
||||
|
||||
whitelistAlert(alert: any): void {
|
||||
this.amlService.reviewAlert(alert.id, 'WHITELIST', this.amlReviewNotes[alert.id] || '').subscribe(() => {
|
||||
this.toast(`Alert #${alert.id} whitelisted`);
|
||||
this.loadAmlAlerts();
|
||||
});
|
||||
}
|
||||
|
||||
escalateAlert(alert: any): void {
|
||||
this.amlService.reviewAlert(alert.id, 'ESCALATE', this.amlReviewNotes[alert.id] || '').subscribe(() => {
|
||||
this.toast(`Alert #${alert.id} escalated to Principal Officer`);
|
||||
this.loadAmlAlerts();
|
||||
this.loadAmlStrCases();
|
||||
});
|
||||
}
|
||||
|
||||
generateCtrReport(): void {
|
||||
if (!this.ctrGenerateMonth) {
|
||||
return;
|
||||
}
|
||||
this.amlService.generateCtrReport(this.ctrGenerateMonth).subscribe({
|
||||
next: () => {
|
||||
this.toast(`CTR report generated for ${this.ctrGenerateMonth}`);
|
||||
this.loadAmlCtrReports();
|
||||
},
|
||||
error: (err) => this.toast(err?.error?.defaultUserMessage || 'CTR report generation failed')
|
||||
});
|
||||
}
|
||||
|
||||
selectStrCase(strCase: any): void {
|
||||
this.selectedStrCase = strCase;
|
||||
this.concludeConclusion = 'NOT_SUSPICIOUS';
|
||||
this.concludeInvestigationNotes = strCase.investigationNotes || '';
|
||||
this.concludeSourceOfFundsNotes = strCase.sourceOfFundsNotes || '';
|
||||
this.fileReferenceNo = '';
|
||||
}
|
||||
|
||||
closeStrCaseDetail(): void {
|
||||
this.selectedStrCase = null;
|
||||
}
|
||||
|
||||
concludeStrCase(): void {
|
||||
if (!this.selectedStrCase) {
|
||||
return;
|
||||
}
|
||||
this.amlService
|
||||
.concludeStrCase(
|
||||
this.selectedStrCase.id,
|
||||
this.concludeConclusion,
|
||||
this.concludeInvestigationNotes,
|
||||
this.concludeSourceOfFundsNotes
|
||||
)
|
||||
.subscribe((updated) => {
|
||||
this.toast(
|
||||
this.concludeConclusion === 'SUSPICIOUS'
|
||||
? `STR case #${updated.id} concluded suspicious — FIU due date ${updated.fiuDueDate}`
|
||||
: `STR case #${updated.id} concluded not suspicious`
|
||||
);
|
||||
this.selectedStrCase = updated;
|
||||
this.loadAmlStrCases();
|
||||
});
|
||||
}
|
||||
|
||||
fileStrCase(): void {
|
||||
if (!this.selectedStrCase || !this.fileReferenceNo) {
|
||||
return;
|
||||
}
|
||||
this.amlService.fileStr(this.selectedStrCase.id, this.fileReferenceNo).subscribe((updated) => {
|
||||
this.toast(`STR case #${updated.id} recorded as filed (ref ${updated.strReferenceNo})`);
|
||||
this.selectedStrCase = updated;
|
||||
this.loadAmlStrCases();
|
||||
});
|
||||
}
|
||||
|
||||
amlStatusClass(status: string): string {
|
||||
return 'status-pill status-' + (status || '').toLowerCase().replace(/_/g, '-');
|
||||
}
|
||||
|
||||
showTab(tab: 'dashboard' | 'ctr' | 'str' | 'customers' | 'files'): void {
|
||||
this.activeTab = tab;
|
||||
}
|
||||
|
||||
@@ -40,6 +40,10 @@ const routes: Routes = [
|
||||
resolve: {
|
||||
offices: OfficesResolver
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'shares',
|
||||
loadChildren: () => import('../shares/shares.module').then((m) => m.SharesModule)
|
||||
}
|
||||
])
|
||||
];
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<!--
|
||||
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/.
|
||||
-->
|
||||
|
||||
<div class="container aml-rules-container">
|
||||
<mat-card class="aml-card">
|
||||
<mat-card-header class="aml-card-header">
|
||||
<div class="aml-header-content">
|
||||
<mat-icon class="header-icon">shield</mat-icon>
|
||||
<div>
|
||||
<mat-card-title class="header-title">AML Rules Master</mat-card-title>
|
||||
<mat-card-subtitle class="header-subtitle">
|
||||
Database-driven rules for client risk categorization and AML compliance checks
|
||||
</mat-card-subtitle>
|
||||
</div>
|
||||
</div>
|
||||
</mat-card-header>
|
||||
|
||||
<mat-card-content class="aml-card-content">
|
||||
<table mat-table [dataSource]="rules" class="aml-table mat-elevation-z1">
|
||||
<!-- Order Position Column -->
|
||||
<ng-container matColumnDef="orderPosition">
|
||||
<th mat-header-cell *matHeaderCellDef class="col-center">Rule Srno</th>
|
||||
<td mat-cell *matCellDef="let element" class="col-center">{{ element.orderPosition }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Rule Name Column -->
|
||||
<ng-container matColumnDef="ruleName">
|
||||
<th mat-header-cell *matHeaderCellDef>Rule Description</th>
|
||||
<td mat-cell *matCellDef="let element" class="rule-name-cell">{{ element.ruleName }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Risk Category Column -->
|
||||
<ng-container matColumnDef="riskCategory">
|
||||
<th mat-header-cell *matHeaderCellDef class="col-center">Risk Category</th>
|
||||
<td mat-cell *matCellDef="let element" class="col-center">
|
||||
<span class="risk-badge" [ngClass]="getRiskColorClass(element.riskCategory)">
|
||||
<span class="dot"></span>
|
||||
{{ getRiskCategoryLabel(element.riskCategory) }}
|
||||
</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Reason Column -->
|
||||
<ng-container matColumnDef="reason">
|
||||
<th mat-header-cell *matHeaderCellDef>Rule Reason / Criteria</th>
|
||||
<td mat-cell *matCellDef="let element" class="reason-cell">{{ element.reason }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- AML Check Status Column -->
|
||||
<ng-container matColumnDef="amlCheckStatus">
|
||||
<th mat-header-cell *matHeaderCellDef class="col-center">AML Check Status</th>
|
||||
<td mat-cell *matCellDef="let element" class="col-center">
|
||||
<span class="compliance-badge" [ngClass]="'status-' + (element.amlCheckStatus || 'clear').toLowerCase()">
|
||||
{{ element.amlCheckStatus || 'CLEAR' }}
|
||||
</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Banned Entity Match Column -->
|
||||
<ng-container matColumnDef="bannedEntityMatch">
|
||||
<th mat-header-cell *matHeaderCellDef class="col-center">Banned Match</th>
|
||||
<td mat-cell *matCellDef="let element" class="col-center">
|
||||
<span class="compliance-badge" [ngClass]="'match-' + (element.bannedEntityMatch || 'no').toLowerCase()">
|
||||
{{ element.bannedEntityMatch || 'NO' }}
|
||||
</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns" class="rule-row"></tr>
|
||||
</table>
|
||||
|
||||
<!-- Mat Paginator for standard pagination -->
|
||||
<mat-paginator [pageSizeOptions]="[10, 25, 50, 100]" showFirstLastButtons class="aml-paginator"></mat-paginator>
|
||||
|
||||
<div *ngIf="rules.data.length === 0" class="no-rules-message">
|
||||
<mat-icon>info_outline</mat-icon>
|
||||
<span>No AML rules found in the database.</span>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
.aml-rules-container {
|
||||
padding: 24px;
|
||||
background-color: var(--bg-neutral-light, #f8fafc);
|
||||
min-height: calc(100vh - 64px);
|
||||
}
|
||||
|
||||
.aml-card {
|
||||
border-radius: 12px;
|
||||
box-shadow:
|
||||
0 4px 20px -2px rgb(0 0 0 / 5%),
|
||||
0 2px 8px -1px rgb(0 0 0 / 3%);
|
||||
border: 1px solid rgb(226 232 240 / 80%);
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.aml-card-header {
|
||||
background: linear-gradient(135deg, #1e293b, #0f172a);
|
||||
color: #fff;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.aml-header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
.header-icon {
|
||||
font-size: 32px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
color: #38bdf8;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
color: #fff;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.header-subtitle {
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
.aml-card-content {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.aml-table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #e2e8f0;
|
||||
|
||||
th {
|
||||
background-color: #f1f5f9;
|
||||
color: #475569;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
padding: 16px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
border-bottom: 1px solid #cbd5e1;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 16px;
|
||||
font-size: 14px;
|
||||
color: #334155;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.rule-row {
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: #f8fafc;
|
||||
}
|
||||
|
||||
&:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.col-center {
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.rule-name-cell {
|
||||
font-weight: 500;
|
||||
color: #1e293b;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.reason-cell {
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
/* Badges for Risk Category */
|
||||
.risk-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
|
||||
.dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
&.risk-high {
|
||||
background-color: #fef2f2;
|
||||
color: #ef4444;
|
||||
border: 1px solid #fee2e2;
|
||||
|
||||
.dot {
|
||||
background-color: #ef4444;
|
||||
}
|
||||
}
|
||||
|
||||
&.risk-medium {
|
||||
background-color: #fffbeb;
|
||||
color: #d97706;
|
||||
border: 1px solid #fef3c7;
|
||||
|
||||
.dot {
|
||||
background-color: #d97706;
|
||||
}
|
||||
}
|
||||
|
||||
&.risk-low {
|
||||
background-color: #f0fdf4;
|
||||
color: #16a34a;
|
||||
border: 1px solid #dcfce7;
|
||||
|
||||
.dot {
|
||||
background-color: #16a34a;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Badges for Compliance status */
|
||||
.compliance-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
|
||||
/* AML Statuses */
|
||||
&.status-clear {
|
||||
background-color: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
&.status-flagged {
|
||||
background-color: #fef3c7;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
&.status-blocked {
|
||||
background-color: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
/* Banned Matches */
|
||||
&.match-no {
|
||||
background-color: #f1f5f9;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
&.match-yes {
|
||||
background-color: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
&.match-partial_match {
|
||||
background-color: #ffedd5;
|
||||
color: #c2410c;
|
||||
}
|
||||
}
|
||||
|
||||
.no-rules-message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px;
|
||||
color: #64748b;
|
||||
gap: 12px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 48px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.aml-paginator {
|
||||
background: #fff;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
padding: 8px 0;
|
||||
font-size: 13px;
|
||||
color: #475569;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* 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, ViewChild } from '@angular/core';
|
||||
import { MatTableModule, MatTableDataSource } from '@angular/material/table';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatPaginatorModule, MatPaginator } from '@angular/material/paginator';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
/** Custom Services */
|
||||
import { ClientsService } from '../../clients/clients.service';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-aml-rules',
|
||||
templateUrl: './aml-rules.component.html',
|
||||
styleUrls: ['./aml-rules.component.scss'],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatTableModule,
|
||||
MatCardModule,
|
||||
MatIconModule,
|
||||
MatButtonModule,
|
||||
MatCheckboxModule,
|
||||
MatPaginatorModule
|
||||
]
|
||||
})
|
||||
export class AmlRulesComponent implements OnInit {
|
||||
private clientsService = inject(ClientsService);
|
||||
|
||||
rules = new MatTableDataSource<any>([]);
|
||||
displayedColumns: string[] = [
|
||||
'orderPosition',
|
||||
'ruleName',
|
||||
'riskCategory',
|
||||
'reason',
|
||||
'amlCheckStatus',
|
||||
'bannedEntityMatch'
|
||||
];
|
||||
|
||||
@ViewChild(MatPaginator) paginator!: MatPaginator;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.clientsService.getClientRiskRules().subscribe({
|
||||
next: (data) => {
|
||||
this.rules.data = data || [];
|
||||
this.rules.paginator = this.paginator;
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
}
|
||||
|
||||
getRiskCategoryLabel(category: number): string {
|
||||
switch (category) {
|
||||
case 1:
|
||||
return 'Low Risk';
|
||||
case 2:
|
||||
return 'Medium Risk';
|
||||
case 3:
|
||||
return 'High Risk';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
getRiskColorClass(category: number): string {
|
||||
switch (category) {
|
||||
case 1:
|
||||
return 'risk-low';
|
||||
case 2:
|
||||
return 'risk-medium';
|
||||
case 3:
|
||||
return 'risk-high';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { Routes, RouterModule } from '@angular/router';
|
||||
|
||||
import { Route } from '../core/route/route.service';
|
||||
import { RbiCircularsComponent } from './rbi-circulars.component';
|
||||
import { AmlRulesComponent } from './aml-rules/aml-rules.component';
|
||||
|
||||
const routes: Routes = [
|
||||
Route.withShell([
|
||||
@@ -18,6 +19,11 @@ const routes: Routes = [
|
||||
path: 'rbi-circulars',
|
||||
component: RbiCircularsComponent,
|
||||
data: { title: 'RBI Circulars', breadcrumb: 'RBI Circulars' }
|
||||
},
|
||||
{
|
||||
path: 'rbi/aml',
|
||||
component: AmlRulesComponent,
|
||||
data: { title: 'AML Rules Master', breadcrumb: 'AML Rules Master' }
|
||||
}
|
||||
])
|
||||
];
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
<!--
|
||||
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/.
|
||||
-->
|
||||
<mat-card class="container">
|
||||
<div class="table-header">
|
||||
<div class="table-header-row">
|
||||
<div class="table-title-group">
|
||||
<h2 class="table-title">
|
||||
<div class="t-logomark"><span></span><span></span><span></span><span></span></div>
|
||||
Share Holders
|
||||
</h2>
|
||||
<span class="table-count" *ngIf="totalRows">{{ totalRows }} records</span>
|
||||
</div>
|
||||
|
||||
<div class="table-buttons"></div>
|
||||
</div>
|
||||
|
||||
<div class="table-filters">
|
||||
<mat-form-field class="table-search" appearance="outline">
|
||||
<input matInput placeholder="Search by client name or account number" [formControl]="searchControl" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field class="table-filter" appearance="outline">
|
||||
<mat-select [(value)]="branchFilter" (selectionChange)="applyFilters()" placeholder="Branch">
|
||||
<mat-option value="">All Branches</mat-option>
|
||||
<mat-option *ngFor="let office of offices" [value]="office.id">
|
||||
{{ office.name }}
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field class="table-filter" appearance="outline">
|
||||
<mat-select [(value)]="productFilter" (selectionChange)="applyFilters()" placeholder="Share Product">
|
||||
<mat-option value="">All Products</mat-option>
|
||||
<mat-option *ngFor="let product of products" [value]="product.id">
|
||||
{{ product.name }}
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field class="table-filter" appearance="outline">
|
||||
<mat-select [(value)]="statusFilter" (selectionChange)="applyFilters()" placeholder="Status">
|
||||
<mat-option value="">All Status</mat-option>
|
||||
<mat-option value="active">Active</mat-option>
|
||||
<mat-option value="approved">Approved</mat-option>
|
||||
<mat-option value="submitted and pending approval">Pending</mat-option>
|
||||
<mat-option value="closed">Closed</mat-option>
|
||||
<mat-option value="rejected">Rejected</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field class="table-filter" appearance="outline" (click)="picker.open()">
|
||||
<mat-date-range-input [formGroup]="dateRangeForm" [rangePicker]="picker">
|
||||
<input matStartDate formControlName="start" placeholder="Start date" (dateChange)="applyFilters()" />
|
||||
<input matEndDate formControlName="end" placeholder="End date" (dateChange)="applyFilters()" />
|
||||
</mat-date-range-input>
|
||||
<mat-datepicker-toggle matSuffix [for]="picker"></mat-datepicker-toggle>
|
||||
<mat-date-range-picker #picker></mat-date-range-picker>
|
||||
</mat-form-field>
|
||||
|
||||
<button mat-icon-button matTooltip="Clear Filters" (click)="clearFilters()">
|
||||
<mat-icon>clear</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<mat-progress-bar *ngIf="isLoading" mode="indeterminate" class="table-loader"></mat-progress-bar>
|
||||
|
||||
<table mat-table [dataSource]="dataSource" matSort class="modern-table">
|
||||
<!-- Member Name Column (clientName) -->
|
||||
<ng-container matColumnDef="clientName">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Client Name</th>
|
||||
<td mat-cell *matCellDef="let element">{{ element.clientName || 'N/A' }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Folio Number Column (accountNo) -->
|
||||
<ng-container matColumnDef="accountNo">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Account No</th>
|
||||
<td mat-cell *matCellDef="let element">{{ element.accountNo || 'N/A' }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Class Column (productName) -->
|
||||
<ng-container matColumnDef="productName">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Share Product Name</th>
|
||||
<td mat-cell *matCellDef="let element">{{ element.productName || 'N/A' }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Approved Shares Column -->
|
||||
<ng-container matColumnDef="totalApprovedShares">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Approved Shares</th>
|
||||
<td mat-cell *matCellDef="let element">{{ element.totalApprovedShares || 0 }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Pending Shares Column -->
|
||||
<ng-container matColumnDef="totalPendingForApprovalShares">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Pending Shares</th>
|
||||
<td mat-cell *matCellDef="let element">{{ element.totalPendingForApprovalShares || 0 }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Currency Column -->
|
||||
<ng-container matColumnDef="currency">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Currency</th>
|
||||
<td mat-cell *matCellDef="let element">{{ element.currency?.code || 'N/A' }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Activated Date Column -->
|
||||
<ng-container matColumnDef="activatedDate">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Activated On</th>
|
||||
<td mat-cell *matCellDef="let element">
|
||||
{{ element.activatedDate ? (element.activatedDate | dateFormat) : 'Not Activated' }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<!-- We can route to /shares/{accountId} or /clients/{clientId}/shares-accounts/{accountId}/general depending on preference -->
|
||||
<tr
|
||||
mat-row
|
||||
*matRowDef="let row; columns: displayedColumns"
|
||||
[routerLink]="['/clients', row.clientId, 'shares-accounts', row.id, 'general']"
|
||||
></tr>
|
||||
</table>
|
||||
|
||||
<mat-paginator [pageSizeOptions]="[10, 25, 50, 100]" showFirstLastButtons></mat-paginator>
|
||||
</div>
|
||||
</mat-card>
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
.container {
|
||||
margin: 1.5rem;
|
||||
padding: 0;
|
||||
border-radius: var(--radius-xl);
|
||||
background: var(--bg-card);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.table-header {
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
|
||||
.table-header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.table-title-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
|
||||
.table-title {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-main);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
letter-spacing: -0.01em;
|
||||
|
||||
.t-logomark {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px;
|
||||
padding: 2px;
|
||||
background: var(--bg-hover);
|
||||
border-radius: 6px;
|
||||
|
||||
span {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
background: var(--primary-main);
|
||||
opacity: 0.8;
|
||||
|
||||
&:nth-child(2) {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
&:nth-child(4) {
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.table-count {
|
||||
padding: 0.25rem 0.75rem;
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-muted);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
}
|
||||
|
||||
.table-filters {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
|
||||
mat-form-field {
|
||||
width: 240px;
|
||||
margin-bottom: -1.25em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.table-container {
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
border-radius: 0 0 var(--radius-xl) var(--radius-xl);
|
||||
|
||||
.table-loader {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
}
|
||||
|
||||
.modern-table {
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
|
||||
tr.mat-row {
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* 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, ViewChild, inject } from '@angular/core';
|
||||
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
|
||||
import { MatPaginator, MatPaginatorModule } from '@angular/material/paginator';
|
||||
import { MatSort, MatSortModule } from '@angular/material/sort';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||
import { MatNativeDateModule } from '@angular/material/core';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { FormsModule, ReactiveFormsModule, FormGroup, FormControl } from '@angular/forms';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { catchError, debounceTime, distinctUntilChanged, filter, switchMap, tap, startWith } from 'rxjs/operators';
|
||||
import { DateFormatPipe } from '../../pipes/date-format.pipe';
|
||||
import { SharesService } from '../shares.service';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-share-holders',
|
||||
templateUrl: './share-holders.component.html',
|
||||
styleUrls: ['./share-holders.component.scss'],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterModule,
|
||||
MatCardModule,
|
||||
MatTableModule,
|
||||
MatPaginatorModule,
|
||||
MatSortModule,
|
||||
MatProgressBarModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
MatDatepickerModule,
|
||||
MatNativeDateModule,
|
||||
MatIconModule,
|
||||
MatButtonModule,
|
||||
MatTooltipModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
DateFormatPipe
|
||||
]
|
||||
})
|
||||
export class ShareHoldersComponent implements OnInit {
|
||||
displayedColumns: string[] = [
|
||||
'clientName',
|
||||
'accountNo',
|
||||
'productName',
|
||||
'totalApprovedShares',
|
||||
'totalPendingForApprovalShares',
|
||||
'currency',
|
||||
'activatedDate'
|
||||
];
|
||||
dataSource = new MatTableDataSource<any>();
|
||||
isLoading = true;
|
||||
totalRows = 0;
|
||||
|
||||
// Filter Data
|
||||
offices: any[] = [];
|
||||
products: any[] = [];
|
||||
|
||||
// Filter State
|
||||
searchValue = '';
|
||||
branchFilter = '';
|
||||
productFilter = '';
|
||||
statusFilter = '';
|
||||
dateRangeForm = new FormGroup({
|
||||
start: new FormControl<Date | null>(null),
|
||||
end: new FormControl<Date | null>(null)
|
||||
});
|
||||
|
||||
@ViewChild(MatPaginator, { static: true }) paginator!: MatPaginator;
|
||||
@ViewChild(MatSort, { static: true }) sort!: MatSort;
|
||||
|
||||
searchControl = new FormControl('');
|
||||
|
||||
private sharesService = inject(SharesService);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.dataSource.paginator = this.paginator;
|
||||
this.dataSource.sort = this.sort;
|
||||
this.setupFilterPredicate();
|
||||
|
||||
this.searchControl.valueChanges
|
||||
.pipe(
|
||||
startWith(''),
|
||||
debounceTime(400),
|
||||
distinctUntilChanged(),
|
||||
filter((term: string | null) => !term || term.trim().length === 0 || term.trim().length >= 2),
|
||||
tap((term: string | null) => {
|
||||
this.searchValue = term || '';
|
||||
this.isLoading = true;
|
||||
}),
|
||||
switchMap((term: string | null) => {
|
||||
const search = term ? term.trim() : '';
|
||||
return forkJoin({
|
||||
accounts: this.sharesService.getShareAccountSummaries(search || undefined).pipe(catchError(() => of([]))),
|
||||
offices: this.sharesService.getOffices().pipe(catchError(() => of([]))),
|
||||
products: this.sharesService.getShareProducts().pipe(catchError(() => of([])))
|
||||
});
|
||||
})
|
||||
)
|
||||
.subscribe({
|
||||
next: (results: any) => {
|
||||
this.offices = results.offices || [];
|
||||
|
||||
const allShareAccounts = results.accounts || [];
|
||||
|
||||
// Derive unique products from the actual active share accounts to ensure no garbage/dummy
|
||||
// products (like NextGen Tech Pvt Ltd) show up in the dropdown.
|
||||
const uniqueProductsMap = new Map();
|
||||
allShareAccounts.forEach((acc: any) => {
|
||||
if (acc.productId && acc.productName) {
|
||||
uniqueProductsMap.set(acc.productId, { id: acc.productId, name: acc.productName });
|
||||
}
|
||||
});
|
||||
this.products = Array.from(uniqueProductsMap.values());
|
||||
|
||||
this.dataSource.data = allShareAccounts;
|
||||
this.totalRows = allShareAccounts.length;
|
||||
this.isLoading = false;
|
||||
},
|
||||
error: () => {
|
||||
this.isLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setupFilterPredicate() {
|
||||
this.dataSource.filterPredicate = (data: any, filter: string) => {
|
||||
let branchMatch = true;
|
||||
if (this.branchFilter) {
|
||||
branchMatch = data.officeId?.toString() === this.branchFilter.toString();
|
||||
}
|
||||
|
||||
let productMatch = true;
|
||||
if (this.productFilter) {
|
||||
productMatch = data.productId?.toString() === this.productFilter.toString();
|
||||
}
|
||||
|
||||
let statusMatch = true;
|
||||
if (this.statusFilter) {
|
||||
statusMatch =
|
||||
data.status?.value?.toLowerCase() === this.statusFilter.toLowerCase() ||
|
||||
data.status?.id?.toString() === this.statusFilter.toString();
|
||||
}
|
||||
|
||||
let dateMatch = true;
|
||||
if (this.dateRangeForm.value.start && data.activatedDate) {
|
||||
const rowDate = new Date(data.activatedDate[0], data.activatedDate[1] - 1, data.activatedDate[2]);
|
||||
const start = this.dateRangeForm.value.start;
|
||||
const end = this.dateRangeForm.value.end || new Date(); // If no end date, use today
|
||||
|
||||
// Reset hours for accurate comparison
|
||||
rowDate.setHours(0, 0, 0, 0);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
end.setHours(0, 0, 0, 0);
|
||||
|
||||
dateMatch = rowDate >= start && rowDate <= end;
|
||||
}
|
||||
|
||||
return branchMatch && productMatch && statusMatch && dateMatch;
|
||||
};
|
||||
}
|
||||
|
||||
applyFilters() {
|
||||
// We update the filter string to a random/unique value to trigger the filterPredicate
|
||||
this.dataSource.filter = Math.random().toString();
|
||||
}
|
||||
|
||||
search(filterValue: string) {
|
||||
this.searchControl.setValue(filterValue);
|
||||
}
|
||||
|
||||
clearFilters() {
|
||||
this.searchValue = '';
|
||||
this.branchFilter = '';
|
||||
this.productFilter = '';
|
||||
this.statusFilter = '';
|
||||
this.dateRangeForm.reset();
|
||||
|
||||
// Trigger server-side reload without search term
|
||||
this.searchControl.setValue('', { emitEvent: true });
|
||||
// Trigger client-side filters update
|
||||
this.applyFilters();
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ import { DividendsTabComponent } from './shares-account-view/dividends-tab/divid
|
||||
import { CreateSharesAccountComponent } from './create-shares-account/create-shares-account.component';
|
||||
import { EditSharesAccountComponent } from './edit-shares-account/edit-shares-account.component';
|
||||
import { SharesAccountActionsComponent } from './shares-account-actions/shares-account-actions.component';
|
||||
import { EditShareAccountDetailsComponent } from './edit-share-account-details/edit-share-account-details.component';
|
||||
import { ShareHoldersComponent } from './share-holders/share-holders.component';
|
||||
|
||||
/** Custom Resolvers */
|
||||
import { SharesAccountViewResolver } from './common-resolvers/share-account-view.resolver';
|
||||
@@ -33,6 +33,11 @@ const routes: Routes = [
|
||||
path: '',
|
||||
data: { title: 'Shares', breadcrumb: 'Shares', routeParamBreadcrumb: false, addBreadcrumbLink: false },
|
||||
children: [
|
||||
{
|
||||
path: 'share-holders',
|
||||
data: { title: 'Share Holders', breadcrumb: 'Share Holders' },
|
||||
component: ShareHoldersComponent
|
||||
},
|
||||
{
|
||||
path: 'create',
|
||||
data: { title: 'Create Shares Account', breadcrumb: 'Create Shares Account' },
|
||||
@@ -90,14 +95,6 @@ const routes: Routes = [
|
||||
sharesAccountAndTemplate: SharesAccountAndTemplateResolver
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'edit-details',
|
||||
data: { title: 'Edit Share Account Details', breadcrumb: 'Edit Details', routeParamBreadcrumb: false },
|
||||
component: EditShareAccountDetailsComponent,
|
||||
resolve: {
|
||||
sharesAccountAndTemplate: SharesAccountAndTemplateResolver
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'actions/:name',
|
||||
data: { title: 'Shares Account Actions', breadcrumb: 'Actions', routeParamBreadcrumb: 'name' },
|
||||
|
||||
@@ -37,6 +37,15 @@ import { ApproveShareDialogComponent } from './shares-account-actions/approve-sh
|
||||
import { RejectSharesComponent } from './shares-account-actions/reject-shares/reject-shares.component';
|
||||
import { RejectShareDialogComponent } from './shares-account-actions/reject-shares/reject-share-dialog/reject-share-dialog.component';
|
||||
import { GeneralTabComponent } from './shares-account-view/general-tab/general-tab.component';
|
||||
import { ShareHoldersComponent } from './share-holders/share-holders.component';
|
||||
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatPaginatorModule } from '@angular/material/paginator';
|
||||
import { MatSortModule } from '@angular/material/sort';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
|
||||
/**
|
||||
* Shares Module
|
||||
@@ -45,6 +54,15 @@ import { GeneralTabComponent } from './shares-account-view/general-tab/general-t
|
||||
*/
|
||||
@NgModule({
|
||||
imports: [
|
||||
ShareHoldersComponent,
|
||||
SharedModule,
|
||||
MatTableModule,
|
||||
MatPaginatorModule,
|
||||
MatSortModule,
|
||||
MatCardModule,
|
||||
MatProgressBarModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
SharedModule,
|
||||
PipesModule,
|
||||
DirectivesModule,
|
||||
|
||||
@@ -22,6 +22,39 @@ import { Observable } from 'rxjs';
|
||||
export class SharesService {
|
||||
private http = inject(HttpClient);
|
||||
|
||||
/**
|
||||
* @param {any} params Optional HTTP params (like offset, limit, search)
|
||||
* @returns {Observable<any>} List of all share accounts
|
||||
*/
|
||||
getSharesAccounts(params?: any): Observable<any> {
|
||||
return this.http.get('/accounts/share', { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Observable<any>} List of all share accounts with client details summary
|
||||
*/
|
||||
getShareAccountSummaries(searchTerm?: string): Observable<any> {
|
||||
let params = new HttpParams();
|
||||
if (searchTerm) {
|
||||
params = params.set('searchTerm', searchTerm);
|
||||
}
|
||||
return this.http.get('/shareaccounts/summary', { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Observable<any>} List of all offices
|
||||
*/
|
||||
getOffices(): Observable<any> {
|
||||
return this.http.get('/offices');
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Observable<any>} List of all share products
|
||||
*/
|
||||
getShareProducts(): Observable<any> {
|
||||
return this.http.get('/products/share');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} accountId Shares Account Id of account to get data for.
|
||||
* @param {boolean} template Shares account template required?.
|
||||
|
||||
+80
-33
@@ -6,29 +6,73 @@
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
@if (showData) {
|
||||
<div class="cv-tab-content">
|
||||
<div class="cv-action-bar">
|
||||
<mat-form-field appearance="outline" class="cv-search-field">
|
||||
<mat-icon matPrefix>search</mat-icon>
|
||||
<input
|
||||
#filterInput
|
||||
matInput
|
||||
placeholder="{{ 'labels.text.Filter by name' | translate }}"
|
||||
(keyup)="applyFilter(filterInput.value)"
|
||||
/>
|
||||
</mat-form-field>
|
||||
<div class="cv-action-buttons">
|
||||
<button
|
||||
mat-stroked-button
|
||||
class="cv-btn cv-btn-approve"
|
||||
*mifosxHasPermission="'ACTIVATE_CLIENT'"
|
||||
(click)="approveClients()"
|
||||
>
|
||||
<fa-icon icon="check" class="m-r-10"></fa-icon>{{ 'labels.buttons.Approve' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="cv-tab-content">
|
||||
<div class="cv-action-bar">
|
||||
<div class="cv-search-box">
|
||||
<fa-icon icon="search"></fa-icon>
|
||||
<input
|
||||
#filterInput
|
||||
type="text"
|
||||
placeholder="{{ 'labels.text.Filter by name' | translate }}"
|
||||
(keyup.enter)="applyFilter(filterInput.value)"
|
||||
/>
|
||||
</div>
|
||||
<div class="cv-quick-filters">
|
||||
@for (p of datePresets; track p.key) {
|
||||
<button
|
||||
type="button"
|
||||
class="cv-chip"
|
||||
[class.cv-chip--active]="activePreset === p.key"
|
||||
(click)="applyPreset(p.key)"
|
||||
>
|
||||
{{ p.label }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div class="cv-action-buttons">
|
||||
@if (selection.selected.length > 0) {
|
||||
<span class="cv-selected-count">{{ selection.selected.length }} selected</span>
|
||||
}
|
||||
<button
|
||||
mat-stroked-button
|
||||
class="cv-btn cv-btn-approve"
|
||||
*mifosxHasPermission="'ACTIVATE_CLIENT'"
|
||||
[disabled]="!selection.hasValue()"
|
||||
(click)="approveClients()"
|
||||
>
|
||||
<fa-icon icon="check" class="m-r-10"></fa-icon>{{ 'labels.buttons.Approve' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (loading) {
|
||||
<div class="cv-empty">
|
||||
<div class="cv-spinner"></div>
|
||||
<p class="cv-empty__text">Loading pending clients…</p>
|
||||
</div>
|
||||
} @else if (loaded && !showData) {
|
||||
<div class="cv-empty">
|
||||
<div class="cv-empty__badge">
|
||||
<fa-icon icon="user-check"></fa-icon>
|
||||
</div>
|
||||
@if (searchTerm) {
|
||||
<p class="cv-empty__title">No matching clients</p>
|
||||
<p class="cv-empty__text">
|
||||
No pending client matches “{{ searchTerm }}”. Try
|
||||
<a class="cv-empty__link" (click)="applyFilter(''); filterInput.value = ''">clearing the search</a>.
|
||||
</p>
|
||||
} @else if (activePreset !== 'all') {
|
||||
<p class="cv-empty__title">No requests in this range</p>
|
||||
<p class="cv-empty__text">
|
||||
No client requests were submitted in this range. Try
|
||||
<a class="cv-empty__link" (click)="applyPreset('all')">All requests</a> instead.
|
||||
</p>
|
||||
} @else {
|
||||
<p class="cv-empty__title">{{ 'labels.text.No pending loans for approval' | translate }}</p>
|
||||
<p class="cv-empty__text">Every client request has been reviewed — nothing waiting on you right now.</p>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
@for (client of groupedClients | keyvalue; track client) {
|
||||
<h3 class="cv-group-title">{{ client.key }}</h3>
|
||||
@if (client.value.length > 0) {
|
||||
@@ -60,8 +104,8 @@
|
||||
{{ element.displayName }}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="accountNumber">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Account Number' | translate }}</th>
|
||||
<ng-container matColumnDef="clientId">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Client ID' | translate }}</th>
|
||||
<td
|
||||
mat-cell
|
||||
*matCellDef="let element"
|
||||
@@ -69,7 +113,7 @@
|
||||
[routerLink]="['../../clients', element.id]"
|
||||
style="cursor: pointer"
|
||||
>
|
||||
{{ element.accountNo }}
|
||||
{{ element.id }}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="staff">
|
||||
@@ -94,12 +138,15 @@
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!showData) {
|
||||
<div class="empty-state">
|
||||
<i class="fas fa-user-check empty-icon"></i>
|
||||
<p class="empty-text">{{ 'labels.text.No pending loans for approval' | translate }}</p>
|
||||
</div>
|
||||
}
|
||||
<mat-paginator
|
||||
class="cv-paginator"
|
||||
[length]="totalRecords"
|
||||
[pageSize]="pageSize"
|
||||
[pageSizeOptions]="pageSizeOptions"
|
||||
[pageIndex]="pageIndex"
|
||||
showFirstLastButtons
|
||||
(page)="onPageChange($event)"
|
||||
></mat-paginator>
|
||||
}
|
||||
</div>
|
||||
|
||||
+207
-42
@@ -9,9 +9,14 @@
|
||||
@use '../../../shared/styles/list-layout' as *;
|
||||
|
||||
$brand: #1353a0;
|
||||
$brand-tint: #eaf1fb;
|
||||
$success: #16a34a;
|
||||
$success-tint: #ecfdf5;
|
||||
$border: #e2e8f0;
|
||||
$text: #0f172a;
|
||||
$text-muted: #64748b;
|
||||
$surface: #fff;
|
||||
$surface-muted: #f8fafc;
|
||||
|
||||
.cv-tab-content {
|
||||
padding: 16px 0;
|
||||
@@ -20,93 +25,252 @@ $text-muted: #64748b;
|
||||
.cv-action-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
padding: 10px 14px;
|
||||
background: $surface;
|
||||
border: 1px solid $border;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 2px rgb(15 23 42 / 4%);
|
||||
}
|
||||
|
||||
.cv-search-field {
|
||||
.cv-search-box {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
max-width: 320px;
|
||||
height: 34px;
|
||||
padding: 0 12px;
|
||||
background: $surface-muted;
|
||||
border: 1px solid $border;
|
||||
border-radius: 8px;
|
||||
transition:
|
||||
background 0.15s,
|
||||
border-color 0.15s,
|
||||
box-shadow 0.15s;
|
||||
|
||||
::ng-deep {
|
||||
.mat-mdc-form-field-flex {
|
||||
height: 32px;
|
||||
fa-icon {
|
||||
color: $text-muted;
|
||||
font-size: 13px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
color: $text;
|
||||
|
||||
&::placeholder {
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
.mat-mdc-form-field-infix {
|
||||
padding: 4px 0;
|
||||
min-height: 32px;
|
||||
}
|
||||
&:focus-within {
|
||||
background: $surface;
|
||||
border-color: $brand;
|
||||
box-shadow: 0 0 0 2px rgb(19 83 160 / 18%);
|
||||
}
|
||||
}
|
||||
|
||||
.mat-mdc-form-field-subscript-wrapper {
|
||||
height: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
.cv-quick-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mdc-notched-outline {
|
||||
.mdc-notched-outline__leading {
|
||||
border-radius: 8px 0 0 8px;
|
||||
}
|
||||
.cv-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
font-family: 'DM Sans', Roboto, sans-serif;
|
||||
color: $text-muted;
|
||||
background: $surface;
|
||||
border: 1px solid $border;
|
||||
border-radius: 17px;
|
||||
height: 34px;
|
||||
padding: 0 14px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s,
|
||||
color 0.15s,
|
||||
border-color 0.15s,
|
||||
box-shadow 0.15s;
|
||||
|
||||
.mdc-notched-outline__trailing {
|
||||
border-radius: 0 8px 8px 0;
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
background: $surface-muted;
|
||||
border-color: #cbd5e1;
|
||||
}
|
||||
|
||||
.mat-mdc-form-field-icon-prefix {
|
||||
padding: 0 6px 0 8px;
|
||||
color: $text-muted;
|
||||
}
|
||||
&--active {
|
||||
background: $brand;
|
||||
border-color: $brand;
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 6px rgb(19 83 160 / 30%);
|
||||
|
||||
input {
|
||||
font-size: 13px;
|
||||
&:hover {
|
||||
background: $brand;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cv-action-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cv-selected-count {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: $brand;
|
||||
background: $brand-tint;
|
||||
border-radius: 14px;
|
||||
padding: 6px 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cv-btn {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
height: 32px;
|
||||
padding: 0 14px;
|
||||
height: 34px;
|
||||
padding: 0 16px;
|
||||
font-family: 'DM Sans', Roboto, sans-serif;
|
||||
letter-spacing: 0.1px;
|
||||
|
||||
fa-icon {
|
||||
margin-right: 4px;
|
||||
margin-right: 6px;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
.cv-btn-approve {
|
||||
border-color: #16a34a;
|
||||
color: #16a34a;
|
||||
border-color: $success;
|
||||
color: $success;
|
||||
|
||||
&:hover {
|
||||
background: #f0fdf4;
|
||||
&:hover:not([disabled]) {
|
||||
background: $success-tint;
|
||||
}
|
||||
|
||||
&[disabled] {
|
||||
opacity: 0.45;
|
||||
}
|
||||
}
|
||||
|
||||
.cv-group-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: $text;
|
||||
margin: 20px 0 8px;
|
||||
padding-bottom: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.2px;
|
||||
text-transform: uppercase;
|
||||
color: $text-muted;
|
||||
margin: 22px 0 8px;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: $brand;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.cv-group-table {
|
||||
margin-bottom: 12px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
border: 1px solid $border;
|
||||
}
|
||||
|
||||
.cv-paginator {
|
||||
margin-top: 8px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
// ===== Empty / loading state — scoped locally so it doesn't touch the shared
|
||||
// .empty-state used by every other checker-inbox tab. =====
|
||||
.cv-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 56px 24px;
|
||||
text-align: center;
|
||||
background: $surface;
|
||||
border: 1px solid $border;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.cv-empty__badge {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: $success-tint;
|
||||
border: 1px solid #a7f3d0;
|
||||
color: $success;
|
||||
font-size: 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.cv-empty__title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: $text;
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
.cv-empty__text {
|
||||
font-size: 13px;
|
||||
color: $text-muted;
|
||||
margin: 0;
|
||||
max-width: 360px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.cv-empty__link {
|
||||
color: $brand;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.cv-spinner {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid $border;
|
||||
border-top-color: $brand;
|
||||
margin-bottom: 14px;
|
||||
animation: cv-spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes cv-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
@@ -121,5 +285,6 @@ $text-muted: #64748b;
|
||||
|
||||
.cv-action-buttons {
|
||||
margin-left: 0;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
+109
-42
@@ -8,11 +8,9 @@
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
import { SelectionModel } from '@angular/cdk/collections';
|
||||
import * as _ from 'lodash';
|
||||
import {
|
||||
MatTableDataSource,
|
||||
MatTable,
|
||||
MatColumnDef,
|
||||
MatHeaderCellDef,
|
||||
@@ -24,6 +22,7 @@ import {
|
||||
MatRowDef,
|
||||
MatRow
|
||||
} from '@angular/material/table';
|
||||
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
|
||||
/** Dialog Imports */
|
||||
@@ -41,6 +40,12 @@ import { MatCheckbox } from '@angular/material/checkbox';
|
||||
import { AccountsFilterPipe } from '../../../pipes/accounts-filter.pipe';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
/**
|
||||
* Checker-facing list of Clients pending approval.
|
||||
* Pagination, name search, and submitted-date filtering all happen server-side via the
|
||||
* standard /clients list API (offset/limit, displayName, fromDate/toDate) — mirrors
|
||||
* PendingClientEditsComponent's load()/onPageChange()/applyPreset() pattern.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-client-approval',
|
||||
templateUrl: './client-approval.component.html',
|
||||
@@ -60,53 +65,122 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
MatRowDef,
|
||||
MatRow,
|
||||
KeyValuePipe,
|
||||
AccountsFilterPipe
|
||||
AccountsFilterPipe,
|
||||
MatPaginatorModule
|
||||
]
|
||||
})
|
||||
export class ClientApprovalComponent {
|
||||
private route = inject(ActivatedRoute);
|
||||
export class ClientApprovalComponent implements OnInit {
|
||||
private dialog = inject(MatDialog);
|
||||
private dateUtils = inject(Dates);
|
||||
private router = inject(Router);
|
||||
private settingsService = inject(SettingsService);
|
||||
private tasksService = inject(TasksService);
|
||||
|
||||
/** Grouped Clients Data */
|
||||
groupedClients: any;
|
||||
/** Checks to show the data */
|
||||
/** Current page of pending clients (from the server), grouped by office for display */
|
||||
groupedClients: any = {};
|
||||
/** True once the first page has loaded and it contains at least one client */
|
||||
showData = false;
|
||||
loading = false;
|
||||
loaded = false;
|
||||
/** Row Selection Data — selection lives across the whole session, not just the current page */
|
||||
selection = new SelectionModel<any>(true, []);
|
||||
/** Batch Requests */
|
||||
batchRequests: any[];
|
||||
/** Datasource */
|
||||
dataSource: MatTableDataSource<any>;
|
||||
/** Row Selection Data */
|
||||
selection: SelectionModel<any>;
|
||||
/** Displayed Columns */
|
||||
displayedColumns: string[] = [
|
||||
'select',
|
||||
'name',
|
||||
'accountNumber',
|
||||
'clientId',
|
||||
'staff'
|
||||
];
|
||||
|
||||
/**
|
||||
* Retrieves the grouped client data from `resolve`.
|
||||
* @param {ActivatedRoute} route Activated Route.
|
||||
* @param {Dialog} dialog MatDialog.
|
||||
* @param {Dates} dateUtils Date Utils.
|
||||
* @param {router} router Router.
|
||||
* @param {SettingsService} settingsService Settings Service.
|
||||
* @param {TasksService} tasksService Tasks Service.
|
||||
*/
|
||||
constructor() {
|
||||
this.route.data.subscribe((data: { groupedClientData: any }) => {
|
||||
this.groupedClients = _.groupBy(data.groupedClientData.pageItems, 'officeName');
|
||||
if (Object.keys(this.groupedClients).length) {
|
||||
this.showData = true;
|
||||
}
|
||||
this.dataSource = new MatTableDataSource(data.groupedClientData.pageItems);
|
||||
this.selection = new SelectionModel(true, []);
|
||||
});
|
||||
totalRecords = 0;
|
||||
pageSize = 20;
|
||||
pageIndex = 0;
|
||||
pageSizeOptions = [
|
||||
10,
|
||||
20,
|
||||
50
|
||||
];
|
||||
|
||||
searchTerm = '';
|
||||
activePreset = 'all';
|
||||
readonly datePresets = [
|
||||
{ key: 'all', label: 'All requests' },
|
||||
{ key: 'today', label: 'Today' },
|
||||
{ key: 'yesterday', label: 'Yesterday' }
|
||||
];
|
||||
private fromDate: string | undefined;
|
||||
private toDate: string | undefined;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.tasksService
|
||||
.getGroupedClientsData(
|
||||
this.searchTerm.trim() || undefined,
|
||||
this.fromDate,
|
||||
this.toDate,
|
||||
this.pageIndex * this.pageSize,
|
||||
this.pageSize
|
||||
)
|
||||
.subscribe({
|
||||
next: (res: any) => {
|
||||
const clients = res?.pageItems ?? [];
|
||||
this.groupedClients = _.groupBy(clients, 'officeName');
|
||||
this.totalRecords = Number(res?.totalFilteredRecords ?? 0);
|
||||
this.showData = this.totalRecords > 0 || Object.keys(this.groupedClients).length > 0;
|
||||
this.loading = false;
|
||||
this.loaded = true;
|
||||
},
|
||||
error: () => {
|
||||
this.groupedClients = {};
|
||||
this.totalRecords = 0;
|
||||
this.loading = false;
|
||||
this.loaded = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onPageChange(event: PageEvent): void {
|
||||
this.pageSize = event.pageSize;
|
||||
this.pageIndex = event.pageIndex;
|
||||
this.load();
|
||||
}
|
||||
|
||||
applyFilter(filterValue: string = ''): void {
|
||||
this.searchTerm = filterValue;
|
||||
this.pageIndex = 0;
|
||||
this.load();
|
||||
}
|
||||
|
||||
applyPreset(key: string): void {
|
||||
this.activePreset = key;
|
||||
const today = new Date(this.settingsService.businessDate);
|
||||
today.setHours(0, 0, 0, 0);
|
||||
if (key === 'today') {
|
||||
this.fromDate = this.isoDate(today);
|
||||
this.toDate = this.isoDate(today);
|
||||
} else if (key === 'yesterday') {
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(today.getDate() - 1);
|
||||
this.fromDate = this.isoDate(yesterday);
|
||||
this.toDate = this.isoDate(yesterday);
|
||||
} else {
|
||||
this.fromDate = undefined;
|
||||
this.toDate = undefined;
|
||||
}
|
||||
this.pageIndex = 0;
|
||||
this.load();
|
||||
}
|
||||
|
||||
private isoDate(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
/** Whether the number of selected elements matches the total number of rows. */
|
||||
@@ -183,6 +257,7 @@ export class ClientApprovalComponent {
|
||||
activatedAccounts++;
|
||||
responseEle.body = JSON.parse(responseEle.body);
|
||||
if (selectedAccounts === activatedAccounts) {
|
||||
this.selection.clear();
|
||||
this.reload();
|
||||
}
|
||||
}
|
||||
@@ -190,18 +265,10 @@ export class ClientApprovalComponent {
|
||||
});
|
||||
}
|
||||
|
||||
applyFilter(filterValue: string = '') {
|
||||
this.dataSource.filter = filterValue.trim().toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Refetches data for the component
|
||||
* TODO: Replace by a custom reload component instead of hard-coded back-routing.
|
||||
* Refetches the current page after a bulk approval.
|
||||
*/
|
||||
reload() {
|
||||
const url: string = this.router.url;
|
||||
this.router
|
||||
.navigateByUrl(`/checker-inbox-and-tasks`, { skipLocationChange: true })
|
||||
.then(() => this.router.navigate([url]));
|
||||
this.load();
|
||||
}
|
||||
}
|
||||
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
<!--
|
||||
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/.
|
||||
-->
|
||||
|
||||
<h1 mat-dialog-title>
|
||||
Proposed Changes — Client
|
||||
<a [routerLink]="['/clients', data.clientId, 'general']" mat-dialog-close class="cecd-client-link">{{
|
||||
data.clientDisplayName
|
||||
}}</a>
|
||||
</h1>
|
||||
|
||||
<div mat-dialog-content class="cecd-content">
|
||||
<table class="cecd-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Field</th>
|
||||
<th>Current Value</th>
|
||||
<th>Proposed Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of data.rows; track row.key) {
|
||||
<tr>
|
||||
<td class="cecd-label">{{ row.label }}</td>
|
||||
<td class="cecd-current">{{ row.current }}</td>
|
||||
<td class="cecd-proposed">{{ row.value }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="3" class="cecd-empty">No changed fields detected.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@if (data.status && data.status !== 'PENDING' && data.checkerName) {
|
||||
<p class="cecd-resolution-note">
|
||||
<strong>{{ data.status === 'APPROVED' ? 'Approved' : 'Rejected' }} by</strong> {{ data.checkerName }}
|
||||
@if (data.checkedDate) {
|
||||
on {{ data.checkedDate }}
|
||||
}
|
||||
</p>
|
||||
}
|
||||
|
||||
@if (data.rejectReason) {
|
||||
<p class="cecd-reject-reason"><strong>Reject Reason:</strong> {{ data.rejectReason }}</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-raised-button mat-dialog-close color="primary">Close</button>
|
||||
</mat-dialog-actions>
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
$border: #e2e8f0;
|
||||
$text-muted: #64748b;
|
||||
|
||||
.cecd-client-link {
|
||||
color: #1e40af;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.cecd-content {
|
||||
min-width: 420px;
|
||||
max-width: 70vw;
|
||||
// Explicit scroll behavior rather than relying only on Material's default dialog-content
|
||||
// sizing — with many changed fields this keeps the dialog itself a fixed height (Close button
|
||||
// always visible) and scrolls just the table.
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.cecd-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12.5px;
|
||||
|
||||
th,
|
||||
td {
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid $border;
|
||||
}
|
||||
|
||||
th {
|
||||
color: $text-muted;
|
||||
font-weight: 600;
|
||||
// Stays visible while scrolling through a long list of changed fields.
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: #fff;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.cecd-label {
|
||||
font-weight: 600;
|
||||
color: #0d1b2a;
|
||||
}
|
||||
|
||||
.cecd-current {
|
||||
color: #b91c1c;
|
||||
text-decoration: line-through;
|
||||
text-decoration-color: #fca5a5;
|
||||
}
|
||||
|
||||
.cecd-proposed {
|
||||
color: #15803d;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cecd-empty {
|
||||
color: $text-muted;
|
||||
font-size: 12.5px;
|
||||
text-align: center;
|
||||
padding: 16px 10px;
|
||||
}
|
||||
|
||||
.cecd-resolution-note {
|
||||
margin-top: 14px;
|
||||
font-size: 12.5px;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.cecd-reject-reason {
|
||||
margin-top: 14px;
|
||||
font-size: 12.5px;
|
||||
color: #b91c1c;
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* 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,
|
||||
MatDialogContent,
|
||||
MatDialogActions,
|
||||
MatDialogClose
|
||||
} from '@angular/material/dialog';
|
||||
import { CdkScrollable } from '@angular/cdk/scrolling';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
export interface ClientEditCompareRow {
|
||||
key: string;
|
||||
label: string;
|
||||
current: any;
|
||||
value: any;
|
||||
}
|
||||
|
||||
export interface ClientEditCompareDialogData {
|
||||
clientId: string;
|
||||
clientDisplayName: string;
|
||||
rows: ClientEditCompareRow[];
|
||||
status?: string;
|
||||
checkerName?: string;
|
||||
checkedDate?: string | null;
|
||||
rejectReason?: string;
|
||||
}
|
||||
|
||||
/** Popup version of the Pending Client Edits "Compare" panel — shows the Field/Current/Proposed
|
||||
* diff in an in-app modal instead of an inline expansion below the table. */
|
||||
@Component({
|
||||
selector: 'mifosx-client-edit-compare-dialog',
|
||||
templateUrl: './client-edit-compare-dialog.component.html',
|
||||
styleUrls: ['./client-edit-compare-dialog.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
MatDialogTitle,
|
||||
CdkScrollable,
|
||||
MatDialogContent,
|
||||
MatDialogActions,
|
||||
MatDialogClose
|
||||
]
|
||||
})
|
||||
export class ClientEditCompareDialogComponent {
|
||||
dialogRef = inject<MatDialogRef<ClientEditCompareDialogComponent>>(MatDialogRef);
|
||||
data = inject<ClientEditCompareDialogData>(MAT_DIALOG_DATA);
|
||||
}
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
<!--
|
||||
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/.
|
||||
-->
|
||||
|
||||
<div class="pce-tab-content">
|
||||
<div class="pce-action-bar">
|
||||
<div class="pce-search-box">
|
||||
<mat-icon>search</mat-icon>
|
||||
<input
|
||||
#filterInput
|
||||
type="text"
|
||||
placeholder="Search by client name"
|
||||
(keyup.enter)="applyFilter(filterInput.value)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select class="pce-filter-select" [(ngModel)]="filterStatus" (change)="onFilterChange()">
|
||||
@for (opt of statusOptions; track opt.value) {
|
||||
<option [value]="opt.value">{{ opt.label }}</option>
|
||||
}
|
||||
</select>
|
||||
|
||||
<span class="pce-fb-sep"></span>
|
||||
|
||||
<div class="pce-presets">
|
||||
@for (p of datePresets; track p.key) {
|
||||
<button
|
||||
type="button"
|
||||
class="pce-preset-chip"
|
||||
[class.pce-preset-chip--active]="activePreset === p.key"
|
||||
(click)="applyPreset(p.key)"
|
||||
>
|
||||
{{ p.label }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<span class="pce-fb-sep"></span>
|
||||
|
||||
<div class="pce-date-range">
|
||||
<div class="pce-date-box">
|
||||
<span class="pce-date-box__lbl">From</span>
|
||||
<input
|
||||
class="pce-date-box__input"
|
||||
[matDatepicker]="fromPicker"
|
||||
[(ngModel)]="fromDate"
|
||||
[max]="maxDate"
|
||||
(dateChange)="onFromDateChange()"
|
||||
readonly
|
||||
/>
|
||||
<button type="button" class="pce-date-box__btn" (click)="fromPicker.open()">
|
||||
<mat-icon>event</mat-icon>
|
||||
</button>
|
||||
<mat-datepicker #fromPicker></mat-datepicker>
|
||||
</div>
|
||||
<span class="pce-date-range__sep">—</span>
|
||||
<div class="pce-date-box">
|
||||
<span class="pce-date-box__lbl">To</span>
|
||||
<input
|
||||
class="pce-date-box__input"
|
||||
[matDatepicker]="toPicker"
|
||||
[(ngModel)]="toDate"
|
||||
[min]="fromDate"
|
||||
[max]="maxDate"
|
||||
(dateChange)="onToDateChange()"
|
||||
readonly
|
||||
/>
|
||||
<button type="button" class="pce-date-box__btn" (click)="toPicker.open()">
|
||||
<mat-icon>event</mat-icon>
|
||||
</button>
|
||||
<mat-datepicker #toPicker></mat-datepicker>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="pce-btn pce-btn-refresh" (click)="load()" [disabled]="loading">
|
||||
<fa-icon icon="sync-alt"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (loading) {
|
||||
<div class="empty-state">
|
||||
<p class="empty-title">Loading…</p>
|
||||
</div>
|
||||
} @else if (loaded && edits.length === 0) {
|
||||
<div class="empty-state">
|
||||
<fa-icon icon="hourglass-half" class="empty-icon"></fa-icon>
|
||||
<p class="empty-title">No {{ filterStatus | lowercase }} client edits</p>
|
||||
<p class="empty-text">
|
||||
No Client details edits found for this filter. Try
|
||||
<a (click)="applyPreset('all')" class="pce-client-link">All</a> dates, or a different status above.
|
||||
</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="table-container">
|
||||
<table mat-table [dataSource]="edits" class="modern-table">
|
||||
<ng-container matColumnDef="clientDisplayName">
|
||||
<th mat-header-cell *matHeaderCellDef>Client</th>
|
||||
<td mat-cell *matCellDef="let edit">
|
||||
<a [routerLink]="['/clients', edit.clientId, 'general']" class="pce-client-link">{{
|
||||
edit.clientDisplayName
|
||||
}}</a>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="makerName">
|
||||
<th mat-header-cell *matHeaderCellDef>Submitted By</th>
|
||||
<td mat-cell *matCellDef="let edit">{{ edit.makerName }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="submittedDate">
|
||||
<th mat-header-cell *matHeaderCellDef>Submitted On</th>
|
||||
<td mat-cell *matCellDef="let edit">{{ formatDate(edit.submittedDate) }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="status">
|
||||
<th mat-header-cell *matHeaderCellDef>Status</th>
|
||||
<td mat-cell *matCellDef="let edit">
|
||||
<span class="status-badge" [ngClass]="statusClass(edit.status)">{{ edit.status }}</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef>Actions</th>
|
||||
<td mat-cell *matCellDef="let edit">
|
||||
<button mat-stroked-button class="pce-btn pce-btn-view" (click)="viewCompare(edit)">
|
||||
<fa-icon icon="eye" class="m-r-10"></fa-icon>Compare
|
||||
</button>
|
||||
@if (edit.status === 'PENDING') {
|
||||
<button mat-stroked-button class="pce-btn pce-btn-approve" (click)="approve(edit)">
|
||||
<fa-icon icon="check" class="m-r-10"></fa-icon>Approve
|
||||
</button>
|
||||
<button mat-stroked-button class="pce-btn pce-btn-reject" (click)="reject(edit)">
|
||||
<fa-icon icon="ban" class="m-r-10"></fa-icon>Reject
|
||||
</button>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns" class="table-row"></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<mat-paginator
|
||||
[length]="totalRecords"
|
||||
[pageSize]="pageSize"
|
||||
[pageSizeOptions]="pageSizeOptions"
|
||||
[pageIndex]="pageIndex"
|
||||
showFirstLastButtons
|
||||
(page)="onPageChange($event)"
|
||||
></mat-paginator>
|
||||
}
|
||||
</div>
|
||||
+348
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
$border: #e2e8f0;
|
||||
$text-muted: #64748b;
|
||||
|
||||
.pce-tab-content {
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.pce-client-link {
|
||||
color: #1e40af;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.pce-action-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid $border;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pce-search-box {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid $border;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
min-width: 200px;
|
||||
max-width: 280px;
|
||||
transition: border-color 0.15s;
|
||||
|
||||
&:focus-within {
|
||||
border-color: #1353a0;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
line-height: 18px;
|
||||
color: #94a3b8;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
input {
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 13px;
|
||||
width: 100%;
|
||||
color: #0d1b2a;
|
||||
font-family: inherit;
|
||||
background: transparent;
|
||||
|
||||
&::placeholder {
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pce-filter-select {
|
||||
height: 34px;
|
||||
min-width: 120px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid $border;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
color: #0d1b2a;
|
||||
font-family: inherit;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #1353a0;
|
||||
}
|
||||
}
|
||||
|
||||
.pce-fb-sep {
|
||||
width: 1px;
|
||||
height: 22px;
|
||||
background: $border;
|
||||
flex-shrink: 0;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.pce-presets {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pce-preset-chip {
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid $border;
|
||||
background: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
color: #475569;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
border-color: #1353a0;
|
||||
color: #1353a0;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
&--active {
|
||||
background: #1353a0;
|
||||
border-color: #1353a0;
|
||||
color: #fff;
|
||||
|
||||
&:hover {
|
||||
background: #0f3f7a;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pce-date-range {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
&__sep {
|
||||
color: #94a3b8;
|
||||
font-size: 16px;
|
||||
font-weight: 300;
|
||||
padding: 0 2px;
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
|
||||
.pce-date-box {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 34px;
|
||||
padding: 0 6px 0 10px;
|
||||
border: 1px solid $border;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
transition: border-color 0.15s;
|
||||
|
||||
&:hover {
|
||||
border-color: #cbd5e1;
|
||||
}
|
||||
|
||||
&:focus-within {
|
||||
border-color: #1353a0;
|
||||
}
|
||||
|
||||
&__lbl {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
&__input {
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 13px;
|
||||
width: 76px;
|
||||
color: #0d1b2a;
|
||||
font-family: inherit;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&__btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
color: #94a3b8;
|
||||
padding: 0;
|
||||
border-radius: 4px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: #1353a0;
|
||||
background: #eff6ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pce-btn {
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
border-radius: 8px !important;
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
margin-right: 6px;
|
||||
|
||||
fa-icon {
|
||||
margin-right: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
.pce-btn-view {
|
||||
border-color: #2563eb;
|
||||
color: #2563eb;
|
||||
|
||||
&:hover {
|
||||
background: #eff6ff;
|
||||
}
|
||||
}
|
||||
|
||||
.pce-btn-refresh {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: 1px solid $border;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
color: #475569;
|
||||
margin-left: auto;
|
||||
|
||||
fa-icon {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: #1353a0;
|
||||
color: #1353a0;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.pce-btn-approve {
|
||||
border-color: #16a34a;
|
||||
color: #16a34a;
|
||||
|
||||
&:hover {
|
||||
background: #f0fdf4;
|
||||
}
|
||||
}
|
||||
|
||||
.pce-btn-reject {
|
||||
border-color: #dc2626;
|
||||
color: #dc2626;
|
||||
|
||||
&:hover {
|
||||
background: #fef2f2;
|
||||
}
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
|
||||
&--pending {
|
||||
background: #fef9c3;
|
||||
color: #854d0e;
|
||||
}
|
||||
|
||||
&--approved {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
&--rejected {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 24px;
|
||||
background: #fff;
|
||||
border: 1px dashed $border;
|
||||
border-radius: 14px;
|
||||
text-align: center;
|
||||
margin: 16px 0;
|
||||
|
||||
.empty-icon {
|
||||
font-size: 42px;
|
||||
color: #cbd5e1;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
margin: 0 0 4px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #0d1b2a;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
color: $text-muted;
|
||||
max-width: 380px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
+429
@@ -0,0 +1,429 @@
|
||||
/**
|
||||
* 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 { FormsModule } from '@angular/forms';
|
||||
import {
|
||||
MatTable,
|
||||
MatColumnDef,
|
||||
MatHeaderCellDef,
|
||||
MatHeaderCell,
|
||||
MatCellDef,
|
||||
MatCell,
|
||||
MatHeaderRowDef,
|
||||
MatHeaderRow,
|
||||
MatRowDef,
|
||||
MatRow
|
||||
} from '@angular/material/table';
|
||||
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
|
||||
import { ClientsService } from 'app/clients/clients.service';
|
||||
import { SettingsService } from 'app/settings/settings.service';
|
||||
import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component';
|
||||
import { FormDialogComponent } from 'app/shared/form-dialog/form-dialog.component';
|
||||
import { FormfieldBase } from 'app/shared/form-dialog/formfield/model/formfield-base';
|
||||
import { InputBase } from 'app/shared/form-dialog/formfield/model/input-base';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { NON_COMPARABLE_KEYS, PENDING_EDIT_FIELD_LABELS } from 'app/clients/shared/client-edit-diff.util';
|
||||
import { ClientEditCompareDialogComponent } from './client-edit-compare-dialog/client-edit-compare-dialog.component';
|
||||
|
||||
/** Maps a payload key that holds a CodeValue/template id to the options array it should be
|
||||
* resolved against — populated from /clients/template plus the same per-code-value lookups the
|
||||
* General step itself uses, so a checker sees "Male" / "Graduate" instead of a bare id. */
|
||||
const ID_FIELD_OPTIONS_KEY: Record<string, string> = {
|
||||
officeId: 'officeOptions',
|
||||
staffId: 'staffOptions',
|
||||
legalFormId: 'clientLegalFormOptions',
|
||||
salutationId: 'salutationOptions',
|
||||
genderId: 'genderOptions',
|
||||
maritalStatusId: 'maritalStatusOptions',
|
||||
educationLevelId: 'educationLevelOptions',
|
||||
degreeQualificationId: 'degreeQualificationOptions',
|
||||
religionId: 'religionOptions',
|
||||
socialCategoryId: 'socialCategoryOptions',
|
||||
occupationCategoryId: 'occupationCategoryOptions',
|
||||
occupationId: 'occupationOptions',
|
||||
pwdCategoryId: 'pwdCategoryOptions',
|
||||
savingsProductId: 'savingProductOptions'
|
||||
};
|
||||
|
||||
/** Code-value lists the General step fetches individually rather than via /clients/template. */
|
||||
const OPTIONS_KEY_TO_CODE_NAME: Record<string, string> = {
|
||||
maritalStatusOptions: 'MaritalStatus',
|
||||
educationLevelOptions: 'EducationLevel',
|
||||
degreeQualificationOptions: 'DegreeQualification',
|
||||
religionOptions: 'Religion',
|
||||
socialCategoryOptions: 'SocialCategory'
|
||||
};
|
||||
|
||||
const NATIONALITY_LABELS: Record<string, string> = {
|
||||
INDIAN: 'Indian',
|
||||
NRI: 'NRI',
|
||||
FOREIGN_NATIONAL: 'Foreign National',
|
||||
PIO: 'PIO',
|
||||
OCI: 'OCI'
|
||||
};
|
||||
|
||||
/**
|
||||
* Checker-facing list of Client details edits awaiting approval.
|
||||
* Mirrors PendingShareAccountEditsComponent's list + compare + approve/reject pattern — the
|
||||
* "compare" panel here generically diffs whatever keys are in the staged payload_json, since
|
||||
* Client has far more editable fields than Share Account's fixed handful.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-pending-client-edits',
|
||||
templateUrl: './pending-client-edits.component.html',
|
||||
styleUrls: ['./pending-client-edits.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
FormsModule,
|
||||
FaIconComponent,
|
||||
MatTable,
|
||||
MatColumnDef,
|
||||
MatHeaderCellDef,
|
||||
MatHeaderCell,
|
||||
MatCellDef,
|
||||
MatCell,
|
||||
MatHeaderRowDef,
|
||||
MatHeaderRow,
|
||||
MatRowDef,
|
||||
MatRow,
|
||||
MatIcon,
|
||||
MatPaginatorModule
|
||||
]
|
||||
})
|
||||
export class PendingClientEditsComponent implements OnInit {
|
||||
private dialog = inject(MatDialog);
|
||||
private clientsService = inject(ClientsService);
|
||||
private settingsService = inject(SettingsService);
|
||||
|
||||
/** Current page of pending edits, as returned by the server — pagination/date/search filtering
|
||||
* all happen server-side now (GET /clients/edit/pending?status&clientName&fromDate&toDate&
|
||||
* offset&limit), mirroring ChequebookRequestsComponent. */
|
||||
edits: any[] = [];
|
||||
totalRecords = 0;
|
||||
pageSize = 20;
|
||||
pageIndex = 0;
|
||||
pageSizeOptions = [
|
||||
10,
|
||||
20,
|
||||
50
|
||||
];
|
||||
loading = false;
|
||||
loaded = false;
|
||||
|
||||
/** Populated from /clients/template + per-code-value lookups, used to resolve raw ids in the
|
||||
* compare panel into human-readable labels. Empty object until it loads — entries() just falls
|
||||
* back to showing the raw id/value until then. */
|
||||
private optionsByKey: Record<string, any[]> = {};
|
||||
|
||||
displayedColumns: string[] = [
|
||||
'clientDisplayName',
|
||||
'makerName',
|
||||
'submittedDate',
|
||||
'status',
|
||||
'actions'
|
||||
];
|
||||
|
||||
searchText = '';
|
||||
fromDate: Date | null = null;
|
||||
toDate: Date | null = null;
|
||||
maxDate!: Date;
|
||||
activePreset = 'today';
|
||||
readonly datePresets = [
|
||||
{ key: 'today', label: 'Today' },
|
||||
{ key: 'yesterday', label: 'Yesterday' },
|
||||
{ key: 'all', label: 'All' }
|
||||
];
|
||||
|
||||
filterStatus = 'PENDING';
|
||||
readonly statusOptions = [
|
||||
{ value: 'PENDING', label: 'Pending' },
|
||||
{ value: 'APPROVED', label: 'Approved' },
|
||||
{ value: 'REJECTED', label: 'Rejected' }
|
||||
];
|
||||
|
||||
ngOnInit(): void {
|
||||
const today = new Date(this.settingsService.businessDate);
|
||||
today.setHours(0, 0, 0, 0);
|
||||
this.maxDate = today;
|
||||
this.loadOptions();
|
||||
this.applyPreset('today');
|
||||
}
|
||||
|
||||
private loadOptions(): void {
|
||||
this.clientsService.getClientTemplate().subscribe((template: any) => {
|
||||
this.optionsByKey = { ...this.optionsByKey, ...(template || {}) };
|
||||
});
|
||||
Object.entries(OPTIONS_KEY_TO_CODE_NAME).forEach(
|
||||
([
|
||||
optionsKey,
|
||||
codeName
|
||||
]) => {
|
||||
this.clientsService.getCodeValuesByName(codeName).subscribe((options: any[]) => {
|
||||
this.optionsByKey[optionsKey] = options || [];
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
onFilterChange(): void {
|
||||
this.pageIndex = 0;
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.clientsService
|
||||
.getAllPendingClientEdits(
|
||||
this.filterStatus,
|
||||
this.searchText.trim() || undefined,
|
||||
this.isoDate(this.fromDate),
|
||||
this.isoDate(this.toDate),
|
||||
this.pageIndex * this.pageSize,
|
||||
this.pageSize
|
||||
)
|
||||
.subscribe({
|
||||
next: (res: any) => {
|
||||
this.edits = res?.pageItems ?? [];
|
||||
this.totalRecords = Number(res?.totalFilteredRecords ?? 0);
|
||||
this.loading = false;
|
||||
this.loaded = true;
|
||||
},
|
||||
error: () => {
|
||||
this.edits = [];
|
||||
this.totalRecords = 0;
|
||||
this.loading = false;
|
||||
this.loaded = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onPageChange(event: PageEvent): void {
|
||||
this.pageSize = event.pageSize;
|
||||
this.pageIndex = event.pageIndex;
|
||||
this.load();
|
||||
}
|
||||
|
||||
applyFilter(filterValue: string = ''): void {
|
||||
this.searchText = filterValue;
|
||||
this.pageIndex = 0;
|
||||
this.load();
|
||||
}
|
||||
|
||||
applyPreset(key: string): void {
|
||||
this.activePreset = key;
|
||||
const today = new Date(this.settingsService.businessDate);
|
||||
today.setHours(0, 0, 0, 0);
|
||||
if (key === 'today') {
|
||||
this.fromDate = new Date(today);
|
||||
this.toDate = new Date(today);
|
||||
} else if (key === 'yesterday') {
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(today.getDate() - 1);
|
||||
this.fromDate = yesterday;
|
||||
this.toDate = yesterday;
|
||||
} else {
|
||||
this.fromDate = null;
|
||||
this.toDate = null;
|
||||
}
|
||||
this.pageIndex = 0;
|
||||
this.load();
|
||||
}
|
||||
|
||||
onFromDateChange(): void {
|
||||
this.activePreset = '';
|
||||
if (this.fromDate && this.toDate && this.fromDate > this.toDate) {
|
||||
this.toDate = new Date(this.fromDate);
|
||||
}
|
||||
this.pageIndex = 0;
|
||||
this.load();
|
||||
}
|
||||
|
||||
onToDateChange(): void {
|
||||
this.activePreset = '';
|
||||
this.pageIndex = 0;
|
||||
this.load();
|
||||
}
|
||||
|
||||
private isoDate(d: Date | null): string | undefined {
|
||||
if (!d) return undefined;
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
formatDate(date: any): string {
|
||||
if (!date) return '';
|
||||
const d = new Date(date);
|
||||
return isNaN(d.getTime()) ? String(date) : d.toLocaleString('en-IN');
|
||||
}
|
||||
|
||||
statusClass(status: string): string {
|
||||
const map: Record<string, string> = {
|
||||
PENDING: 'status-badge--pending',
|
||||
APPROVED: 'status-badge--approved',
|
||||
REJECTED: 'status-badge--rejected'
|
||||
};
|
||||
return map[status] ?? '';
|
||||
}
|
||||
|
||||
/** "Field / Current / Proposed" rows for whatever in the staged payload actually differs from
|
||||
* the point-in-time snapshot captured by the maker's browser when the edit page was opened
|
||||
* (stored as original_json — see buildOriginalValuesSnapshot() in client-edit-diff.util.ts).
|
||||
* The payload itself carries every General-step field (changed or not), so this filters it down
|
||||
* to only what the maker actually proposed to change — a TRUE historical diff, unlike comparing
|
||||
* against the LIVE client, which would show nothing once an edit is approved (by then current ==
|
||||
* proposed). Falls back to showing every field unfiltered when there's no original snapshot at
|
||||
* all (edits submitted before this column existed) rather than risk silently hiding a change. */
|
||||
entries(edit: any): { key: string; label: string; current: any; value: any }[] {
|
||||
if (!edit?.payloadJson) return [];
|
||||
let parsed: Record<string, any>;
|
||||
try {
|
||||
parsed = JSON.parse(edit.payloadJson);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
let original: Record<string, any> | null = null;
|
||||
if (edit.originalJson) {
|
||||
try {
|
||||
original = JSON.parse(edit.originalJson);
|
||||
} catch {
|
||||
original = null;
|
||||
}
|
||||
}
|
||||
|
||||
const keys = Object.keys(parsed).filter(
|
||||
(key) => !NON_COMPARABLE_KEYS.has(key) && parsed[key] !== null && parsed[key] !== undefined && parsed[key] !== ''
|
||||
);
|
||||
const rows: { key: string; label: string; current: any; value: any }[] = [];
|
||||
for (const key of keys) {
|
||||
const proposedRaw = parsed[key];
|
||||
const hasOriginal = !!original && Object.prototype.hasOwnProperty.call(original, key);
|
||||
const originalRaw = hasOriginal ? original![key] : undefined;
|
||||
|
||||
// Nested objects (e.g. clientNonPersonDetails for non-individual clients) aren't captured
|
||||
// in the flat original snapshot at all — always show them rather than guess at a diff.
|
||||
if (typeof proposedRaw === 'object' && !Array.isArray(proposedRaw)) {
|
||||
rows.push({ key, label: PENDING_EDIT_FIELD_LABELS[key] || key, current: null, value: proposedRaw });
|
||||
continue;
|
||||
}
|
||||
|
||||
const changed = !original || !hasOriginal || this.normalize(originalRaw) !== this.normalize(proposedRaw);
|
||||
if (!changed) continue;
|
||||
rows.push({
|
||||
key,
|
||||
label: PENDING_EDIT_FIELD_LABELS[key] || key,
|
||||
current: hasOriginal ? this.resolveValue(key, originalRaw) : null,
|
||||
value: this.resolveValue(key, proposedRaw)
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
private resolveValue(key: string, value: any): any {
|
||||
if (key === 'nationalityId') {
|
||||
return NATIONALITY_LABELS[value] || value;
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? 'Yes' : 'No';
|
||||
}
|
||||
const optionsKey = ID_FIELD_OPTIONS_KEY[key];
|
||||
const options = optionsKey ? this.optionsByKey[optionsKey] : null;
|
||||
if (options?.length) {
|
||||
const match = options.find((o: any) => String(o.id) === String(value));
|
||||
if (match) return match.name || match.value || value;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Normalizes a value (Fineract date array, formatted date string, boolean, id, plain value)
|
||||
* into a comparable string so a genuinely-unchanged field isn't shown as a false "change" just
|
||||
* because one side is an array-date and the other a formatted string. */
|
||||
private normalize(value: any): string {
|
||||
if (value === null || value === undefined) return '';
|
||||
if (Array.isArray(value) && value.length >= 3 && value.every((v) => typeof v === 'number')) {
|
||||
return `${value[0]}-${value[1]}-${value[2]}`;
|
||||
}
|
||||
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
||||
if (typeof value === 'string') {
|
||||
const asDate = new Date(value);
|
||||
if (!isNaN(asDate.getTime()) && /[a-zA-Z]/.test(value)) {
|
||||
return `${asDate.getFullYear()}-${asDate.getMonth() + 1}-${asDate.getDate()}`;
|
||||
}
|
||||
}
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
viewCompare(edit: any): void {
|
||||
this.openCompareDialog(edit);
|
||||
}
|
||||
|
||||
private openCompareDialog(edit: any): void {
|
||||
this.dialog.open(ClientEditCompareDialogComponent, {
|
||||
width: '640px',
|
||||
data: {
|
||||
clientId: edit.clientId,
|
||||
clientDisplayName: edit.clientDisplayName,
|
||||
rows: this.entries(edit),
|
||||
status: edit.status,
|
||||
checkerName: edit.checkerName,
|
||||
checkedDate: edit.checkedDate ? this.formatDate(edit.checkedDate) : null,
|
||||
rejectReason: edit.rejectReason
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
approve(edit: any): void {
|
||||
const dialogRef = this.dialog.open(ConfirmationDialogComponent, {
|
||||
data: {
|
||||
heading: 'Approve Client Edit',
|
||||
dialogContext: `Apply the pending details edit for client "${edit.clientDisplayName}"? This cannot be undone.`,
|
||||
type: 'basic'
|
||||
}
|
||||
});
|
||||
dialogRef.afterClosed().subscribe((response: any) => {
|
||||
if (response?.confirm) {
|
||||
this.clientsService.approveClientEdit(edit.clientId).subscribe(() => this.reload());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
reject(edit: any): void {
|
||||
const formfields: FormfieldBase[] = [
|
||||
new InputBase({
|
||||
controlName: 'rejectReason',
|
||||
label: 'Reason for rejection',
|
||||
value: '',
|
||||
type: 'text',
|
||||
required: false
|
||||
})
|
||||
];
|
||||
const dialogRef = this.dialog.open(FormDialogComponent, {
|
||||
data: {
|
||||
title: `Reject edit for client "${edit.clientDisplayName}"`,
|
||||
layout: { addButtonText: 'Reject' },
|
||||
formfields
|
||||
}
|
||||
});
|
||||
dialogRef.afterClosed().subscribe((response: any) => {
|
||||
if (response?.data) {
|
||||
const rejectReason = response.data.value.rejectReason || '';
|
||||
this.clientsService.rejectClientEdit(edit.clientId, rejectReason).subscribe(() => this.reload());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
reload(): void {
|
||||
this.load();
|
||||
}
|
||||
}
|
||||
@@ -130,6 +130,18 @@
|
||||
<fa-icon icon="edit" class="ac-tab__ic"></fa-icon>
|
||||
<span>Pending Share Account Edits</span>
|
||||
</a>
|
||||
<a
|
||||
mat-tab-link
|
||||
class="ac-tab"
|
||||
[routerLink]="['./pending-client-edits']"
|
||||
routerLinkActive
|
||||
#pendingClientEdits="routerLinkActive"
|
||||
[active]="pendingClientEdits.isActive"
|
||||
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
|
||||
>
|
||||
<fa-icon icon="edit" class="ac-tab__ic"></fa-icon>
|
||||
<span>Pending Client Edits</span>
|
||||
</a>
|
||||
<a
|
||||
mat-tab-link
|
||||
class="ac-tab"
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
/**
|
||||
* 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 { Injectable, inject } from '@angular/core';
|
||||
|
||||
/** rxjs Imports */
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
/** Custom Services */
|
||||
import { TasksService } from '../tasks.service';
|
||||
|
||||
/**
|
||||
* Grouped Client Data data resolver.
|
||||
*/
|
||||
@Injectable()
|
||||
export class GetGroupedClientsData {
|
||||
private tasksService = inject(TasksService);
|
||||
|
||||
/**
|
||||
* Returns the grouped client data.
|
||||
* @returns {Observable<any>}
|
||||
*/
|
||||
resolve(): Observable<any> {
|
||||
return this.tasksService.getGroupedClientsData();
|
||||
}
|
||||
}
|
||||
@@ -30,11 +30,11 @@ import { PendingSavingsProductsComponent } from './checker-inbox-and-tasks-tabs/
|
||||
import { ChequebookRequestsComponent } from './checker-inbox-and-tasks-tabs/chequebook-requests/chequebook-requests.component';
|
||||
import { PendingSavingsAccountEditsComponent } from './checker-inbox-and-tasks-tabs/pending-savings-account-edits/pending-savings-account-edits.component';
|
||||
import { PendingShareAccountEditsComponent } from './checker-inbox-and-tasks-tabs/pending-share-account-edits/pending-share-account-edits.component';
|
||||
import { PendingClientEditsComponent } from './checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component';
|
||||
import { ViewCheckerInboxComponent } from './view-checker-inbox/view-checker-inbox.component';
|
||||
|
||||
/** Custom Resolvers */
|
||||
import { GetMakerCheckers } from './common-resolvers/getmakercheckers.resolver';
|
||||
import { GetGroupedClientsData } from './common-resolvers/getGroupedClientsData.resolver';
|
||||
import { GetOffices } from './common-resolvers/getOffices.resolver';
|
||||
import { GetLoansToBeApproved } from './common-resolvers/getLoansToBeApproved.resolver';
|
||||
import { GetLoansToBeDisbursed } from './common-resolvers/getLoansToBeDisbursed.resolver';
|
||||
@@ -69,10 +69,7 @@ const routes: Routes = [
|
||||
{
|
||||
path: 'client-approval',
|
||||
component: ClientApprovalComponent,
|
||||
data: { title: 'Client Approval' },
|
||||
resolve: {
|
||||
groupedClientData: GetGroupedClientsData
|
||||
}
|
||||
data: { title: 'Client Approval' }
|
||||
},
|
||||
{
|
||||
path: 'loan-approval',
|
||||
@@ -152,6 +149,11 @@ const routes: Routes = [
|
||||
pendingShareAccountEditsData: GetPendingShareAccountEdits
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'pending-client-edits',
|
||||
component: PendingClientEditsComponent,
|
||||
data: { title: 'Pending Client Edits' }
|
||||
},
|
||||
{
|
||||
path: 'withdrawal-requests',
|
||||
component: WithdrawalRequestsComponent,
|
||||
@@ -219,7 +221,6 @@ const routes: Routes = [
|
||||
exports: [RouterModule],
|
||||
providers: [
|
||||
GetMakerCheckers,
|
||||
GetGroupedClientsData,
|
||||
GetOffices,
|
||||
GetLoansToBeApproved,
|
||||
GetLoansToBeDisbursed,
|
||||
|
||||
@@ -48,10 +48,27 @@ export class TasksService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Grouped Clients Data
|
||||
* Get pending clients awaiting approval — pagination, name search and submitted-date
|
||||
* filtering all happen server-side via the standard /clients list API (offset/limit,
|
||||
* displayName, fromDate/toDate on submittedon_date), mirroring
|
||||
* ClientsService.getAllPendingClientEdits.
|
||||
*/
|
||||
getGroupedClientsData(): Observable<any> {
|
||||
const httpParams = new HttpParams().set('limit', '1000').set('status', 'pending');
|
||||
getGroupedClientsData(
|
||||
displayName?: string,
|
||||
fromDate?: string,
|
||||
toDate?: string,
|
||||
offset = 0,
|
||||
limit = 20
|
||||
): Observable<any> {
|
||||
let httpParams = new HttpParams()
|
||||
.set('status', 'pending')
|
||||
.set('orderBy', 'submittedOnDate')
|
||||
.set('sortOrder', 'DESC')
|
||||
.set('offset', offset)
|
||||
.set('limit', limit);
|
||||
if (displayName) httpParams = httpParams.set('displayName', displayName);
|
||||
if (fromDate) httpParams = httpParams.set('fromDate', fromDate);
|
||||
if (toDate) httpParams = httpParams.set('toDate', toDate);
|
||||
return this.http.get('/clients', { params: httpParams });
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
/* tslint:disable */
|
||||
export default {
|
||||
'mifos_x': {
|
||||
'version': '260728',
|
||||
'hash': '2e4187c3'
|
||||
'version': '260731',
|
||||
'hash': 'ca85bae4'
|
||||
},
|
||||
'allow_switching_backend_instance': true
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user