Compare commits
7
Commits
29f1cdc646
..
uate
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c7d8287f9 | ||
|
|
a560cd6533 | ||
|
|
0bf337b962 | ||
|
|
1daa12a117 | ||
|
|
f687e1808d | ||
|
|
186a9057a1 | ||
|
|
387bd060e1 |
@@ -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`);
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
@@ -12,6 +12,7 @@ import { TranslateService } from '@ngx-translate/core';
|
|||||||
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
|
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
|
||||||
import { ClientFamilyMemberDialogComponent } from './client-family-member-dialog/client-family-member-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 { 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 { ClientsService } from '../../clients.service';
|
||||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
import { MatIcon } from '@angular/material/icon';
|
import { MatIcon } from '@angular/material/icon';
|
||||||
@@ -152,6 +153,22 @@ export class ClientFamilyMembersStepComponent implements OnInit {
|
|||||||
ref.afterClosed().subscribe((response: any) => {
|
ref.afterClosed().subscribe((response: any) => {
|
||||||
if (response?.member) {
|
if (response?.member) {
|
||||||
this.clientFamilyMembers.push(response.member);
|
this.clientFamilyMembers.push(response.member);
|
||||||
|
this.showFamilyMemberAddedConfirmation(response.member);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Confirms what was just recorded — in particular the linked client's own CBS Client ID,
|
||||||
|
* since the "Add Member" dialog closes silently otherwise and gives no acknowledgement. */
|
||||||
|
private showFamilyMemberAddedConfirmation(member: any): void {
|
||||||
|
this.dialog.open(FamilyMemberAddedDialogComponent, {
|
||||||
|
width: '360px',
|
||||||
|
data: {
|
||||||
|
clientId: member._linkedClient?.id ?? member.linkedClientId ?? null,
|
||||||
|
name: member._linkedClient?.displayName || member.firstName,
|
||||||
|
relationship: this.getRelName(member.relationshipId),
|
||||||
|
dateOfBirth: member.dateOfBirth,
|
||||||
|
isDependent: member.isDependent
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+145
@@ -0,0 +1,145 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, inject } from '@angular/core';
|
||||||
|
import { MatDialogRef, MAT_DIALOG_DATA, MatDialogTitle, MatDialogActions } from '@angular/material/dialog';
|
||||||
|
import { MatIcon } from '@angular/material/icon';
|
||||||
|
import { MatButton } from '@angular/material/button';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirmation popup shown right after a family member is added — surfaces the linked
|
||||||
|
* client's own CBS Client ID (when the member was linked to an existing client rather than
|
||||||
|
* entered manually) alongside name/relationship, since the "Add Member" dialog itself just
|
||||||
|
* closed silently before this with no acknowledgement of what got recorded.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-family-member-added-dialog',
|
||||||
|
imports: [
|
||||||
|
MatDialogTitle,
|
||||||
|
MatDialogActions,
|
||||||
|
MatIcon,
|
||||||
|
MatButton
|
||||||
|
],
|
||||||
|
template: `
|
||||||
|
<h2 mat-dialog-title class="fma-title">
|
||||||
|
<mat-icon class="fma-title__icon">check_circle</mat-icon>
|
||||||
|
Family Member Added
|
||||||
|
</h2>
|
||||||
|
<div class="fma-body">
|
||||||
|
@if (data.clientId) {
|
||||||
|
<div class="fma-row fma-row--id">
|
||||||
|
<span class="fma-row__label">Client ID</span>
|
||||||
|
<span class="fma-row__value fma-row__value--id">{{ data.clientId }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="fma-row">
|
||||||
|
<span class="fma-row__label">Name</span>
|
||||||
|
<span class="fma-row__value">{{ data.name }}</span>
|
||||||
|
</div>
|
||||||
|
@if (data.relationship) {
|
||||||
|
<div class="fma-row">
|
||||||
|
<span class="fma-row__label">Relationship</span>
|
||||||
|
<span class="fma-row__value">{{ data.relationship }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (data.dateOfBirth) {
|
||||||
|
<div class="fma-row">
|
||||||
|
<span class="fma-row__label">Date of Birth</span>
|
||||||
|
<span class="fma-row__value">{{ data.dateOfBirth }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (data.isDependent) {
|
||||||
|
<div class="fma-row">
|
||||||
|
<span class="fma-row__label">Dependent</span>
|
||||||
|
<span class="fma-row__value">Yes</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<mat-dialog-actions align="end">
|
||||||
|
<button mat-flat-button color="primary" (click)="dialogRef.close()">Done</button>
|
||||||
|
</mat-dialog-actions>
|
||||||
|
`,
|
||||||
|
styles: [
|
||||||
|
`
|
||||||
|
.fma-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px 24px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fma-title__icon {
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fma-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 12px 24px 8px;
|
||||||
|
min-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fma-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid #f1f5f9;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fma-row__label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fma-row__value {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #0f172a;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fma-row--id {
|
||||||
|
background: #eff6ff;
|
||||||
|
margin: 0 -24px;
|
||||||
|
padding: 10px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fma-row__value--id {
|
||||||
|
font-family: 'Roboto Mono', monospace;
|
||||||
|
color: #1353a0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
mat-dialog-actions {
|
||||||
|
padding: 8px 24px 16px;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class FamilyMemberAddedDialogComponent {
|
||||||
|
dialogRef = inject<MatDialogRef<FamilyMemberAddedDialogComponent>>(MatDialogRef);
|
||||||
|
data: {
|
||||||
|
clientId?: string | number | null;
|
||||||
|
name: string;
|
||||||
|
relationship?: string;
|
||||||
|
dateOfBirth?: string;
|
||||||
|
isDependent?: boolean;
|
||||||
|
} = inject(MAT_DIALOG_DATA);
|
||||||
|
}
|
||||||
+61
-6
@@ -548,8 +548,16 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
this.maxDate = this.settingsService.businessDate;
|
this.maxDate = this.settingsService.businessDate;
|
||||||
this.setOptions();
|
this.setOptions();
|
||||||
|
|
||||||
this.clientService.getClientRiskRules().subscribe((rules) => {
|
// Risk rules are an enhancement on top of the default low/medium-risk fallback in
|
||||||
this.riskRules = rules || [];
|
// 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
|
// Fetch marital status options from code values
|
||||||
this.clientService.getCodeValuesByName('MaritalStatus').subscribe((options) => {
|
this.clientService.getCodeValuesByName('MaritalStatus').subscribe((options) => {
|
||||||
@@ -2126,7 +2134,15 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
return generalDetails;
|
return generalDetails;
|
||||||
}
|
}
|
||||||
|
|
||||||
get calculatedRisk(): { category: number; reason: string } {
|
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 legalFormId = this.createClientForm.get('legalFormId')?.value || 1; // 1 = Person, 2 = Entity
|
||||||
const occCatId = this.createClientForm.get('occupationCategoryCvId')?.value;
|
const occCatId = this.createClientForm.get('occupationCategoryCvId')?.value;
|
||||||
const occId = this.createClientForm.get('occupationCvId')?.value;
|
const occId = this.createClientForm.get('occupationCvId')?.value;
|
||||||
@@ -2143,6 +2159,21 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
// Evaluate rules from DB (ordered by orderPosition ASC)
|
// Evaluate rules from DB (ordered by orderPosition ASC)
|
||||||
if (this.riskRules && this.riskRules.length > 0) {
|
if (this.riskRules && this.riskRules.length > 0) {
|
||||||
for (const rule of this.riskRules) {
|
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
|
// Match legal form
|
||||||
if (rule.legalFormId != null && rule.legalFormId !== legalFormId) {
|
if (rule.legalFormId != null && rule.legalFormId !== legalFormId) {
|
||||||
continue;
|
continue;
|
||||||
@@ -2183,15 +2214,39 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
}
|
}
|
||||||
|
|
||||||
// All matched!
|
// All matched!
|
||||||
return { category: rule.riskCategory, reason: rule.reason };
|
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
|
// Default Fallbacks
|
||||||
if (legalFormId !== LegalFormId.PERSON) {
|
if (legalFormId !== LegalFormId.PERSON) {
|
||||||
return { category: 2, reason: 'Non-Individual / Entity profile (Default Medium Risk)' };
|
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)' };
|
return {
|
||||||
|
category: 1,
|
||||||
|
reason: 'Default Low Risk profile (Salary / Service / Agriculture / Others)',
|
||||||
|
amlCheckStatus: 'CLEAR',
|
||||||
|
bannedEntityMatch: 'NO',
|
||||||
|
willfulDefaulter: 'NO',
|
||||||
|
ecgcSalFlag: 'NO',
|
||||||
|
dormantFlag: 'ACTIVE'
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1251,6 +1251,12 @@
|
|||||||
</h4>
|
</h4>
|
||||||
|
|
||||||
<div class="form-grid">
|
<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-form-field appearance="outline" class="form-field">
|
||||||
<mat-label>AML Check Status</mat-label>
|
<mat-label>AML Check Status</mat-label>
|
||||||
<mat-select required formControlName="amlCheckStatus">
|
<mat-select required formControlName="amlCheckStatus">
|
||||||
@@ -1481,8 +1487,8 @@
|
|||||||
<input type="file" accept="image/*" hidden (change)="onSignatureSelected($event)" />
|
<input type="file" accept="image/*" hidden (change)="onSignatureSelected($event)" />
|
||||||
<fa-icon icon="crop"></fa-icon> Choose & Crop
|
<fa-icon icon="crop"></fa-icon> Choose & Crop
|
||||||
</label>
|
</label>
|
||||||
<button type="button" class="upload-box__btn upload-box__btn--secondary" (click)="captureWebcamSignature()">
|
<button type="button" class="upload-box__btn upload-box__btn--secondary" (click)="drawSignature()">
|
||||||
<fa-icon icon="video"></fa-icon> Use Webcam
|
<fa-icon icon="pen"></fa-icon> Draw Signature
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@if (photoSigTouched) {
|
@if (photoSigTouched) {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { Dates } from 'app/core/utils/dates';
|
|||||||
import { ProofType, normalizeProofType, groupProofsByCategory } from '../../shared/kyc-proof-types';
|
import { ProofType, normalizeProofType, groupProofsByCategory } from '../../shared/kyc-proof-types';
|
||||||
import { KycUploadHelperService } from '../../shared/kyc-upload-helper.service';
|
import { KycUploadHelperService } from '../../shared/kyc-upload-helper.service';
|
||||||
import { CaptureImageDialogComponent } from '../../clients-view/custom-dialogs/capture-image-dialog/capture-image-dialog.component';
|
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 { dataUrlToFile } from 'app/shared/utils/data-url.util';
|
||||||
import { DocumentPreviewDialogComponent } from '../../shared/document-preview-dialog/document-preview-dialog.component';
|
import { DocumentPreviewDialogComponent } from '../../shared/document-preview-dialog/document-preview-dialog.component';
|
||||||
|
|
||||||
@@ -70,7 +71,15 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
@Input() entityName: string = '';
|
@Input() entityName: string = '';
|
||||||
/** Read-only mode — disables form editing until user clicks edit icon */
|
/** Read-only mode — disables form editing until user clicks edit icon */
|
||||||
@Input() readOnly = false;
|
@Input() readOnly = false;
|
||||||
@Input() calculatedRisk: { category: number; reason: string } | null = null;
|
@Input() calculatedRisk: {
|
||||||
|
category: number;
|
||||||
|
reason: string;
|
||||||
|
amlCheckStatus?: string;
|
||||||
|
bannedEntityMatch?: string;
|
||||||
|
willfulDefaulter?: string;
|
||||||
|
ecgcSalFlag?: string;
|
||||||
|
dormantFlag?: string;
|
||||||
|
} | null = null;
|
||||||
|
|
||||||
today = new Date();
|
today = new Date();
|
||||||
kycId: string = '';
|
kycId: string = '';
|
||||||
@@ -457,6 +466,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
kycDocExpiryDate: [''],
|
kycDocExpiryDate: [''],
|
||||||
riskCategory: [1],
|
riskCategory: [1],
|
||||||
allowRiskOverride: [false],
|
allowRiskOverride: [false],
|
||||||
|
allowAmlOverride: [false],
|
||||||
remarks: [''],
|
remarks: [''],
|
||||||
amlCheckStatus: [
|
amlCheckStatus: [
|
||||||
'CLEAR',
|
'CLEAR',
|
||||||
@@ -534,9 +544,35 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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) {
|
if (!this.kycForm.get('allowRiskOverride')?.value) {
|
||||||
this.kycForm.get('riskCategory')?.disable();
|
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 (entity RBI docs vs. individual OVDs,
|
/** Seed the document-type picker from defaults (entity RBI docs vs. individual OVDs,
|
||||||
@@ -644,6 +680,13 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
if (!this.kycForm.get('allowRiskOverride')?.value) {
|
if (!this.kycForm.get('allowRiskOverride')?.value) {
|
||||||
this.kycForm.get('riskCategory')?.disable({ emitEvent: false });
|
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']) {
|
if (changes['calculatedRisk']) {
|
||||||
@@ -656,6 +699,11 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
riskControl.setValue(newRisk.category);
|
riskControl.setValue(newRisk.category);
|
||||||
riskControl.markAsDirty();
|
riskControl.markAsDirty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const allowAmlOverride = this.kycForm.get('allowAmlOverride')?.value;
|
||||||
|
if (!allowAmlOverride) {
|
||||||
|
this.applyAutoCalculatedAmlFlags(newRisk);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
this.riskCalculationReason = '';
|
this.riskCalculationReason = '';
|
||||||
}
|
}
|
||||||
@@ -870,6 +918,13 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
// Risk & Remarks
|
// Risk & Remarks
|
||||||
riskCategory: data.riskCategory || 1,
|
riskCategory: data.riskCategory || 1,
|
||||||
allowRiskOverride: this.calculatedRisk ? this.calculatedRisk.category !== (data.riskCategory || 1) : false,
|
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 || '',
|
remarks: data.remarks || '',
|
||||||
hasPan: !!data.panNumber,
|
hasPan: !!data.panNumber,
|
||||||
// AML & Compliance
|
// AML & Compliance
|
||||||
@@ -1657,20 +1712,6 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
await this.openSignatureCropDialog(file);
|
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> {
|
private async openSignatureCropDialog(file: File): Promise<void> {
|
||||||
const { ImageCropDialogComponent } = await import('./image-crop-dialog/image-crop-dialog.component');
|
const { ImageCropDialogComponent } = await import('./image-crop-dialog/image-crop-dialog.component');
|
||||||
const dialogRef = this.dialog.open(ImageCropDialogComponent, {
|
const dialogRef = this.dialog.open(ImageCropDialogComponent, {
|
||||||
@@ -1680,42 +1721,51 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
});
|
});
|
||||||
dialogRef.afterClosed().subscribe((result: any) => {
|
dialogRef.afterClosed().subscribe((result: any) => {
|
||||||
if (result) {
|
if (result) {
|
||||||
this.signatureFile = result.croppedFile;
|
this.applySignatureFile(result.croppedFile, result.previewUrl);
|
||||||
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: () => {}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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() {
|
removePhoto() {
|
||||||
this.photographFile = null;
|
this.photographFile = null;
|
||||||
this.photographPreview = null;
|
this.photographPreview = null;
|
||||||
@@ -1809,6 +1859,21 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
v.form15Applicable
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -410,6 +410,9 @@
|
|||||||
<button mat-menu-item (click)="doAction('Add Charge')" *mifosxHasPermission="'CREATE_CLIENTCHARGE'">
|
<button mat-menu-item (click)="doAction('Add Charge')" *mifosxHasPermission="'CREATE_CLIENTCHARGE'">
|
||||||
{{ 'labels.buttons.Add Charge' | translate }}
|
{{ 'labels.buttons.Add Charge' | translate }}
|
||||||
</button>
|
</button>
|
||||||
|
<button mat-menu-item (click)="doAction('Welcome Kit')" *mifosxHasPermission="'READ_WELCOMEKIT'">
|
||||||
|
Welcome Kit
|
||||||
|
</button>
|
||||||
<button mat-menu-item (click)="doAction('Create Collateral')">
|
<button mat-menu-item (click)="doAction('Create Collateral')">
|
||||||
{{ 'labels.buttons.Create Collateral' | translate }}
|
{{ 'labels.buttons.Create Collateral' | translate }}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { DrawSignatureDialogComponent } from './custom-dialogs/draw-signature-di
|
|||||||
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
|
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
|
||||||
import { UploadImageDialogComponent } from './custom-dialogs/upload-image-dialog/upload-image-dialog.component';
|
import { UploadImageDialogComponent } from './custom-dialogs/upload-image-dialog/upload-image-dialog.component';
|
||||||
import { CaptureImageDialogComponent } from './custom-dialogs/capture-image-dialog/capture-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 */
|
/** Custom Services */
|
||||||
import { ClientsService } from '../clients.service';
|
import { ClientsService } from '../clients.service';
|
||||||
@@ -255,6 +256,9 @@ export class ClientsViewComponent implements OnInit {
|
|||||||
case 'Delete Image':
|
case 'Delete Image':
|
||||||
this.deleteProfileImage();
|
this.deleteProfileImage();
|
||||||
break;
|
break;
|
||||||
|
case 'Welcome Kit':
|
||||||
|
this.dispatchWelcomeKit();
|
||||||
|
break;
|
||||||
case 'Create Standing Instructions':
|
case 'Create Standing Instructions':
|
||||||
const createStandingInstructionsQueryParams: any = {
|
const createStandingInstructionsQueryParams: any = {
|
||||||
officeId: this.clientViewData.officeId,
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+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`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -137,7 +137,16 @@ export class ClientsComponent implements OnInit {
|
|||||||
private getClients() {
|
private getClients() {
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
this.clientService
|
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(
|
.subscribe(
|
||||||
(data: any) => {
|
(data: any) => {
|
||||||
this.allRows = data.content || [];
|
this.allRows = data.content || [];
|
||||||
@@ -166,7 +175,7 @@ export class ClientsComponent implements OnInit {
|
|||||||
// Use individual subscriptions instead of forkJoin to avoid EMPTY observable issues
|
// Use individual subscriptions instead of forkJoin to avoid EMPTY observable issues
|
||||||
clients.forEach((client, i) => {
|
clients.forEach((client, i) => {
|
||||||
this.clientService
|
this.clientService
|
||||||
.getClientData(client.id?.toString())
|
.getClientData(client.id?.toString(), true)
|
||||||
.pipe(catchError(() => of(null)))
|
.pipe(catchError(() => of(null)))
|
||||||
.subscribe((data: any) => {
|
.subscribe((data: any) => {
|
||||||
if (data?.legalForm) {
|
if (data?.legalForm) {
|
||||||
@@ -176,7 +185,7 @@ export class ClientsComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.clientService
|
this.clientService
|
||||||
.getClientKyc(client.id?.toString())
|
.getClientKyc(client.id?.toString(), true)
|
||||||
.pipe(catchError(() => of(null)))
|
.pipe(catchError(() => of(null)))
|
||||||
.subscribe((kyc: any) => {
|
.subscribe((kyc: any) => {
|
||||||
if (kyc) {
|
if (kyc) {
|
||||||
@@ -188,7 +197,7 @@ export class ClientsComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.clientService
|
this.clientService
|
||||||
.getClientAddressData(client.id?.toString())
|
.getClientAddressData(client.id?.toString(), true)
|
||||||
.pipe(catchError(() => of(null)))
|
.pipe(catchError(() => of(null)))
|
||||||
.subscribe((addr: any) => {
|
.subscribe((addr: any) => {
|
||||||
if (addr && Array.isArray(addr) && addr.length > 0) {
|
if (addr && Array.isArray(addr) && addr.length > 0) {
|
||||||
|
|||||||
@@ -8,7 +8,8 @@
|
|||||||
|
|
||||||
/** Angular Imports */
|
/** Angular Imports */
|
||||||
import { Injectable, inject } from '@angular/core';
|
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 */
|
/** rxjs Imports */
|
||||||
import { Observable, of, throwError } from 'rxjs';
|
import { Observable, of, throwError } from 'rxjs';
|
||||||
@@ -66,8 +67,11 @@ export class ClientsService {
|
|||||||
return this.http.get(`/clients/template?officeId=${officeId}&staffInSelectedOfficeOnly=true`);
|
return this.http.get(`/clients/template?officeId=${officeId}&staffInSelectedOfficeOnly=true`);
|
||||||
}
|
}
|
||||||
|
|
||||||
getClientData(clientId: string) {
|
getClientData(clientId: string, silent = false) {
|
||||||
return this.http.get(`/clients/${clientId}`);
|
return this.http.get(
|
||||||
|
`/clients/${clientId}`,
|
||||||
|
silent ? { context: new HttpContext().set(SILENT_HTTP_ERROR, true) } : {}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
createClient(client: any) {
|
createClient(client: any) {
|
||||||
@@ -153,8 +157,11 @@ export class ClientsService {
|
|||||||
return this.http.get(`/clients/${clientId}/accounts`);
|
return this.http.get(`/clients/${clientId}/accounts`);
|
||||||
}
|
}
|
||||||
|
|
||||||
getClientAddressData(clientId: string) {
|
getClientAddressData(clientId: string, silent = false) {
|
||||||
return this.http.get(`/client/${clientId}/addresses`);
|
return this.http.get(
|
||||||
|
`/client/${clientId}/addresses`,
|
||||||
|
silent ? { context: new HttpContext().set(SILENT_HTTP_ERROR, true) } : {}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
getClientChargesData(clientId: string) {
|
getClientChargesData(clientId: string) {
|
||||||
@@ -698,7 +705,8 @@ export class ClientsService {
|
|||||||
sortAttribute: string = '',
|
sortAttribute: string = '',
|
||||||
sortDirection: string = '',
|
sortDirection: string = '',
|
||||||
activeOnly: boolean = false,
|
activeOnly: boolean = false,
|
||||||
legalFormType: string = ''
|
legalFormType: string = '',
|
||||||
|
silent: boolean = false
|
||||||
) {
|
) {
|
||||||
const innerRequest: any = { text };
|
const innerRequest: any = { text };
|
||||||
if (activeOnly) {
|
if (activeOnly) {
|
||||||
@@ -727,7 +735,11 @@ export class ClientsService {
|
|||||||
]
|
]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return this.http.post(`/v2/clients/search`, request);
|
return this.http.post(
|
||||||
|
`/v2/clients/search`,
|
||||||
|
request,
|
||||||
|
silent ? { context: new HttpContext().set(SILENT_HTTP_ERROR, true) } : {}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -764,8 +776,11 @@ export class ClientsService {
|
|||||||
* Gets KYC details for a client.
|
* Gets KYC details for a client.
|
||||||
* @param clientId Client ID
|
* @param clientId Client ID
|
||||||
*/
|
*/
|
||||||
getClientKyc(clientId: string): Observable<any> {
|
getClientKyc(clientId: string, silent = false): Observable<any> {
|
||||||
return this.http.get(`/clients/${clientId}/kyc`);
|
return this.http.get(
|
||||||
|
`/clients/${clientId}/kyc`,
|
||||||
|
silent ? { context: new HttpContext().set(SILENT_HTTP_ERROR, true) } : {}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -935,4 +950,12 @@ export class ClientsService {
|
|||||||
submitClientForm60(clientId: string, data: any): Observable<any> {
|
submitClientForm60(clientId: string, data: any): Observable<any> {
|
||||||
return this.http.post(`/clients/${clientId}/form60`, data);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -392,6 +392,22 @@ export class AuthenticationService {
|
|||||||
return roleNames.some((n) => n === 'clerk');
|
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).
|
* Returns true ONLY for regular cashier role (not Head Cashier).
|
||||||
* Regular cashier → full-screen transaction entry, no nav.
|
* Regular cashier → full-screen transaction entry, no nav.
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { environment } from '../../../environments/environment';
|
|||||||
import { Logger } from '../logger/logger.service';
|
import { Logger } from '../logger/logger.service';
|
||||||
import { AlertService } from '../alert/alert.service';
|
import { AlertService } from '../alert/alert.service';
|
||||||
import { TranslateService } from '@ngx-translate/core'; // Added import for TranslateService
|
import { TranslateService } from '@ngx-translate/core'; // Added import for TranslateService
|
||||||
|
import { SILENT_HTTP_ERROR } from './silent-error.token';
|
||||||
|
|
||||||
/** Initialize Logger */
|
/** Initialize Logger */
|
||||||
const log = new Logger('ErrorHandlerInterceptor');
|
const log = new Logger('ErrorHandlerInterceptor');
|
||||||
@@ -57,13 +58,30 @@ export class ErrorHandlerInterceptor implements HttpInterceptor {
|
|||||||
// "No pending edit for this client" is the normal/expected state — checked on every edit-page
|
// "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.
|
// load, not a real error the user needs to see.
|
||||||
const isClientPendingEditRequest = request.url.match(/\/clients\/\d+\/edit\/pending$/);
|
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 =
|
const isSilentRequest =
|
||||||
isClientImageRequest || isClientKycRequest || isDirectorRequest || isClientPendingEditRequest;
|
isClientImageRequest ||
|
||||||
|
isClientKycRequest ||
|
||||||
|
isDirectorRequest ||
|
||||||
|
isClientPendingEditRequest ||
|
||||||
|
isKycRiskRulesRequest;
|
||||||
|
|
||||||
if (!environment.production && !isSilentRequest) {
|
// Explicit per-request opt-out (e.g. per-row enrichment fetches on a list page whose
|
||||||
|
// component already recovers via its own catchError) — silenced for every status, since
|
||||||
|
// the caller took responsibility for handling failure when it set this.
|
||||||
|
const isSilentOverride = request.context.get(SILENT_HTTP_ERROR);
|
||||||
|
|
||||||
|
if (!environment.production && !isSilentRequest && !isSilentOverride) {
|
||||||
log.error(`Request Error: ${errorMessage}`);
|
log.error(`Request Error: ${errorMessage}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isSilentOverride) {
|
||||||
|
throw response;
|
||||||
|
}
|
||||||
|
|
||||||
// Silently handle expected 403/404 errors (no image, no KYC record)
|
// Silently handle expected 403/404 errors (no image, no KYC record)
|
||||||
// Throw instead of EMPTY so component-level catchError can handle it
|
// Throw instead of EMPTY so component-level catchError can handle it
|
||||||
if (isSilentRequest && (status === 403 || status === 404)) {
|
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">
|
<button mat-menu-item tabindex="0">
|
||||||
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Share Issued
|
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Share Issued
|
||||||
</button>
|
</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
|
<fa-icon icon="users" class="menu-item-icon"></fa-icon> Share Holder
|
||||||
</button>
|
</button>
|
||||||
<button mat-menu-item tabindex="0">
|
<button mat-menu-item tabindex="0">
|
||||||
@@ -1167,10 +1167,6 @@
|
|||||||
<button mat-menu-item tabindex="0">
|
<button mat-menu-item tabindex="0">
|
||||||
<fa-icon icon="chart-bar" class="menu-item-icon"></fa-icon> Depreciation Register
|
<fa-icon icon="chart-bar" class="menu-item-icon"></fa-icon> Depreciation Register
|
||||||
</button>
|
</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>
|
</div>
|
||||||
<!-- Investment -->
|
<!-- Investment -->
|
||||||
<div class="deposit-menu-group">
|
<div class="deposit-menu-group">
|
||||||
@@ -1571,7 +1567,8 @@
|
|||||||
<a
|
<a
|
||||||
class="gl-link"
|
class="gl-link"
|
||||||
[class.hidden]="!matchesRbiSearch('CTR / STR')"
|
[class.hidden]="!matchesRbiSearch('CTR / STR')"
|
||||||
(click)="rbiMenuOpen = false; openCtrStrDialog()"
|
routerLink="/fiu"
|
||||||
|
(click)="rbiMenuOpen = false"
|
||||||
>
|
>
|
||||||
<fa-icon icon="calculator" class="gl-icon"></fa-icon> CTR / STR
|
<fa-icon icon="calculator" class="gl-icon"></fa-icon> CTR / STR
|
||||||
</a>
|
</a>
|
||||||
@@ -1668,8 +1665,13 @@
|
|||||||
>
|
>
|
||||||
<fa-icon icon="bell" class="gl-icon"></fa-icon> Daily Position Notice
|
<fa-icon icon="bell" class="gl-icon"></fa-icon> Daily Position Notice
|
||||||
</a>
|
</a>
|
||||||
<a class="gl-link" [class.hidden]="!matchesRbiSearch('R/SLR')" (click)="rbiMenuOpen = false">
|
<a
|
||||||
<fa-icon icon="lock" class="gl-icon"></fa-icon> R/SLR
|
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>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<!-- OSS Reports -->
|
<!-- OSS Reports -->
|
||||||
|
|||||||
@@ -280,23 +280,6 @@ export class ShellComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Opens the CTR & STR Compliance Center dialog.
|
|
||||||
*/
|
|
||||||
async openCtrStrDialog(): Promise<void> {
|
|
||||||
const { CtrStrDialogComponent } = await import('../../home/dashboard/ctr-str-dialog/ctr-str-dialog.component');
|
|
||||||
this.dialog.open(CtrStrDialogComponent, {
|
|
||||||
width: '95vw',
|
|
||||||
maxWidth: '1280px',
|
|
||||||
maxHeight: '92vh',
|
|
||||||
panelClass: 'crr-slr-dialog-panel',
|
|
||||||
autoFocus: false,
|
|
||||||
disableClose: false,
|
|
||||||
hasBackdrop: true,
|
|
||||||
backdropClass: 'ckyc-rpt-dlg-backdrop'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Opens the CIMS / XBRL Returns dialog. The initial form (OSS-2, PSA, RAL, RCP-UCB)
|
* Opens the CIMS / XBRL Returns dialog. The initial form (OSS-2, PSA, RAL, RCP-UCB)
|
||||||
* is selected based on the menu link the user clicked; user can switch between
|
* is selected based on the menu link the user clicked; user can switch between
|
||||||
|
|||||||
@@ -129,9 +129,130 @@
|
|||||||
<!-- CTR -->
|
<!-- CTR -->
|
||||||
@if (activeTab === 'ctr') {
|
@if (activeTab === 'ctr') {
|
||||||
<div class="fiu-section">
|
<div class="fiu-section">
|
||||||
|
<!-- LIVE: cash-related AML alerts awaiting AML Officer review -->
|
||||||
<div class="fiu-form-meta">
|
<div class="fiu-form-meta">
|
||||||
<div class="fiu-form-title">
|
<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>
|
<p>
|
||||||
All cash transactions ≥ ₹{{ bank.cashThreshold | number }} (single or aggregate per month per account)
|
All cash transactions ≥ ₹{{ bank.cashThreshold | number }} (single or aggregate per month per account)
|
||||||
</p>
|
</p>
|
||||||
@@ -221,9 +342,107 @@
|
|||||||
<!-- STR -->
|
<!-- STR -->
|
||||||
@if (activeTab === 'str') {
|
@if (activeTab === 'str') {
|
||||||
<div class="fiu-section">
|
<div class="fiu-section">
|
||||||
|
<!-- LIVE: STR cases with full Principal Officer actions -->
|
||||||
<div class="fiu-form-meta">
|
<div class="fiu-form-meta">
|
||||||
<div class="fiu-form-title">
|
<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>
|
<p>Detected by AML rules engine + branch compliance officers; filed within 7 days</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="fiu-btn fiu-btn-xml" (click)="exportStrXml()">
|
<button class="fiu-btn fiu-btn-xml" (click)="exportStrXml()">
|
||||||
|
|||||||
@@ -579,6 +579,41 @@
|
|||||||
background: var(--f-red-l);
|
background: var(--f-red-l);
|
||||||
color: var(--f-red);
|
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 {
|
.risk-pill {
|
||||||
@@ -892,3 +927,85 @@
|
|||||||
grid-template-columns: 1fr;
|
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/.
|
* 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 { FormsModule } from '@angular/forms';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
import { AmlService } from 'app/aml/aml.service';
|
||||||
|
import { AuthenticationService } from 'app/core/authentication/authentication.service';
|
||||||
import {
|
import {
|
||||||
CtrTransaction,
|
CtrTransaction,
|
||||||
CTR_TXNS,
|
CTR_TXNS,
|
||||||
@@ -33,7 +35,9 @@ import {
|
|||||||
FormsModule
|
FormsModule
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class FiuComponent {
|
export class FiuComponent implements OnInit {
|
||||||
|
private amlService = inject(AmlService);
|
||||||
|
private authenticationService = inject(AuthenticationService);
|
||||||
/** Active tab */
|
/** Active tab */
|
||||||
activeTab: 'dashboard' | 'ctr' | 'str' | 'customers' | 'files' = 'dashboard';
|
activeTab: 'dashboard' | 'ctr' | 'str' | 'customers' | 'files' = 'dashboard';
|
||||||
|
|
||||||
@@ -57,6 +61,126 @@ export class FiuComponent {
|
|||||||
toastVisible = false;
|
toastVisible = false;
|
||||||
private toastTimer: any;
|
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 {
|
showTab(tab: 'dashboard' | 'ctr' | 'str' | 'customers' | 'files'): void {
|
||||||
this.activeTab = tab;
|
this.activeTab = tab;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,285 +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/.
|
|
||||||
-->
|
|
||||||
|
|
||||||
<div class="ctr-str-dlg">
|
|
||||||
<!-- Dialog Header -->
|
|
||||||
<div class="dlg-header">
|
|
||||||
<div class="dlg-title-area">
|
|
||||||
<fa-icon icon="shield-alt" class="header-icon"></fa-icon>
|
|
||||||
<div>
|
|
||||||
<h2 class="title">CTR & STR Compliance Control Center</h2>
|
|
||||||
<p class="subtitle">Anti-Money Laundering (AML) & CFT Regulatory Reporting Terminal</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button mat-icon-button [mat-dialog-close]="true" class="close-btn">
|
|
||||||
<mat-icon>close</mat-icon>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tab Navigators -->
|
|
||||||
<div class="tab-navs">
|
|
||||||
<button class="tab-btn" [class.active]="activeTab === 'dashboard'" (click)="activeTab = 'dashboard'">
|
|
||||||
<fa-icon icon="chart-bar" class="tab-icon"></fa-icon> Compliance Dashboard
|
|
||||||
</button>
|
|
||||||
<button class="tab-btn" [class.active]="activeTab === 'guidelines'" (click)="activeTab = 'guidelines'">
|
|
||||||
<fa-icon icon="university" class="tab-icon"></fa-icon> RBI Regulatory Guidelines
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Dialog Body (Tabs) -->
|
|
||||||
<div class="dlg-body">
|
|
||||||
<!-- TAB 1: COMPLIANCE DASHBOARD -->
|
|
||||||
@if (activeTab === 'dashboard') {
|
|
||||||
<div class="tab-content dashboard-tab">
|
|
||||||
<!-- SECTION 1: CTR MANAGMENT -->
|
|
||||||
<div class="dashboard-section card-glass">
|
|
||||||
<div class="section-hdr">
|
|
||||||
<div>
|
|
||||||
<h3>Cash Transaction Reports (CTR - Monthly Cash Registry)</h3>
|
|
||||||
<p class="section-sub">
|
|
||||||
Scans and compiles cash transactions exceeding INR 10 Lakhs limit (Single or monthly cumulative cash
|
|
||||||
deposits/withdrawals).
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button class="action-btn primary" [disabled]="isGeneratingCtr" (click)="generateCtrReport()">
|
|
||||||
@if (isGeneratingCtr) {
|
|
||||||
<span class="spinner"></span> Generating Report...
|
|
||||||
} @else {
|
|
||||||
<fa-icon icon="sync-alt"></fa-icon> Generate Monthly CTR
|
|
||||||
}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="table-actions">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search CTR files by month or name..."
|
|
||||||
[(ngModel)]="ctrFilter"
|
|
||||||
class="search-input"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="table-container">
|
|
||||||
<table class="compliance-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Report Payload Name</th>
|
|
||||||
<th>Reporting Period</th>
|
|
||||||
<th>Record Count</th>
|
|
||||||
<th>Generated By</th>
|
|
||||||
<th>Generated Date</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (report of filteredCtrReports; track report.id) {
|
|
||||||
<tr>
|
|
||||||
<td class="bold-text"><fa-icon icon="file-code" class="xml-icon"></fa-icon> {{ report.name }}</td>
|
|
||||||
<td>{{ report.month }}</td>
|
|
||||||
<td class="center-text">
|
|
||||||
<span class="cnt-badge">{{ report.recordCount }} txns</span>
|
|
||||||
</td>
|
|
||||||
<td>{{ report.generatedBy }}</td>
|
|
||||||
<td>{{ report.generatedAt | date: 'medium' }}</td>
|
|
||||||
<td><span class="status-badge ready">Ready</span></td>
|
|
||||||
<td>
|
|
||||||
<button
|
|
||||||
class="icon-action-btn"
|
|
||||||
matTooltip="Download FIU-IND XML Payload"
|
|
||||||
(click)="downloadCtrFile(report.name)"
|
|
||||||
>
|
|
||||||
<fa-icon icon="download"></fa-icon> Download XML
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
} @empty {
|
|
||||||
<tr>
|
|
||||||
<td colspan="7" class="empty-state">No CTR reports found matching your search.</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- SECTION 2: STR ALERTS PIPELINE -->
|
|
||||||
<div class="dashboard-section card-glass mt-20">
|
|
||||||
<div class="section-hdr">
|
|
||||||
<div>
|
|
||||||
<h3>Suspicious Transaction Activity Pipeline (STR Management)</h3>
|
|
||||||
<p class="section-sub">
|
|
||||||
Behavioral & structural AML alerts generated by KYC profile rules engine. Investigator actions update
|
|
||||||
state in real-time.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button class="action-btn secondary" (click)="downloadStrReportTemplate()">
|
|
||||||
<fa-icon icon="file-excel"></fa-icon> Export Active Alerts (CSV)
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="table-actions">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search alerts by Client, CIF, or alert type..."
|
|
||||||
[(ngModel)]="strFilter"
|
|
||||||
class="search-input"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="alerts-grid">
|
|
||||||
@for (alert of filteredStrAlerts; track alert.id) {
|
|
||||||
<div class="alert-card" [class]="alert.status.toLowerCase().replace(' ', '-')">
|
|
||||||
<div class="card-hdr">
|
|
||||||
<div class="client-info">
|
|
||||||
<h4>{{ alert.clientName }}</h4>
|
|
||||||
<span class="cif-number">{{ alert.clientCif }}</span>
|
|
||||||
</div>
|
|
||||||
<span class="status-badge" [class]="alert.status.toLowerCase().replace(' ', '-')">{{
|
|
||||||
alert.status
|
|
||||||
}}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="amount-line">
|
|
||||||
<span class="label">Amount Highlighted:</span>
|
|
||||||
<span class="amount">₹{{ alert.amount | number: '1.2-2' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="type-line">
|
|
||||||
<span class="label">Alert Flag:</span>
|
|
||||||
<span class="type-value">{{ alert.alertType }}</span>
|
|
||||||
</div>
|
|
||||||
<p class="desc">{{ alert.description }}</p>
|
|
||||||
<span class="date-stamp"
|
|
||||||
><fa-icon icon="clock"></fa-icon> Flagged Date: {{ alert.date | date: 'mediumDate' }}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-footer">
|
|
||||||
@if (alert.status === 'Open') {
|
|
||||||
<button class="card-btn investigate" (click)="changeAlertStatus(alert.id, 'Investigating')">
|
|
||||||
<fa-icon icon="search"></fa-icon> Investigate
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
@if (alert.status === 'Open' || alert.status === 'Investigating') {
|
|
||||||
<button class="card-btn file-str" (click)="changeAlertStatus(alert.id, 'Reported STR')">
|
|
||||||
<fa-icon icon="flag"></fa-icon> File STR
|
|
||||||
</button>
|
|
||||||
<button class="card-btn close-alert" (click)="changeAlertStatus(alert.id, 'Closed')">
|
|
||||||
<fa-icon icon="ban"></fa-icon> Dismiss
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
@if (alert.status === 'Closed' || alert.status === 'Reported STR') {
|
|
||||||
<button class="card-btn reset-alert" (click)="changeAlertStatus(alert.id, 'Open')">
|
|
||||||
<fa-icon icon="redo-alt"></fa-icon> Reopen Alert
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
} @empty {
|
|
||||||
<div class="empty-alerts">No Suspicious Transaction alerts matching your criteria.</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- TAB 2: RBI REGULATORY GUIDELINES -->
|
|
||||||
@if (activeTab === 'guidelines') {
|
|
||||||
<div class="tab-content scroll-y">
|
|
||||||
<div class="info-layout">
|
|
||||||
<!-- CTR Rules Card -->
|
|
||||||
<div class="info-card card-glass">
|
|
||||||
<div class="card-title-badge ctr-badge-color">
|
|
||||||
<fa-icon icon="coins" class="badge-icon"></fa-icon> CTR - Cash Transaction Report
|
|
||||||
</div>
|
|
||||||
<div class="rules-body">
|
|
||||||
<p class="lead-desc">
|
|
||||||
As mandated by Section 12 of the **Prevention of Money Laundering Act (PMLA), 2002** and RBI KYC
|
|
||||||
guidelines, banks must record and report all high-value cash transactions.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="rule-point">
|
|
||||||
<h5>1. Transaction Threshold</h5>
|
|
||||||
<p>
|
|
||||||
All cash transactions (either deposits or withdrawals) of **value more than INR 10 Lakhs** (or its
|
|
||||||
equivalent in foreign currency) in a single transaction.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rule-point">
|
|
||||||
<h5>2. Monthly Aggregation Rule (Structuring check)</h5>
|
|
||||||
<p>
|
|
||||||
A series of cash transactions in a calendar month that are **integrally connected** and accumulate to
|
|
||||||
a total value exceeding **INR 10 Lakhs** must be compiled and reported in the monthly CTR.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rule-point">
|
|
||||||
<h5>3. Reporting Timeline</h5>
|
|
||||||
<p>
|
|
||||||
CTRs must be generated and uploaded to the FIU-IND gateway **by the 15th day of the succeeding month**
|
|
||||||
(e.g., June transactions must be uploaded by July 15th).
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rule-point text-warning-border">
|
|
||||||
<strong>Important Note:</strong> Transactions where the net cash balance does not change but high volume
|
|
||||||
is rotated (e.g., cash withdrawals immediately deposited back) must be reported under CTR as separate
|
|
||||||
debit and credit records.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- STR Rules Card -->
|
|
||||||
<div class="info-card card-glass">
|
|
||||||
<div class="card-title-badge str-badge-color">
|
|
||||||
<fa-icon icon="flag" class="badge-icon"></fa-icon> STR - Suspicious Transaction Report
|
|
||||||
</div>
|
|
||||||
<div class="rules-body">
|
|
||||||
<p class="lead-desc">
|
|
||||||
Every reporting entity must maintain vigilance over account transactions. If there are reasonable
|
|
||||||
grounds to suspect transactions involve proceeds of crime, an STR must be generated.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="rule-point">
|
|
||||||
<h5>1. What Constitutes Suspicion?</h5>
|
|
||||||
<p>
|
|
||||||
Transactions that are complex, show unusual patterns, mismatch the customer’s business/risk profile,
|
|
||||||
have no apparent economic rationale, or involve suspected proceeds of crime or terror funding.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rule-point">
|
|
||||||
<h5>2. No Minimum Threshold Limit</h5>
|
|
||||||
<p>
|
|
||||||
**There is no minimum monetary limit** for STR. An alert should be generated for any transaction,
|
|
||||||
regardless of size, if suspicious behavior is flagged.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rule-point">
|
|
||||||
<h5>3. Reporting Timeline</h5>
|
|
||||||
<p>
|
|
||||||
The STR must be filed to FIU-IND **within 7 days** of the Principal Compliance Officer confirming the
|
|
||||||
suspicion.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rule-point text-danger-border">
|
|
||||||
<strong>Strict Tipping-off Restriction:</strong> It is illegal to disclose or "tip-off" to the client
|
|
||||||
that their account is being monitored, investigated, or reported under an STR. Complete confidentiality
|
|
||||||
must be maintained.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,750 +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/.
|
|
||||||
*/
|
|
||||||
|
|
||||||
.ctr-str-dlg {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 90vh;
|
|
||||||
max-height: 820px;
|
|
||||||
background: #f8fafc;
|
|
||||||
color: #1e293b;
|
|
||||||
font-family: Inter, sans-serif;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Header
|
|
||||||
.dlg-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 16px 24px;
|
|
||||||
background: #fff;
|
|
||||||
border-bottom: 1px solid #e2e8f0;
|
|
||||||
|
|
||||||
.dlg-title-area {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 14px;
|
|
||||||
|
|
||||||
.header-icon {
|
|
||||||
font-size: 26px;
|
|
||||||
color: #4f46e5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #0f172a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subtitle {
|
|
||||||
margin: 2px 0 0;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #64748b;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.close-btn {
|
|
||||||
color: #64748b;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: #0f172a;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Navigation Tabs
|
|
||||||
.tab-navs {
|
|
||||||
display: flex;
|
|
||||||
gap: 6px;
|
|
||||||
background: #fff;
|
|
||||||
padding: 8px 24px;
|
|
||||||
border-bottom: 1px solid #e2e8f0;
|
|
||||||
|
|
||||||
.tab-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
padding: 10px 16px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #64748b;
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 6px;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
|
|
||||||
.tab-icon {
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: #f1f5f9;
|
|
||||||
color: #0f172a;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
background: #e0e7ff;
|
|
||||||
color: #4f46e5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scroll layout
|
|
||||||
.scroll-y {
|
|
||||||
overflow-y: auto;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dialog Body
|
|
||||||
.dlg-body {
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
padding: 24px;
|
|
||||||
background: #f1f5f9;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-content {
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
&.scroll-y {
|
|
||||||
overflow-y: auto;
|
|
||||||
padding-right: 6px;
|
|
||||||
|
|
||||||
/* Custom scrollbar */
|
|
||||||
&::-webkit-scrollbar {
|
|
||||||
width: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-webkit-scrollbar-thumb {
|
|
||||||
background: #cbd5e1;
|
|
||||||
border-radius: 3px;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: #94a3b8;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dashboard Tab specific layout
|
|
||||||
.dashboard-tab {
|
|
||||||
overflow-y: auto;
|
|
||||||
padding-right: 6px;
|
|
||||||
|
|
||||||
&::-webkit-scrollbar {
|
|
||||||
width: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-webkit-scrollbar-thumb {
|
|
||||||
background: #cbd5e1;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Card Style Container
|
|
||||||
.card-glass {
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 12px;
|
|
||||||
box-shadow:
|
|
||||||
0 4px 6px -1px rgb(0 0 0 / 5%),
|
|
||||||
0 2px 4px -1px rgb(0 0 0 / 2%);
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard-section {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
.section-hdr {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #0f172a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-sub {
|
|
||||||
margin: 4px 0 0;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #64748b;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.mt-20 {
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mt-25 {
|
|
||||||
margin-top: 25px;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Search input
|
|
||||||
.table-actions {
|
|
||||||
margin-bottom: 14px;
|
|
||||||
|
|
||||||
.search-input {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 380px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
font-size: 13px;
|
|
||||||
border: 1px solid #cbd5e1;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: #fff;
|
|
||||||
color: #1e293b;
|
|
||||||
outline: none;
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
border-color: #4f46e5;
|
|
||||||
box-shadow: 0 0 0 2px rgb(79 70 229 / 10%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Button styles
|
|
||||||
.action-btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 10px 16px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
|
|
||||||
&.primary {
|
|
||||||
background: #4f46e5;
|
|
||||||
color: #fff;
|
|
||||||
|
|
||||||
&:hover:not([disabled]) {
|
|
||||||
background: #4338ca;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
background: #94a3b8;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.secondary {
|
|
||||||
background: #0ea5e9;
|
|
||||||
color: #fff;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: #0284c7;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Spinner animation
|
|
||||||
.spinner {
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
border: 2px solid rgb(255 255 255 / 30%);
|
|
||||||
border-radius: 50%;
|
|
||||||
border-top-color: #fff;
|
|
||||||
animation: spin 0.8s linear infinite;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tables styling
|
|
||||||
.table-container {
|
|
||||||
overflow-x: auto;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.compliance-table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
font-size: 13px;
|
|
||||||
text-align: left;
|
|
||||||
|
|
||||||
th {
|
|
||||||
background: #f8fafc;
|
|
||||||
color: #475569;
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 12px 16px;
|
|
||||||
border-bottom: 1px solid #e2e8f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
td {
|
|
||||||
padding: 12px 16px;
|
|
||||||
border-bottom: 1px solid #f1f5f9;
|
|
||||||
color: #334155;
|
|
||||||
|
|
||||||
&.bold-text {
|
|
||||||
font-weight: 600;
|
|
||||||
color: #0f172a;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.center-text {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tr:last-child td {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cnt-badge {
|
|
||||||
background: #e2e8f0;
|
|
||||||
color: #475569;
|
|
||||||
padding: 3px 8px;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xml-icon {
|
|
||||||
color: #ea580c;
|
|
||||||
margin-right: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state {
|
|
||||||
text-align: center;
|
|
||||||
color: #64748b;
|
|
||||||
padding: 24px;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Icon Action Button
|
|
||||||
.icon-action-btn {
|
|
||||||
background: #f1f5f9;
|
|
||||||
border: 1px solid #cbd5e1;
|
|
||||||
color: #475569;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 6px 12px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: #e2e8f0;
|
|
||||||
color: #0f172a;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Alert Cards Grid
|
|
||||||
.alerts-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-card {
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 16px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
transition: box-shadow 0.2s ease;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
box-shadow: 0 4px 12px rgb(0 0 0 / 5%);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Left boundary color codes based on status
|
|
||||||
&.open {
|
|
||||||
border-left: 4px solid #dc2626;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.investigating {
|
|
||||||
border-left: 4px solid #ea580c;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.reported-str {
|
|
||||||
border-left: 4px solid #16a34a;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.closed {
|
|
||||||
border-left: 4px solid #64748b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-hdr {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: flex-start;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
|
|
||||||
h4 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #0f172a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cif-number {
|
|
||||||
font-size: 11px;
|
|
||||||
color: #64748b;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-body {
|
|
||||||
flex: 1;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #475569;
|
|
||||||
|
|
||||||
.amount-line,
|
|
||||||
.type-line {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
|
|
||||||
.label {
|
|
||||||
color: #64748b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.amount {
|
|
||||||
font-weight: 700;
|
|
||||||
color: #0f172a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.type-value {
|
|
||||||
font-weight: 600;
|
|
||||||
color: #4f46e5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.desc {
|
|
||||||
margin: 8px 0;
|
|
||||||
line-height: 1.4;
|
|
||||||
background: #f8fafc;
|
|
||||||
padding: 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px dashed #e2e8f0;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.date-stamp {
|
|
||||||
font-size: 11px;
|
|
||||||
color: #94a3b8;
|
|
||||||
display: block;
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-footer {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 6px;
|
|
||||||
margin-top: 14px;
|
|
||||||
border-top: 1px solid #f1f5f9;
|
|
||||||
padding-top: 12px;
|
|
||||||
|
|
||||||
.card-btn {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 75px;
|
|
||||||
padding: 6px 8px;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 4px;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
|
|
||||||
&.investigate {
|
|
||||||
background: #fef3c7;
|
|
||||||
color: #d97706;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: #fde68a;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.file-str {
|
|
||||||
background: #dcfce7;
|
|
||||||
color: #16a34a;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: #bbf7d0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.close-alert {
|
|
||||||
background: #fee2e2;
|
|
||||||
color: #dc2626;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: #fecaca;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.reset-alert {
|
|
||||||
background: #f1f5f9;
|
|
||||||
color: #475569;
|
|
||||||
border: 1px solid #cbd5e1;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: #e2e8f0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Badges
|
|
||||||
.status-badge {
|
|
||||||
padding: 3px 8px;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 600;
|
|
||||||
|
|
||||||
&.ready {
|
|
||||||
background: #dcfce7;
|
|
||||||
color: #16a34a;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.open {
|
|
||||||
background: #fee2e2;
|
|
||||||
color: #dc2626;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.investigating {
|
|
||||||
background: #fef3c7;
|
|
||||||
color: #d97706;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.reported-str {
|
|
||||||
background: #dbeafe;
|
|
||||||
color: #2563eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.closed {
|
|
||||||
background: #f1f5f9;
|
|
||||||
color: #475569;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-alerts {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
text-align: center;
|
|
||||||
padding: 36px;
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
border-radius: 8px;
|
|
||||||
color: #64748b;
|
|
||||||
font-style: italic;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tab 2: Guidelines layout
|
|
||||||
.info-layout {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
gap: 20px;
|
|
||||||
|
|
||||||
@media (width >= 900px) {
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-card {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
.card-title-badge {
|
|
||||||
align-self: flex-start;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 6px 14px;
|
|
||||||
border-radius: 20px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ctr-badge-color {
|
|
||||||
background: #ea580c;
|
|
||||||
}
|
|
||||||
|
|
||||||
.str-badge-color {
|
|
||||||
background: #be185d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rules-body {
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.5;
|
|
||||||
color: #334155;
|
|
||||||
|
|
||||||
.lead-desc {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #0f172a;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rule-point {
|
|
||||||
margin-bottom: 18px;
|
|
||||||
|
|
||||||
h5 {
|
|
||||||
margin: 0 0 6px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #0f172a;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin: 0;
|
|
||||||
color: #475569;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-warning-border {
|
|
||||||
border-left: 3px solid #eab308;
|
|
||||||
background: #fef9c3;
|
|
||||||
padding: 10px 14px;
|
|
||||||
border-radius: 0 6px 6px 0;
|
|
||||||
color: #854d0e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-danger-border {
|
|
||||||
border-left: 3px solid #ef4444;
|
|
||||||
background: #fee2e2;
|
|
||||||
padding: 10px 14px;
|
|
||||||
border-radius: 0 6px 6px 0;
|
|
||||||
color: #991b1b;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tab 3: Architecture & Schema
|
|
||||||
.arch-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #0f172a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.desc {
|
|
||||||
margin: 4px 0 20px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #64748b;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.arch-section {
|
|
||||||
h4 {
|
|
||||||
margin: 0 0 4px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #0f172a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-sub {
|
|
||||||
margin: 0 0 10px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #64748b;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.code-snippet {
|
|
||||||
background: #0f172a;
|
|
||||||
color: #e2e8f0;
|
|
||||||
padding: 14px;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-family: 'Courier New', Courier, monospace;
|
|
||||||
font-size: 12px;
|
|
||||||
overflow-x: auto;
|
|
||||||
line-height: 1.4;
|
|
||||||
margin: 0;
|
|
||||||
|
|
||||||
&.plain {
|
|
||||||
background: #1e293b;
|
|
||||||
color: #94a3b8;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// API Routes
|
|
||||||
.route-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
background: #f8fafc;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
border-radius: 6px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
font-size: 12px;
|
|
||||||
|
|
||||||
.method {
|
|
||||||
padding: 3px 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #fff;
|
|
||||||
min-width: 45px;
|
|
||||||
text-align: center;
|
|
||||||
|
|
||||||
&.get {
|
|
||||||
background: #16a34a;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.put {
|
|
||||||
background: #ea580c;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.post {
|
|
||||||
background: #2563eb;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.path {
|
|
||||||
font-family: 'Courier New', Courier, monospace;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #0f172a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.route-desc {
|
|
||||||
color: #475569;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,279 +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/.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Component, inject } from '@angular/core';
|
|
||||||
import { CommonModule } from '@angular/common';
|
|
||||||
import { FormsModule } from '@angular/forms';
|
|
||||||
import { MatDialogRef, MatDialogModule } from '@angular/material/dialog';
|
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
|
||||||
import { MatTabsModule } from '@angular/material/tabs';
|
|
||||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
|
||||||
import { FaIconComponent, FaIconLibrary } from '@fortawesome/angular-fontawesome';
|
|
||||||
import {
|
|
||||||
faShieldAlt,
|
|
||||||
faChartBar,
|
|
||||||
faUniversity,
|
|
||||||
faCode,
|
|
||||||
faSyncAlt,
|
|
||||||
faFileExcel,
|
|
||||||
faFileCode,
|
|
||||||
faDownload,
|
|
||||||
faClock,
|
|
||||||
faSearch,
|
|
||||||
faFlag,
|
|
||||||
faBan,
|
|
||||||
faRedoAlt,
|
|
||||||
faCoins
|
|
||||||
} from '@fortawesome/free-solid-svg-icons';
|
|
||||||
|
|
||||||
interface CtrReport {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
month: string;
|
|
||||||
recordCount: number;
|
|
||||||
generatedBy: string;
|
|
||||||
generatedAt: Date;
|
|
||||||
status: 'Ready' | 'Generating';
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StrAlert {
|
|
||||||
id: number;
|
|
||||||
clientName: string;
|
|
||||||
clientCif: string;
|
|
||||||
alertType: string;
|
|
||||||
amount: number;
|
|
||||||
status: 'Open' | 'Investigating' | 'Reported STR' | 'Closed';
|
|
||||||
description: string;
|
|
||||||
date: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'mifosx-ctr-str-dialog',
|
|
||||||
standalone: true,
|
|
||||||
templateUrl: './ctr-str-dialog.component.html',
|
|
||||||
styleUrls: ['./ctr-str-dialog.component.scss'],
|
|
||||||
imports: [
|
|
||||||
CommonModule,
|
|
||||||
FormsModule,
|
|
||||||
MatDialogModule,
|
|
||||||
MatButtonModule,
|
|
||||||
MatTabsModule,
|
|
||||||
MatTooltipModule,
|
|
||||||
MatIconModule,
|
|
||||||
FaIconComponent
|
|
||||||
]
|
|
||||||
})
|
|
||||||
export class CtrStrDialogComponent {
|
|
||||||
dialogRef = inject(MatDialogRef<CtrStrDialogComponent>);
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
const library = inject(FaIconLibrary);
|
|
||||||
library.addIcons(
|
|
||||||
faShieldAlt,
|
|
||||||
faChartBar,
|
|
||||||
faUniversity,
|
|
||||||
faCode,
|
|
||||||
faSyncAlt,
|
|
||||||
faFileExcel,
|
|
||||||
faFileCode,
|
|
||||||
faDownload,
|
|
||||||
faClock,
|
|
||||||
faSearch,
|
|
||||||
faFlag,
|
|
||||||
faBan,
|
|
||||||
faRedoAlt,
|
|
||||||
faCoins
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
activeTab: 'dashboard' | 'guidelines' = 'dashboard';
|
|
||||||
|
|
||||||
// Mock State
|
|
||||||
isGeneratingCtr = false;
|
|
||||||
ctrFilter = '';
|
|
||||||
strFilter = '';
|
|
||||||
|
|
||||||
ctrReports: CtrReport[] = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
name: 'CTR_REPORT_JUNE_2026.xml',
|
|
||||||
month: 'June 2026',
|
|
||||||
recordCount: 14,
|
|
||||||
generatedBy: 'Compliance_Officer_1',
|
|
||||||
generatedAt: new Date(2026, 6, 5, 10, 30),
|
|
||||||
status: 'Ready'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
name: 'CTR_REPORT_MAY_2026.xml',
|
|
||||||
month: 'May 2026',
|
|
||||||
recordCount: 22,
|
|
||||||
generatedBy: 'Compliance_Officer_1',
|
|
||||||
generatedAt: new Date(2026, 5, 4, 14, 15),
|
|
||||||
status: 'Ready'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
name: 'CTR_REPORT_APRIL_2026.xml',
|
|
||||||
month: 'April 2026',
|
|
||||||
recordCount: 9,
|
|
||||||
generatedBy: 'Compliance_Officer_2',
|
|
||||||
generatedAt: new Date(2026, 4, 3, 11, 45),
|
|
||||||
status: 'Ready'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
strAlerts: StrAlert[] = [
|
|
||||||
{
|
|
||||||
id: 101,
|
|
||||||
clientName: 'Aarav Sharma',
|
|
||||||
clientCif: 'CIF982348',
|
|
||||||
alertType: 'Profile Mismatch',
|
|
||||||
amount: 450000,
|
|
||||||
status: 'Open',
|
|
||||||
description:
|
|
||||||
'Student account received a large inward transfer (INR 4.5L) mismatching declared zero/student income category.',
|
|
||||||
date: new Date(2026, 6, 26)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 102,
|
|
||||||
clientName: 'Karan Malhotra',
|
|
||||||
clientCif: 'CIF229482',
|
|
||||||
alertType: 'Structuring (Smurfing)',
|
|
||||||
amount: 980000,
|
|
||||||
status: 'Investigating',
|
|
||||||
description:
|
|
||||||
'Multiple cash deposits of INR 2.45L, 2.45L, 2.45L, 2.45L within 10 days, totaling INR 9.8L cash (deliberately staying below the 10L single cash transaction CTR threshold).',
|
|
||||||
date: new Date(2026, 6, 24)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 103,
|
|
||||||
clientName: 'Nexora Real Estate Ltd',
|
|
||||||
clientCif: 'CIF500481',
|
|
||||||
alertType: 'Turnover Outlier',
|
|
||||||
amount: 15000000,
|
|
||||||
status: 'Open',
|
|
||||||
description:
|
|
||||||
'Entity account registered an outward wire of INR 1.5Cr when maximum monthly turnover limit is set to INR 50L.',
|
|
||||||
date: new Date(2026, 6, 20)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 104,
|
|
||||||
clientName: 'Priyanka Patel',
|
|
||||||
clientCif: 'CIF774920',
|
|
||||||
alertType: 'Dormant Account Reactivation',
|
|
||||||
amount: 600000,
|
|
||||||
status: 'Reported STR',
|
|
||||||
description:
|
|
||||||
'Account inactive for 18 months suddenly received a cash deposit of INR 6L, followed by immediate online transfers.',
|
|
||||||
date: new Date(2026, 6, 15)
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
get filteredCtrReports() {
|
|
||||||
return this.ctrReports.filter(
|
|
||||||
(r) =>
|
|
||||||
r.name.toLowerCase().includes(this.ctrFilter.toLowerCase()) ||
|
|
||||||
r.month.toLowerCase().includes(this.ctrFilter.toLowerCase())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
get filteredStrAlerts() {
|
|
||||||
return this.strAlerts.filter(
|
|
||||||
(a) =>
|
|
||||||
a.clientName.toLowerCase().includes(this.strFilter.toLowerCase()) ||
|
|
||||||
a.clientCif.toLowerCase().includes(this.strFilter.toLowerCase()) ||
|
|
||||||
a.alertType.toLowerCase().includes(this.strFilter.toLowerCase())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
generateCtrReport() {
|
|
||||||
if (this.isGeneratingCtr) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.isGeneratingCtr = true;
|
|
||||||
|
|
||||||
// Simulate API call to Spring Boot batch job
|
|
||||||
setTimeout(() => {
|
|
||||||
const date = new Date();
|
|
||||||
const newReport: CtrReport = {
|
|
||||||
id: this.ctrReports.length + 1,
|
|
||||||
name: `CTR_REPORT_JULY_2026_DRAFT.xml`,
|
|
||||||
month: 'July 2026 (Draft)',
|
|
||||||
recordCount: 8,
|
|
||||||
generatedBy: 'Current_Admin',
|
|
||||||
generatedAt: date,
|
|
||||||
status: 'Ready'
|
|
||||||
};
|
|
||||||
this.ctrReports.unshift(newReport);
|
|
||||||
this.isGeneratingCtr = false;
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadCtrFile(reportName: string) {
|
|
||||||
const dummyXml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<FIU-IND-CTR-Report xmlns="http://fiuindia.gov.in/ctr/v1">
|
|
||||||
<Header>
|
|
||||||
<ReportType>CTR</ReportType>
|
|
||||||
<ReportingEntityName>CBS Nexora Bank</ReportingEntityName>
|
|
||||||
<ReportingPeriod>2026-06</ReportingPeriod>
|
|
||||||
</Header>
|
|
||||||
<ReportData>
|
|
||||||
<Record>
|
|
||||||
<TransactionID>TXN88924824</TransactionID>
|
|
||||||
<Date>2026-06-15</Date>
|
|
||||||
<Mode>CASH</Mode>
|
|
||||||
<Amount>1250000.00</Amount>
|
|
||||||
<AccountType>SAVINGS</AccountType>
|
|
||||||
<CustomerName>Aarav Sharma</CustomerName>
|
|
||||||
<CifNumber>CIF982348</CifNumber>
|
|
||||||
</Record>
|
|
||||||
</ReportData>
|
|
||||||
</FIU-IND-CTR-Report>`;
|
|
||||||
|
|
||||||
const blob = new Blob([dummyXml], { type: 'application/xml' });
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = reportName;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
changeAlertStatus(alertId: number, newStatus: 'Open' | 'Investigating' | 'Reported STR' | 'Closed') {
|
|
||||||
const alert = this.strAlerts.find((a) => a.id === alertId);
|
|
||||||
if (alert) {
|
|
||||||
alert.status = newStatus;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadStrReportTemplate() {
|
|
||||||
const dummyCsv =
|
|
||||||
`AlertID,ClientName,CIF,AlertType,Amount,Status,Description\n` +
|
|
||||||
this.strAlerts
|
|
||||||
.map(
|
|
||||||
(a) =>
|
|
||||||
`"${a.id}","${a.clientName}","${a.clientCif}","${a.alertType}","${a.amount}","${a.status}","${a.description.replace(/"/g, '""')}"`
|
|
||||||
)
|
|
||||||
.join('\n');
|
|
||||||
|
|
||||||
const blob = new Blob([dummyCsv], { type: 'text/csv' });
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = `STR_AML_ALERTS_EXPORT.csv`;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -40,6 +40,10 @@ const routes: Routes = [
|
|||||||
resolve: {
|
resolve: {
|
||||||
offices: OfficesResolver
|
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 { Route } from '../core/route/route.service';
|
||||||
import { RbiCircularsComponent } from './rbi-circulars.component';
|
import { RbiCircularsComponent } from './rbi-circulars.component';
|
||||||
|
import { AmlRulesComponent } from './aml-rules/aml-rules.component';
|
||||||
|
|
||||||
const routes: Routes = [
|
const routes: Routes = [
|
||||||
Route.withShell([
|
Route.withShell([
|
||||||
@@ -18,6 +19,11 @@ const routes: Routes = [
|
|||||||
path: 'rbi-circulars',
|
path: 'rbi-circulars',
|
||||||
component: RbiCircularsComponent,
|
component: RbiCircularsComponent,
|
||||||
data: { title: 'RBI Circulars', breadcrumb: 'RBI Circulars' }
|
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 { CreateSharesAccountComponent } from './create-shares-account/create-shares-account.component';
|
||||||
import { EditSharesAccountComponent } from './edit-shares-account/edit-shares-account.component';
|
import { EditSharesAccountComponent } from './edit-shares-account/edit-shares-account.component';
|
||||||
import { SharesAccountActionsComponent } from './shares-account-actions/shares-account-actions.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 */
|
/** Custom Resolvers */
|
||||||
import { SharesAccountViewResolver } from './common-resolvers/share-account-view.resolver';
|
import { SharesAccountViewResolver } from './common-resolvers/share-account-view.resolver';
|
||||||
@@ -33,6 +33,11 @@ const routes: Routes = [
|
|||||||
path: '',
|
path: '',
|
||||||
data: { title: 'Shares', breadcrumb: 'Shares', routeParamBreadcrumb: false, addBreadcrumbLink: false },
|
data: { title: 'Shares', breadcrumb: 'Shares', routeParamBreadcrumb: false, addBreadcrumbLink: false },
|
||||||
children: [
|
children: [
|
||||||
|
{
|
||||||
|
path: 'share-holders',
|
||||||
|
data: { title: 'Share Holders', breadcrumb: 'Share Holders' },
|
||||||
|
component: ShareHoldersComponent
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'create',
|
path: 'create',
|
||||||
data: { title: 'Create Shares Account', breadcrumb: 'Create Shares Account' },
|
data: { title: 'Create Shares Account', breadcrumb: 'Create Shares Account' },
|
||||||
@@ -90,14 +95,6 @@ const routes: Routes = [
|
|||||||
sharesAccountAndTemplate: SharesAccountAndTemplateResolver
|
sharesAccountAndTemplate: SharesAccountAndTemplateResolver
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: 'edit-details',
|
|
||||||
data: { title: 'Edit Share Account Details', breadcrumb: 'Edit Details', routeParamBreadcrumb: false },
|
|
||||||
component: EditShareAccountDetailsComponent,
|
|
||||||
resolve: {
|
|
||||||
sharesAccountAndTemplate: SharesAccountAndTemplateResolver
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: 'actions/:name',
|
path: 'actions/:name',
|
||||||
data: { title: 'Shares Account Actions', breadcrumb: 'Actions', routeParamBreadcrumb: '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 { 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 { 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 { 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
|
* Shares Module
|
||||||
@@ -45,6 +54,15 @@ import { GeneralTabComponent } from './shares-account-view/general-tab/general-t
|
|||||||
*/
|
*/
|
||||||
@NgModule({
|
@NgModule({
|
||||||
imports: [
|
imports: [
|
||||||
|
ShareHoldersComponent,
|
||||||
|
SharedModule,
|
||||||
|
MatTableModule,
|
||||||
|
MatPaginatorModule,
|
||||||
|
MatSortModule,
|
||||||
|
MatCardModule,
|
||||||
|
MatProgressBarModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatInputModule,
|
||||||
SharedModule,
|
SharedModule,
|
||||||
PipesModule,
|
PipesModule,
|
||||||
DirectivesModule,
|
DirectivesModule,
|
||||||
|
|||||||
@@ -22,6 +22,39 @@ import { Observable } from 'rxjs';
|
|||||||
export class SharesService {
|
export class SharesService {
|
||||||
private http = inject(HttpClient);
|
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 {string} accountId Shares Account Id of account to get data for.
|
||||||
* @param {boolean} template Shares account template required?.
|
* @param {boolean} template Shares account template required?.
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
/* tslint:disable */
|
/* tslint:disable */
|
||||||
export default {
|
export default {
|
||||||
'mifos_x': {
|
'mifos_x': {
|
||||||
'version': '260729',
|
'version': '260731',
|
||||||
'hash': 'c1fc95d9'
|
'hash': 'ca85bae4'
|
||||||
},
|
},
|
||||||
'allow_switching_backend_instance': true
|
'allow_switching_backend_instance': true
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user