feat: add KYC document renewal alerts, Aadhaar/PAN exceptions, and AML recurring alerts UI; remove old journal AML marking
This commit is contained in:
+126
@@ -0,0 +1,126 @@
|
||||
<!--
|
||||
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 class="rule-dialog-title">
|
||||
<mat-icon>add_moderator</mat-icon>
|
||||
Add AML Rule
|
||||
</h2>
|
||||
|
||||
<mat-dialog-content class="rule-dialog-content">
|
||||
<form [formGroup]="ruleForm" class="rule-form" autocomplete="off">
|
||||
<div *ngIf="errorMessage" class="rule-form-error">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
<span>{{ errorMessage }}</span>
|
||||
</div>
|
||||
|
||||
<mat-form-field appearance="outline" class="rule-field-full">
|
||||
<mat-label>Rule Description</mat-label>
|
||||
<textarea
|
||||
matInput
|
||||
rows="2"
|
||||
formControlName="ruleName"
|
||||
placeholder="e.g. Cash deposits exceeding the monthly threshold"
|
||||
></textarea>
|
||||
<mat-error *ngIf="ruleForm.get('ruleName')?.hasError('required')">Rule description is required</mat-error>
|
||||
<mat-error *ngIf="ruleForm.get('ruleName')?.hasError('maxlength')">Keep this under 100 characters</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="rule-field-full">
|
||||
<mat-label>Rule Reason / Criteria</mat-label>
|
||||
<textarea
|
||||
matInput
|
||||
rows="3"
|
||||
formControlName="reason"
|
||||
placeholder="Why the rule exists and what it looks for"
|
||||
></textarea>
|
||||
<mat-error *ngIf="ruleForm.get('reason')?.hasError('required')">Reason is required</mat-error>
|
||||
<mat-error *ngIf="ruleForm.get('reason')?.hasError('maxlength')">Keep this under 500 characters</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<div class="rule-grid">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Risk Category</mat-label>
|
||||
<mat-select formControlName="riskCategory">
|
||||
<mat-option *ngFor="let r of riskCategories" [value]="r.value">{{ r.label }}</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Applies Over</mat-label>
|
||||
<mat-select formControlName="txnWindowDays">
|
||||
<mat-option *ngFor="let w of windowOptions" [value]="w.value">{{ w.label }}</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Minimum Limit</mat-label>
|
||||
<span matPrefix class="rule-field-prefix">₹</span>
|
||||
<input matInput type="number" min="0" formControlName="txnMinLimitAmount" />
|
||||
<mat-hint>Leave blank for a non-amount rule</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Maximum Limit</mat-label>
|
||||
<span matPrefix class="rule-field-prefix">₹</span>
|
||||
<input matInput type="number" min="0" formControlName="txnMaxLimitAmount" />
|
||||
<mat-hint>Blank means no upper limit</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Rule Type</mat-label>
|
||||
<mat-select formControlName="txnRuleType">
|
||||
<mat-option *ngFor="let t of ruleTypes" [value]="t">{{ t }}</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Applies To</mat-label>
|
||||
<mat-select formControlName="txnAppliesTo">
|
||||
<mat-option *ngFor="let a of appliesToOptions" [value]="a">{{ a }}</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>AML Check Status</mat-label>
|
||||
<mat-select formControlName="amlCheckStatus">
|
||||
<mat-option *ngFor="let s of amlCheckStatuses" [value]="s">{{ s }}</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Banned Entity Match</mat-label>
|
||||
<mat-select formControlName="bannedEntityMatch">
|
||||
<mat-option *ngFor="let y of yesNo" [value]="y">{{ y }}</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div *ngIf="bandInverted" class="rule-form-error">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
<span>Maximum limit cannot be lower than the minimum — the rule would never match.</span>
|
||||
</div>
|
||||
|
||||
<p class="rule-form-note">
|
||||
Amount or window details also create the matching live-transaction rule. Leave them blank for a profile-only rule.
|
||||
</p>
|
||||
</form>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end" class="rule-dialog-actions">
|
||||
<button mat-stroked-button type="button" [disabled]="submitting" (click)="cancel()">Cancel</button>
|
||||
<button
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
type="button"
|
||||
[disabled]="ruleForm.invalid || bandInverted || submitting"
|
||||
(click)="submit()"
|
||||
>
|
||||
<mat-spinner *ngIf="submitting" diameter="18" class="rule-submit-spinner"></mat-spinner>
|
||||
{{ submitting ? 'Saving…' : 'Add Rule' }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
.rule-dialog-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: #1a237e;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rule-dialog-content {
|
||||
padding-top: 8px;
|
||||
min-width: 560px;
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.rule-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.rule-field-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.rule-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 4px 16px;
|
||||
}
|
||||
|
||||
.rule-field-prefix {
|
||||
color: #64748b;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.rule-form-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 12px;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 6px;
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
font-size: 13px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.rule-form-note {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12.5px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.rule-dialog-actions {
|
||||
padding: 8px 24px 20px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rule-submit-spinner {
|
||||
display: inline-block;
|
||||
margin-right: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@media (width <= 640px) {
|
||||
.rule-dialog-content {
|
||||
min-width: unset;
|
||||
}
|
||||
|
||||
.rule-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* 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, inject } from '@angular/core';
|
||||
import { FormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
/** Custom Services */
|
||||
import { ClientsService } from '../../../clients/clients.service';
|
||||
|
||||
/**
|
||||
* Authoring form for a new AML catalog rule.
|
||||
*
|
||||
* The screen the officer came from renders one flat table, but a rule is stored across two
|
||||
* tables: the profile rule in m_client_risk_rule and, when a transaction band or observation
|
||||
* window is given, an evaluable counterpart in m_aml_transaction_rule. That split is the
|
||||
* backend's concern — this form stays shaped like the table it was opened from.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-add-aml-rule-dialog',
|
||||
templateUrl: './add-aml-rule-dialog.component.html',
|
||||
styleUrls: ['./add-aml-rule-dialog.component.scss'],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
MatDialogModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatProgressSpinnerModule
|
||||
]
|
||||
})
|
||||
export class AddAmlRuleDialogComponent {
|
||||
private formBuilder = inject(FormBuilder);
|
||||
private clientsService = inject(ClientsService);
|
||||
private dialogRef = inject(MatDialogRef<AddAmlRuleDialogComponent>);
|
||||
|
||||
submitting = false;
|
||||
/** Server-side rejection, surfaced in the dialog so the officer keeps their input. */
|
||||
errorMessage = '';
|
||||
|
||||
readonly riskCategories = [
|
||||
{ value: 3, label: 'High Risk' },
|
||||
{ value: 2, label: 'Medium Risk' },
|
||||
{ value: 1, label: 'Low Risk' }
|
||||
];
|
||||
|
||||
/** Mirrors the "Applies Over" column: window in days, per-transaction being no window. */
|
||||
readonly windowOptions = [
|
||||
{ value: 0, label: 'Per transaction' },
|
||||
{ value: 1, label: 'Daily' },
|
||||
{ value: 7, label: 'Weekly' },
|
||||
{ value: 30, label: 'Monthly' }
|
||||
];
|
||||
|
||||
readonly ruleTypes = [
|
||||
'THRESHOLD',
|
||||
'STRUCTURING',
|
||||
'VELOCITY'
|
||||
];
|
||||
readonly appliesToOptions = [
|
||||
'ALL',
|
||||
'SAVINGS',
|
||||
'LOAN',
|
||||
'TRANSFER'
|
||||
];
|
||||
readonly amlCheckStatuses = [
|
||||
'CLEAR',
|
||||
'FLAGGED',
|
||||
'PENDING'
|
||||
];
|
||||
readonly yesNo = [
|
||||
'NO',
|
||||
'YES'
|
||||
];
|
||||
|
||||
ruleForm = this.formBuilder.group({
|
||||
ruleName: [
|
||||
'',
|
||||
[
|
||||
Validators.required,
|
||||
Validators.maxLength(100)
|
||||
]
|
||||
],
|
||||
reason: [
|
||||
'',
|
||||
[
|
||||
Validators.required,
|
||||
Validators.maxLength(500)
|
||||
]
|
||||
],
|
||||
riskCategory: [
|
||||
3,
|
||||
Validators.required
|
||||
],
|
||||
txnWindowDays: [0],
|
||||
txnRuleType: ['THRESHOLD'],
|
||||
txnAppliesTo: ['ALL'],
|
||||
txnMinLimitAmount: [
|
||||
null as
|
||||
| number
|
||||
| null,
|
||||
Validators.min(0)
|
||||
],
|
||||
txnMaxLimitAmount: [
|
||||
null as
|
||||
| number
|
||||
| null,
|
||||
Validators.min(0)
|
||||
],
|
||||
amlCheckStatus: ['FLAGGED'],
|
||||
bannedEntityMatch: ['NO'],
|
||||
willfulDefaulter: ['NO'],
|
||||
ecgcSalFlag: ['NO'],
|
||||
dormantFlag: ['ACTIVE']
|
||||
});
|
||||
|
||||
/** Mirrors the backend band check so the officer is told before the round trip. */
|
||||
get bandInverted(): boolean {
|
||||
const min = this.ruleForm.value.txnMinLimitAmount;
|
||||
const max = this.ruleForm.value.txnMaxLimitAmount;
|
||||
return min != null && max != null && Number(max) < Number(min);
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (this.ruleForm.invalid || this.bandInverted) {
|
||||
this.ruleForm.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
this.submitting = true;
|
||||
this.errorMessage = '';
|
||||
this.clientsService.createClientRiskRule(this.buildPayload()).subscribe({
|
||||
next: (rule) => {
|
||||
this.submitting = false;
|
||||
this.dialogRef.close(rule);
|
||||
},
|
||||
error: (err) => {
|
||||
this.submitting = false;
|
||||
this.errorMessage = this.readError(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
private buildPayload(): any {
|
||||
const v = this.ruleForm.value;
|
||||
const amount = (n: any) => (n === null || n === undefined || n === '' ? null : Number(n));
|
||||
return {
|
||||
ruleName: (v.ruleName || '').trim(),
|
||||
reason: (v.reason || '').trim(),
|
||||
riskCategory: v.riskCategory,
|
||||
amlCheckStatus: v.amlCheckStatus,
|
||||
bannedEntityMatch: v.bannedEntityMatch,
|
||||
willfulDefaulter: v.willfulDefaulter,
|
||||
ecgcSalFlag: v.ecgcSalFlag,
|
||||
dormantFlag: v.dormantFlag,
|
||||
// A window of 0 means per-transaction, which the backend stores as a null window rather
|
||||
// than a zero-day one.
|
||||
txnWindowDays: v.txnWindowDays ? v.txnWindowDays : null,
|
||||
txnRuleType: v.txnRuleType,
|
||||
txnAppliesTo: v.txnAppliesTo,
|
||||
txnMinLimitAmount: amount(v.txnMinLimitAmount),
|
||||
txnMaxLimitAmount: amount(v.txnMaxLimitAmount)
|
||||
};
|
||||
}
|
||||
|
||||
/** Fineract returns field-level failures under errors[]; fall back to the envelope message. */
|
||||
private readError(err: any): string {
|
||||
const body = err?.error;
|
||||
const first = body?.errors?.[0];
|
||||
return (
|
||||
first?.defaultUserMessage || first?.developerMessage || body?.defaultUserMessage || 'Could not save the rule.'
|
||||
);
|
||||
}
|
||||
}
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
<!--
|
||||
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="flagged-toolbar">
|
||||
<div class="flagged-live">
|
||||
<span class="live-dot" [class.paused]="!live"></span>
|
||||
<span class="live-label">{{ live ? 'Live' : 'Paused' }}</span>
|
||||
<span *ngIf="lastUpdated" class="live-updated">Updated {{ lastUpdated | date: 'HH:mm:ss' }}</span>
|
||||
<mat-spinner *ngIf="loading" diameter="16" class="live-spinner"></mat-spinner>
|
||||
</div>
|
||||
|
||||
<div class="flagged-actions">
|
||||
<button
|
||||
*ngIf="newAlertCount > 0"
|
||||
type="button"
|
||||
class="new-alert-pill"
|
||||
matTooltip="Clear the new-alert highlight"
|
||||
(click)="acknowledgeNew()"
|
||||
>
|
||||
<mat-icon>notifications_active</mat-icon>
|
||||
{{ newAlertCount }} new
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
[matTooltip]="live ? 'Pause live updates' : 'Resume live updates'"
|
||||
(click)="toggleLive()"
|
||||
>
|
||||
<mat-icon>{{ live ? 'pause_circle' : 'play_circle' }}</mat-icon>
|
||||
</button>
|
||||
<button mat-icon-button type="button" matTooltip="Refresh now" [disabled]="loading" (click)="fetch()">
|
||||
<mat-icon>refresh</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div *ngIf="loadFailed" class="flagged-error">
|
||||
<mat-icon>cloud_off</mat-icon>
|
||||
<span>Could not reach the alert feed. Retrying automatically.</span>
|
||||
</div>
|
||||
|
||||
<!-- Narrow the feed by where each alert sits in the review workflow -->
|
||||
<div class="status-filters">
|
||||
<span class="status-filters-label">Status</span>
|
||||
<button
|
||||
*ngFor="let s of statusFilters"
|
||||
type="button"
|
||||
class="status-chip"
|
||||
[class.active]="activeStatus === s.key"
|
||||
[disabled]="statusCount(s.key) === 0 && activeStatus !== s.key"
|
||||
(click)="setStatus(s.key)"
|
||||
>
|
||||
{{ s.label }}
|
||||
<span class="status-chip-n">{{ statusCount(s.key) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Severity mix of what the table is showing -->
|
||||
<div class="risk-summary">
|
||||
<div *ngFor="let tier of riskSummary" class="risk-tile" [ngClass]="tier.cls">
|
||||
<span class="risk-tile-n">{{ tier.count }}</span>
|
||||
<span class="risk-tile-l">{{ tier.label }}</span>
|
||||
</div>
|
||||
<div *ngIf="unclassifiedCount > 0" class="risk-tile risk-none" matTooltip="Raised by a user, not by a catalog rule">
|
||||
<span class="risk-tile-n">{{ unclassifiedCount }}</span>
|
||||
<span class="risk-tile-l">Unclassified</span>
|
||||
</div>
|
||||
<div class="risk-tile risk-total">
|
||||
<span class="risk-tile-n">{{ summaryTotal }}</span>
|
||||
<span class="risk-tile-l">Total Flagged</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table mat-table [dataSource]="alerts" class="flagged-table mat-elevation-z1">
|
||||
<!-- When the engine raised the alert -->
|
||||
<ng-container matColumnDef="raisedOn">
|
||||
<th mat-header-cell *matHeaderCellDef>Raised On</th>
|
||||
<td mat-cell *matCellDef="let alert" class="raised-cell">
|
||||
<span class="raised-time">{{
|
||||
alert.raisedOnDate ? (alert.raisedOnDate | date: 'dd MMM yyyy, HH:mm') : '—'
|
||||
}}</span>
|
||||
<span *ngIf="isNew(alert)" class="new-badge">NEW</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Which transaction tripped the rule -->
|
||||
<ng-container matColumnDef="transaction">
|
||||
<th mat-header-cell *matHeaderCellDef>Transaction</th>
|
||||
<td mat-cell *matCellDef="let alert">
|
||||
<span [ngClass]="txnTypeClass(alert.transactionType)">{{ alert.transactionType }}</span>
|
||||
<span class="txn-id">#{{ alert.transactionId }}</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="client">
|
||||
<th mat-header-cell *matHeaderCellDef>Client</th>
|
||||
<td mat-cell *matCellDef="let alert">
|
||||
<div class="stacked-cell">
|
||||
<span>{{ alert.clientDisplayName || '—' }}</span>
|
||||
<span *ngIf="alert.clientId" class="cell-sub">ID {{ alert.clientId }}</span>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="rule">
|
||||
<th mat-header-cell *matHeaderCellDef>Rule Triggered</th>
|
||||
<td mat-cell *matCellDef="let alert" class="rule-cell">
|
||||
<div class="stacked-cell">
|
||||
<span class="rule-code">{{ alert.ruleCode }}</span>
|
||||
<span class="cell-sub">{{ alert.ruleDescription }}</span>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="amount">
|
||||
<th mat-header-cell *matHeaderCellDef class="col-right">Amount</th>
|
||||
<td mat-cell *matCellDef="let alert" class="col-right amount-cell">{{ amount(alert) }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="status">
|
||||
<th mat-header-cell *matHeaderCellDef class="col-center">Status</th>
|
||||
<td mat-cell *matCellDef="let alert" class="col-center">
|
||||
<span [ngClass]="statusClass(alert.status)">{{ alert.status }}</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Disposition. Hidden once an alert is already whitelisted or escalated. -->
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef class="col-center">Action</th>
|
||||
<td mat-cell *matCellDef="let alert" class="col-center actions-cell">
|
||||
<ng-container *ngIf="canReview(alert); else reviewed">
|
||||
<!-- Same gate the FIU-IND register uses; the endpoint enforces REVIEW_AML_ALERT server side,
|
||||
so without this a user who cannot review still sees buttons that only fail on click. -->
|
||||
<div class="action-group" *mifosxHasPermission="'REVIEW_AML_ALERT'">
|
||||
<button
|
||||
type="button"
|
||||
class="action-btn action-whitelist"
|
||||
matTooltip="Clear this transaction with no report"
|
||||
(click)="review(alert, 'WHITELIST')"
|
||||
>
|
||||
<mat-icon>verified</mat-icon>
|
||||
Whitelist
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="action-btn action-suspicious"
|
||||
matTooltip="Raise as suspicious — opens an STR case"
|
||||
(click)="review(alert, 'ESCALATE')"
|
||||
>
|
||||
<mat-icon>report</mat-icon>
|
||||
Suspicious
|
||||
</button>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-template #reviewed>
|
||||
<div class="reviewed-cell">
|
||||
<span class="reviewed-by">
|
||||
{{ alert.reviewedByUsername ? 'by ' + alert.reviewedByUsername : 'Reviewed' }}
|
||||
</span>
|
||||
<!-- The evidence recorded at review time, so the trail is readable without leaving the feed -->
|
||||
<mat-icon
|
||||
*ngIf="alert.reviewNotes"
|
||||
class="reviewed-evidence-icon"
|
||||
[matTooltip]="alert.reviewNotes"
|
||||
matTooltipClass="evidence-tooltip"
|
||||
>
|
||||
description
|
||||
</mat-icon>
|
||||
<span *ngIf="alert.status === 'ESCALATED'" class="str-routed" matTooltip="An STR case is open for this alert">
|
||||
→ STR
|
||||
</span>
|
||||
</div>
|
||||
</ng-template>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns" class="alert-row" [class.row-new]="isNew(row)"></tr>
|
||||
</table>
|
||||
|
||||
<mat-paginator [pageSizeOptions]="[10, 25, 50, 100]" showFirstLastButtons class="flagged-paginator"></mat-paginator>
|
||||
|
||||
<div *ngIf="alerts.data.length === 0 && !loading" class="no-alerts-message">
|
||||
<mat-icon>{{ activeStatus ? 'filter_alt_off' : 'verified_user' }}</mat-icon>
|
||||
<span *ngIf="!activeStatus"
|
||||
>No transactions flagged. The feed updates automatically as the engine raises alerts.</span
|
||||
>
|
||||
<span *ngIf="activeStatus">No alerts in this status.</span>
|
||||
</div>
|
||||
+539
@@ -0,0 +1,539 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
.flagged-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
padding: 4px 0 14px;
|
||||
}
|
||||
|
||||
.flagged-live {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
/* Pulses while the feed is polling; goes flat and grey when paused. */
|
||||
.live-dot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: #16a34a;
|
||||
box-shadow: 0 0 0 0 rgb(22 163 74 / 60%);
|
||||
animation: live-pulse 2s infinite;
|
||||
|
||||
&.paused {
|
||||
background: #94a3b8;
|
||||
animation: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes live-pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgb(22 163 74 / 60%);
|
||||
}
|
||||
|
||||
70% {
|
||||
box-shadow: 0 0 0 8px rgb(22 163 74 / 0%);
|
||||
}
|
||||
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgb(22 163 74 / 0%);
|
||||
}
|
||||
}
|
||||
|
||||
.live-label {
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.05em;
|
||||
color: #16a34a;
|
||||
|
||||
.live-dot.paused + & {
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
.live-updated {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.live-spinner {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.flagged-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.new-alert-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 12px;
|
||||
border: 1px solid #fdba74;
|
||||
border-radius: 999px;
|
||||
background: #fff7ed;
|
||||
color: #c2410c;
|
||||
font-family: inherit;
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #ffedd5;
|
||||
}
|
||||
}
|
||||
|
||||
.flagged-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 12px;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 6px;
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
font-size: 13px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Chips that narrow the feed by review status. */
|
||||
.status-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
padding: 0 0 14px;
|
||||
}
|
||||
|
||||
.status-filters-label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.status-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 6px 13px;
|
||||
border: 1px solid #cbd5e1;
|
||||
background: #fff;
|
||||
border-radius: 999px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: #1a237e;
|
||||
color: #1a237e;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #1a237e;
|
||||
border-color: #1a237e;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.status-chip-n {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
|
||||
.status-chip.active & {
|
||||
background: rgb(255 255 255 / 22%);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
/* Severity mix of the visible rows. Colours match the Rules Master risk badges so the same
|
||||
category reads the same across both tabs. */
|
||||
.risk-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 12px;
|
||||
padding: 0 0 16px;
|
||||
}
|
||||
|
||||
.risk-tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-left-width: 4px;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.risk-tile-n {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.risk-tile-l {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.risk-tile.risk-high {
|
||||
border-left-color: #dc2626;
|
||||
background: #fef2f2;
|
||||
|
||||
.risk-tile-n {
|
||||
color: #b91c1c;
|
||||
}
|
||||
}
|
||||
|
||||
.risk-tile.risk-medium {
|
||||
border-left-color: #f59e0b;
|
||||
background: #fffbeb;
|
||||
|
||||
.risk-tile-n {
|
||||
color: #b45309;
|
||||
}
|
||||
}
|
||||
|
||||
.risk-tile.risk-low {
|
||||
border-left-color: #16a34a;
|
||||
background: #f0fdf4;
|
||||
|
||||
.risk-tile-n {
|
||||
color: #15803d;
|
||||
}
|
||||
}
|
||||
|
||||
.risk-tile.risk-none {
|
||||
border-left-color: #94a3b8;
|
||||
background: #f8fafc;
|
||||
|
||||
.risk-tile-n {
|
||||
color: #475569;
|
||||
}
|
||||
}
|
||||
|
||||
.risk-tile.risk-total {
|
||||
border-left-color: #1a237e;
|
||||
background: #f8fafc;
|
||||
|
||||
.risk-tile-n {
|
||||
color: #1a237e;
|
||||
}
|
||||
}
|
||||
|
||||
.flagged-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.03em;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 14px 16px;
|
||||
font-size: 13px;
|
||||
color: #1e293b;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
}
|
||||
|
||||
.alert-row:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
/* A newly arrived alert stays tinted until acknowledged. */
|
||||
.row-new {
|
||||
background: #fffbeb;
|
||||
|
||||
&:hover {
|
||||
background: #fef3c7;
|
||||
}
|
||||
}
|
||||
|
||||
.col-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.col-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.raised-cell {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.raised-time {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.new-badge {
|
||||
display: inline-block;
|
||||
margin-left: 8px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
background: #f59e0b;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.txn-type {
|
||||
display: inline-block;
|
||||
padding: 3px 9px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
background: #e2e8f0;
|
||||
color: #475569;
|
||||
|
||||
&.type-savings {
|
||||
background: #dbeafe;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
&.type-loan {
|
||||
background: #ede9fe;
|
||||
color: #6d28d9;
|
||||
}
|
||||
|
||||
&.type-transfer {
|
||||
background: #ccfbf1;
|
||||
color: #0f766e;
|
||||
}
|
||||
}
|
||||
|
||||
.txn-id {
|
||||
margin-left: 8px;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Two-line cells stack inside a wrapper div. Making the <td> itself a flex container drops its
|
||||
display:table-cell, which collapses the row and pushes later columns onto their own line. */
|
||||
.stacked-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.cell-sub {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.rule-code {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rule-cell {
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.amount-cell {
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.alert-status {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
|
||||
&.status-raised {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
&.status-under_review {
|
||||
background: #dbeafe;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
&.status-escalated {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
&.status-whitelisted {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
}
|
||||
|
||||
.actions-cell {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.action-group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 5px 11px;
|
||||
margin: 2px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid transparent;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
|
||||
mat-icon {
|
||||
font-size: 15px;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.action-whitelist {
|
||||
border-color: #bbf7d0;
|
||||
background: #f0fdf4;
|
||||
color: #15803d;
|
||||
|
||||
&:hover {
|
||||
background: #dcfce7;
|
||||
border-color: #86efac;
|
||||
}
|
||||
}
|
||||
|
||||
.action-suspicious {
|
||||
border-color: #fecaca;
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
|
||||
&:hover {
|
||||
background: #fee2e2;
|
||||
border-color: #fca5a5;
|
||||
}
|
||||
}
|
||||
|
||||
.reviewed-cell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.reviewed-by {
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.reviewed-evidence-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: #64748b;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
/* Mirrors the FIU-IND STR register badge, so the same state reads the same in both screens. */
|
||||
.str-routed {
|
||||
padding: 2px 7px;
|
||||
border-radius: 4px;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
color: #b91c1c;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
::ng-deep .evidence-tooltip {
|
||||
max-width: 320px;
|
||||
white-space: pre-wrap;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.no-alerts-message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px;
|
||||
color: #64748b;
|
||||
gap: 12px;
|
||||
text-align: center;
|
||||
|
||||
mat-icon {
|
||||
font-size: 48px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.flagged-paginator {
|
||||
background: #fff;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
+333
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* 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, OnDestroy, inject, ViewChild } from '@angular/core';
|
||||
import { MatTableModule, MatTableDataSource } from '@angular/material/table';
|
||||
import { MatPaginatorModule, MatPaginator } from '@angular/material/paginator';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Subject, timer, of } from 'rxjs';
|
||||
import { switchMap, takeUntil, catchError, filter } from 'rxjs/operators';
|
||||
|
||||
/** Custom Services */
|
||||
import { AmlService } from '../../../aml/aml.service';
|
||||
import { AlertService } from '../../../core/alert/alert.service';
|
||||
import { HasPermissionDirective } from '../../../directives/has-permission/has-permission.directive';
|
||||
import { AmlReviewAlertDialogComponent } from './aml-review-alert-dialog.component';
|
||||
|
||||
/**
|
||||
* Live feed of transactions the AML rule engine has flagged.
|
||||
*
|
||||
* The engine writes an m_aml_alert row synchronously as each savings/loan/transfer transaction
|
||||
* posts (AmlDomainServiceImpl -> AmlRuleEvaluationService), but there is no push channel out of
|
||||
* the platform — no websocket or SSE endpoint exists. So "real time" here is a short poll of
|
||||
* GET /aml/alerts. Alerts therefore surface within one poll interval of the transaction, not on
|
||||
* the same instant it posts.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-aml-flagged-transactions',
|
||||
templateUrl: './aml-flagged-transactions.component.html',
|
||||
styleUrls: ['./aml-flagged-transactions.component.scss'],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatTableModule,
|
||||
MatPaginatorModule,
|
||||
MatIconModule,
|
||||
MatButtonModule,
|
||||
MatTooltipModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatDialogModule,
|
||||
HasPermissionDirective
|
||||
]
|
||||
})
|
||||
export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy {
|
||||
private amlService = inject(AmlService);
|
||||
private dialog = inject(MatDialog);
|
||||
private alertService = inject(AlertService);
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
/** Short enough to feel live to an officer watching the desk, long enough not to hammer the API. */
|
||||
private static readonly POLL_INTERVAL_MS = 15000;
|
||||
|
||||
alerts = new MatTableDataSource<any>([]);
|
||||
displayedColumns: string[] = [
|
||||
'raisedOn',
|
||||
'transaction',
|
||||
'client',
|
||||
'rule',
|
||||
'amount',
|
||||
'status',
|
||||
'actions'
|
||||
];
|
||||
|
||||
/** Every alert as returned, before the status filter narrows the table. */
|
||||
private allAlerts: any[] = [];
|
||||
|
||||
readonly statusFilters: { key: string; label: string }[] = [
|
||||
{ key: '', label: 'All alerts' },
|
||||
{ key: 'RAISED', label: 'Raised' },
|
||||
{ key: 'UNDER_REVIEW', label: 'Under review' },
|
||||
{ key: 'ESCALATED', label: 'Escalated' },
|
||||
{ key: 'WHITELISTED', label: 'Whitelisted' }
|
||||
];
|
||||
activeStatus = '';
|
||||
private statusCounts: Record<string, number> = {};
|
||||
|
||||
/**
|
||||
* Severity mix of the flagged transactions currently in the table. riskCategory is the severity
|
||||
* of the rule that fired, resolved server-side from the originating catalog entry — the same
|
||||
* field the Rules Master tab shows in its Risk Category column.
|
||||
*/
|
||||
riskSummary: { label: string; count: number; cls: string }[] = [];
|
||||
/** Alerts whose rule has no catalog ancestor (MANUAL_REFERRAL), so no severity to report. */
|
||||
unclassifiedCount = 0;
|
||||
|
||||
live = true;
|
||||
loading = false;
|
||||
/** Null until the first successful poll, so the header does not claim a stale time. */
|
||||
lastUpdated: Date | null = null;
|
||||
loadFailed = false;
|
||||
|
||||
/** Ids already on screen, so a later poll can tell genuinely new alerts from repeats. */
|
||||
private seenIds = new Set<number>();
|
||||
/** Alerts that arrived after the officer started watching — highlighted until acknowledged. */
|
||||
newAlertIds = new Set<number>();
|
||||
|
||||
@ViewChild(MatPaginator) paginator!: MatPaginator;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.fetch();
|
||||
timer(AmlFlaggedTransactionsComponent.POLL_INTERVAL_MS, AmlFlaggedTransactionsComponent.POLL_INTERVAL_MS)
|
||||
.pipe(
|
||||
// Skip while paused, and while the browser tab is in the background — a hidden tab
|
||||
// polling every 15s is pure waste, and the next visible tick catches up anyway.
|
||||
filter(() => this.live && !document.hidden),
|
||||
// Always fetch unfiltered: the status chips need counts for every bucket, and filtering
|
||||
// client-side keeps a chip switch instant instead of a round trip.
|
||||
switchMap(() => this.amlService.getAlerts().pipe(catchError(() => of(null)))),
|
||||
takeUntil(this.destroy$)
|
||||
)
|
||||
.subscribe((data) => {
|
||||
if (data) {
|
||||
this.applyAlerts(data);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
/** Manual refresh, and the initial load. */
|
||||
fetch(): void {
|
||||
this.loading = true;
|
||||
this.amlService
|
||||
.getAlerts()
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (data) => {
|
||||
this.loading = false;
|
||||
this.applyAlerts(data || []);
|
||||
},
|
||||
error: () => {
|
||||
this.loading = false;
|
||||
this.loadFailed = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toggleLive(): void {
|
||||
this.live = !this.live;
|
||||
if (this.live) {
|
||||
// Resuming should show current state immediately rather than after a full interval.
|
||||
this.fetch();
|
||||
}
|
||||
}
|
||||
|
||||
setStatus(key: string): void {
|
||||
this.activeStatus = key;
|
||||
this.applyStatusFilter();
|
||||
}
|
||||
|
||||
statusCount(key: string): number {
|
||||
return this.statusCounts[key] ?? 0;
|
||||
}
|
||||
|
||||
get newAlertCount(): number {
|
||||
return this.newAlertIds.size;
|
||||
}
|
||||
|
||||
/** Clears the NEW highlight once the officer has looked at them. */
|
||||
acknowledgeNew(): void {
|
||||
this.newAlertIds.clear();
|
||||
}
|
||||
|
||||
isNew(alert: any): boolean {
|
||||
return this.newAlertIds.has(alert.id);
|
||||
}
|
||||
|
||||
private applyAlerts(data: any[]): void {
|
||||
this.loadFailed = false;
|
||||
// A live feed reads newest-first. The API returns ascending when a status filter is applied
|
||||
// and unordered otherwise, so ordering here is what makes the newest alert visible on page 1.
|
||||
// Sorting needs the parsed date: raisedOn itself is an array, and new Date([...]) is NaN,
|
||||
// which would silently make the comparator a no-op.
|
||||
const list = (data || [])
|
||||
.map((a) => ({ ...a, raisedOnDate: this.toDate(a.raisedOn) }))
|
||||
.sort((a, b) => (b.raisedOnDate?.getTime() ?? 0) - (a.raisedOnDate?.getTime() ?? 0));
|
||||
|
||||
const firstLoad = this.lastUpdated === null;
|
||||
if (!firstLoad) {
|
||||
// Accumulate rather than replace: two quiet polls in a row must not clear a highlight the
|
||||
// officer has not looked at yet.
|
||||
for (const alert of list) {
|
||||
if (!this.seenIds.has(alert.id)) {
|
||||
this.newAlertIds.add(alert.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.seenIds = new Set(list.map((a) => a.id));
|
||||
|
||||
this.allAlerts = list;
|
||||
this.recomputeStatusCounts();
|
||||
this.applyStatusFilter();
|
||||
this.lastUpdated = new Date();
|
||||
}
|
||||
|
||||
private applyStatusFilter(): void {
|
||||
this.alerts.data = this.activeStatus
|
||||
? this.allAlerts.filter((a) => a.status === this.activeStatus)
|
||||
: this.allAlerts;
|
||||
if (!this.alerts.paginator && this.paginator) {
|
||||
this.alerts.paginator = this.paginator;
|
||||
}
|
||||
this.recomputeRiskSummary();
|
||||
}
|
||||
|
||||
/**
|
||||
* Counted over the rows the status filter leaves visible, not the whole set — a summary that
|
||||
* disagreed with the table underneath it would be worse than no summary.
|
||||
*/
|
||||
private recomputeRiskSummary(): void {
|
||||
const pool = this.alerts.data;
|
||||
const tiers = [
|
||||
{ key: 3, label: 'High Risk', cls: 'risk-high' },
|
||||
{ key: 2, label: 'Medium Risk', cls: 'risk-medium' },
|
||||
{ key: 1, label: 'Low Risk', cls: 'risk-low' }
|
||||
];
|
||||
this.riskSummary = tiers.map((t) => ({
|
||||
label: t.label,
|
||||
cls: t.cls,
|
||||
count: pool.filter((a) => Number(a.riskCategory) === t.key).length
|
||||
}));
|
||||
// Anything outside 1-3, including null, so the tiles always reconcile to the total.
|
||||
this.unclassifiedCount = pool.filter(
|
||||
(a) => ![
|
||||
1,
|
||||
2,
|
||||
3
|
||||
].includes(Number(a.riskCategory))
|
||||
).length;
|
||||
}
|
||||
|
||||
get summaryTotal(): number {
|
||||
return this.alerts.data.length;
|
||||
}
|
||||
|
||||
private recomputeStatusCounts(): void {
|
||||
this.statusCounts = { '': this.allAlerts.length };
|
||||
for (const s of this.statusFilters) {
|
||||
if (s.key) {
|
||||
this.statusCounts[s.key] = this.allAlerts.filter((a) => a.status === s.key).length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
statusClass(status: string): string {
|
||||
return 'alert-status status-' + (status || 'raised').toLowerCase();
|
||||
}
|
||||
|
||||
txnTypeClass(type: string): string {
|
||||
return 'txn-type type-' + (type || 'unknown').toLowerCase();
|
||||
}
|
||||
|
||||
amount(alert: any): string {
|
||||
const value = Number(alert.amount) || 0;
|
||||
return '₹' + value.toLocaleString('en-IN');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fineract's Gson LocalDateTimeAdapter emits [year, month, day, hour, minute, second] rather
|
||||
* than an ISO string, so binding raisedOn straight into the date pipe renders nothing. Month is
|
||||
* 1-based in that array and 0-based in Date. The string branch is a fallback in case the
|
||||
* serializer is ever configured to emit ISO.
|
||||
*/
|
||||
private toDate(value: any): Date | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const [
|
||||
y,
|
||||
M,
|
||||
d,
|
||||
H = 0,
|
||||
m = 0,
|
||||
s = 0
|
||||
] = value;
|
||||
if (y == null || M == null || d == null) {
|
||||
return null;
|
||||
}
|
||||
return new Date(y, M - 1, d, H, m, s);
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
return isNaN(parsed.getTime()) ? null : parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Only an open alert can be disposed of. The backend's whitelist/escalate set state
|
||||
* unconditionally, so nothing there stops an already-escalated alert being escalated again and
|
||||
* opening a second STR case — this gate is what prevents it.
|
||||
*/
|
||||
canReview(alert: any): boolean {
|
||||
return alert?.status === 'RAISED' || alert?.status === 'UNDER_REVIEW';
|
||||
}
|
||||
|
||||
review(alert: any, disposition: 'WHITELIST' | 'ESCALATE'): void {
|
||||
const dialogRef = this.dialog.open(AmlReviewAlertDialogComponent, {
|
||||
width: '620px',
|
||||
maxWidth: '95vw',
|
||||
autoFocus: false,
|
||||
disableClose: true,
|
||||
data: { alert, disposition }
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe((result: 'WHITELIST' | 'ESCALATE' | undefined) => {
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
this.alertService.alert({
|
||||
type: result === 'WHITELIST' ? 'Alert Whitelisted' : 'Alert Escalated',
|
||||
message:
|
||||
result === 'WHITELIST'
|
||||
? `${alert.transactionType} #${alert.transactionId} cleared with no report.`
|
||||
: `${alert.transactionType} #${alert.transactionId} escalated — an STR case is now open.`
|
||||
});
|
||||
// The status changed server side, and escalation also re-rates the client, so re-read
|
||||
// rather than patching the row locally.
|
||||
this.fetch();
|
||||
});
|
||||
}
|
||||
}
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
<!--
|
||||
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 class="review-dialog-title" [class.escalate]="!isWhitelist">
|
||||
<mat-icon>{{ isWhitelist ? 'verified' : 'report' }}</mat-icon>
|
||||
{{ title }}
|
||||
</h2>
|
||||
|
||||
<mat-dialog-content class="review-dialog-content">
|
||||
<!-- Restate exactly which transaction is being disposed of -->
|
||||
<div class="review-summary">
|
||||
<div class="review-summary-row">
|
||||
<span class="review-label">Transaction</span>
|
||||
<span class="review-value">
|
||||
{{ data.alert.transactionType }} <span class="review-mono">#{{ data.alert.transactionId }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="review-summary-row">
|
||||
<span class="review-label">Client</span>
|
||||
<span class="review-value">
|
||||
{{ data.alert.clientDisplayName || '—' }}
|
||||
<span *ngIf="data.alert.clientId" class="review-muted">(ID {{ data.alert.clientId }})</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="review-summary-row">
|
||||
<span class="review-label">Amount</span>
|
||||
<span class="review-value review-mono">{{ amount() }}</span>
|
||||
</div>
|
||||
<div class="review-summary-row">
|
||||
<span class="review-label">Rule</span>
|
||||
<span class="review-value">
|
||||
{{ data.alert.ruleCode }}
|
||||
<span class="review-muted">— {{ data.alert.ruleDescription }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="review-summary-row">
|
||||
<span class="review-label">Raised On</span>
|
||||
<span class="review-value">{{ data.alert.raisedOnDate | date: 'dd MMM yyyy, HH:mm:ss' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Escalation is not a reversible bookkeeping change; say what it sets in motion -->
|
||||
<div *ngIf="!isWhitelist" class="review-warning">
|
||||
<mat-icon>warning</mat-icon>
|
||||
<span>
|
||||
This opens an STR case for the Principal Officer and re-rates the client's risk category. The case carries a
|
||||
7-working-day FIU filing deadline.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div *ngIf="isWhitelist" class="review-note">
|
||||
<mat-icon>info_outline</mat-icon>
|
||||
<span>The alert is closed with no report filed. The reason below is retained as the audit trail.</span>
|
||||
</div>
|
||||
|
||||
<div *ngIf="errorMessage" class="review-error">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
<span>{{ errorMessage }}</span>
|
||||
</div>
|
||||
|
||||
<form [formGroup]="reviewForm" autocomplete="off">
|
||||
<!-- Evidence classification only applies to clearing an alert -->
|
||||
<ng-container *ngIf="isWhitelist">
|
||||
<div class="evidence-block" [class.evidence-block-invalid]="evidenceMissing">
|
||||
<span class="evidence-legend">Evidence relied on — tick all that apply</span>
|
||||
<div formArrayName="evidence" class="evidence-options">
|
||||
<mat-checkbox *ngFor="let t of evidenceTypes; let i = index" [formControlName]="i" class="evidence-option">
|
||||
{{ t }}
|
||||
</mat-checkbox>
|
||||
</div>
|
||||
<span *ngIf="evidenceMissing" class="evidence-error">Tick at least one form of evidence</span>
|
||||
</div>
|
||||
|
||||
<mat-form-field appearance="outline" class="review-notes-field">
|
||||
<mat-label>Document / Reference No.</mat-label>
|
||||
<input matInput formControlName="evidenceRef" placeholder="e.g. Invoice INV-4471, Sale deed 12/2026" />
|
||||
<mat-hint>Optional — identifies the documents held on file</mat-hint>
|
||||
</mat-form-field>
|
||||
</ng-container>
|
||||
|
||||
<mat-form-field appearance="outline" class="review-notes-field">
|
||||
<mat-label>{{ notesLabel }}</mat-label>
|
||||
<textarea
|
||||
matInput
|
||||
rows="4"
|
||||
formControlName="notes"
|
||||
[placeholder]="
|
||||
isWhitelist
|
||||
? 'What the evidence shows and how it accounts for this transaction'
|
||||
: 'The pattern or behaviour that makes this transaction suspicious'
|
||||
"
|
||||
></textarea>
|
||||
<mat-error *ngIf="reviewForm.get('notes')?.hasError('required')">
|
||||
Required — this is the audit record for the decision
|
||||
</mat-error>
|
||||
<mat-error *ngIf="reviewForm.get('notes')?.hasError('maxlength')">
|
||||
Keep this under {{ detailMax }} characters
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- No guessing about what is retained: show the exact string that will be stored. -->
|
||||
<div *ngIf="composedNotes" class="review-stored">
|
||||
<span class="review-stored-label">Stored on the alert record</span>
|
||||
<code class="review-stored-value">{{ composedNotes }}</code>
|
||||
<span class="review-stored-hint">
|
||||
Saved to <code>m_aml_alert.review_notes</code> against alert #{{ data.alert.id }}, and shown in the NOTES column
|
||||
of the FIU‑IND STR register.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div *ngIf="composedTooLong" class="review-error">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
<span>Too long to store — untick some evidence, or shorten the detail or reference.</span>
|
||||
</div>
|
||||
</form>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end" class="review-dialog-actions">
|
||||
<button mat-stroked-button type="button" [disabled]="submitting" (click)="cancel()">Cancel</button>
|
||||
<button
|
||||
mat-flat-button
|
||||
type="button"
|
||||
[color]="isWhitelist ? 'primary' : 'warn'"
|
||||
[disabled]="reviewForm.invalid || composedTooLong || submitting"
|
||||
(click)="submit()"
|
||||
>
|
||||
<mat-spinner *ngIf="submitting" diameter="18" class="review-spinner"></mat-spinner>
|
||||
{{ submitting ? 'Saving…' : confirmLabel }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
.review-dialog-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: #15803d;
|
||||
font-weight: 600;
|
||||
|
||||
&.escalate {
|
||||
color: #b91c1c;
|
||||
}
|
||||
}
|
||||
|
||||
.review-dialog-content {
|
||||
padding-top: 8px;
|
||||
min-width: 520px;
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
.review-summary {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.review-summary-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 4px 0;
|
||||
font-size: 13px;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.review-label {
|
||||
flex: 0 0 96px;
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.review-value {
|
||||
flex: 1 1 auto;
|
||||
color: #1e293b;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.review-muted {
|
||||
color: #64748b;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.review-mono {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.review-warning,
|
||||
.review-note,
|
||||
.review-error {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
|
||||
mat-icon {
|
||||
flex: 0 0 auto;
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.review-warning {
|
||||
border: 1px solid #fecaca;
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.review-note {
|
||||
border: 1px solid #bbf7d0;
|
||||
background: #f0fdf4;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.review-error {
|
||||
border: 1px solid #fecaca;
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Multi-select evidence. Bordered so the group reads as one required field rather than a
|
||||
loose row of checkboxes. */
|
||||
.evidence-block {
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
&.evidence-block-invalid {
|
||||
border-color: #fca5a5;
|
||||
background: #fef2f2;
|
||||
}
|
||||
}
|
||||
|
||||
.evidence-legend {
|
||||
display: block;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #64748b;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.evidence-options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 2px 12px;
|
||||
}
|
||||
|
||||
.evidence-option {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.evidence-error {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.review-notes-field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Shows the officer the exact string the review will persist. */
|
||||
.review-stored {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 10px 12px;
|
||||
border: 1px dashed #cbd5e1;
|
||||
border-radius: 6px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.review-stored-label {
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.review-stored-value {
|
||||
font-size: 12.5px;
|
||||
color: #1e293b;
|
||||
word-break: break-word;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.review-stored-hint {
|
||||
font-size: 11.5px;
|
||||
color: #64748b;
|
||||
line-height: 1.45;
|
||||
|
||||
code {
|
||||
background: #e2e8f0;
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
.review-dialog-actions {
|
||||
padding: 8px 24px 20px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.review-spinner {
|
||||
display: inline-block;
|
||||
margin-right: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@media (width <= 600px) {
|
||||
.review-dialog-content {
|
||||
min-width: unset;
|
||||
}
|
||||
|
||||
.review-summary-row {
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.evidence-options {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* 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, inject } from '@angular/core';
|
||||
import {
|
||||
AbstractControl,
|
||||
FormArray,
|
||||
FormBuilder,
|
||||
ValidationErrors,
|
||||
Validators,
|
||||
ReactiveFormsModule
|
||||
} from '@angular/forms';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
/** Custom Services */
|
||||
import { AmlService } from '../../../aml/aml.service';
|
||||
|
||||
export interface AmlReviewDialogData {
|
||||
alert: any;
|
||||
disposition: 'WHITELIST' | 'ESCALATE';
|
||||
}
|
||||
|
||||
/**
|
||||
* Disposes of one engine alert: clear it (WHITELIST) or raise it as suspicious (ESCALATE).
|
||||
*
|
||||
* The alert being actioned is restated in full here rather than relying on the officer having
|
||||
* read the right table row — the two outcomes are not symmetric. WHITELIST closes the alert with
|
||||
* no report; ESCALATE opens an STR case and re-rates the client's risk, which is not something to
|
||||
* trigger from a stray click on a table. Notes are mandatory because they are the audit trail for
|
||||
* why a flagged transaction was cleared.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-aml-review-alert-dialog',
|
||||
templateUrl: './aml-review-alert-dialog.component.html',
|
||||
styleUrls: ['./aml-review-alert-dialog.component.scss'],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
MatDialogModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatCheckboxModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatProgressSpinnerModule
|
||||
]
|
||||
})
|
||||
export class AmlReviewAlertDialogComponent {
|
||||
private formBuilder = inject(FormBuilder);
|
||||
private amlService = inject(AmlService);
|
||||
private dialogRef = inject(MatDialogRef<AmlReviewAlertDialogComponent>);
|
||||
readonly data = inject<AmlReviewDialogData>(MAT_DIALOG_DATA);
|
||||
|
||||
submitting = false;
|
||||
errorMessage = '';
|
||||
|
||||
/**
|
||||
* What supports clearing the alert. The platform has no evidence table — m_aml_alert.review_notes
|
||||
* (VARCHAR(1000)) is the only field the review writes — so the classification is folded into that
|
||||
* one string rather than invented as a column the API cannot persist.
|
||||
*/
|
||||
readonly evidenceTypes = [
|
||||
'Customer explanation obtained',
|
||||
'Source of funds documented',
|
||||
'KYC / identity re-verified',
|
||||
'Consistent with declared income or turnover',
|
||||
'Established business or salary pattern',
|
||||
'Duplicate or system-generated alert',
|
||||
'Other'
|
||||
];
|
||||
|
||||
/** Leaves headroom for the composed prefix inside the 1000-char column. */
|
||||
private static readonly DETAIL_MAX = 800;
|
||||
private static readonly NOTES_COLUMN_MAX = 1000;
|
||||
|
||||
/** One checkbox per evidence type; a clearing decision can rest on several at once. */
|
||||
reviewForm = this.formBuilder.group({
|
||||
evidence: this.formBuilder.array(this.evidenceTypes.map(() => false)),
|
||||
evidenceRef: [
|
||||
'',
|
||||
Validators.maxLength(100)
|
||||
],
|
||||
notes: [
|
||||
'',
|
||||
[
|
||||
Validators.required,
|
||||
Validators.maxLength(AmlReviewAlertDialogComponent.DETAIL_MAX)
|
||||
]
|
||||
]
|
||||
});
|
||||
|
||||
constructor() {
|
||||
// Evidence classification is meaningless on an escalation — that path records grounds for
|
||||
// suspicion, not proof of innocence — so it is only required when clearing an alert.
|
||||
if (this.isWhitelist) {
|
||||
this.evidenceArray.addValidators(AmlReviewAlertDialogComponent.atLeastOneChecked);
|
||||
this.evidenceArray.updateValueAndValidity();
|
||||
}
|
||||
}
|
||||
|
||||
/** FormArray of booleans, so "none ticked" has to be checked across the whole array. */
|
||||
private static atLeastOneChecked(control: AbstractControl): ValidationErrors | null {
|
||||
const boxes = control as FormArray;
|
||||
return boxes.controls.some((c) => c.value === true) ? null : { required: true };
|
||||
}
|
||||
|
||||
get evidenceArray(): FormArray {
|
||||
return this.reviewForm.get('evidence') as FormArray;
|
||||
}
|
||||
|
||||
get selectedEvidence(): string[] {
|
||||
return this.evidenceTypes.filter((_, i) => this.evidenceArray.at(i)?.value === true);
|
||||
}
|
||||
|
||||
get evidenceMissing(): boolean {
|
||||
return this.isWhitelist && this.evidenceArray.invalid && this.evidenceArray.touched;
|
||||
}
|
||||
|
||||
get isWhitelist(): boolean {
|
||||
return this.data.disposition === 'WHITELIST';
|
||||
}
|
||||
|
||||
get detailMax(): number {
|
||||
return AmlReviewAlertDialogComponent.DETAIL_MAX;
|
||||
}
|
||||
|
||||
/** Exactly what will be written to the alert record, shown before the officer commits. */
|
||||
get composedNotes(): string {
|
||||
const v = this.reviewForm.value;
|
||||
const detail = (v.notes || '').trim();
|
||||
if (!this.isWhitelist) {
|
||||
return detail ? `Grounds: ${detail}` : '';
|
||||
}
|
||||
const parts: string[] = [];
|
||||
const selected = this.selectedEvidence;
|
||||
if (selected.length) {
|
||||
parts.push(`Evidence: ${selected.join('; ')}`);
|
||||
}
|
||||
if (v.evidenceRef) {
|
||||
parts.push(`Ref: ${(v.evidenceRef || '').trim()}`);
|
||||
}
|
||||
const head = parts.join(' | ');
|
||||
return head && detail ? `${head} — ${detail}` : head || detail;
|
||||
}
|
||||
|
||||
get composedTooLong(): boolean {
|
||||
return this.composedNotes.length > AmlReviewAlertDialogComponent.NOTES_COLUMN_MAX;
|
||||
}
|
||||
|
||||
get title(): string {
|
||||
return this.isWhitelist ? 'Whitelist Transaction' : 'Mark as Suspicious';
|
||||
}
|
||||
|
||||
get confirmLabel(): string {
|
||||
return this.isWhitelist ? 'Whitelist' : 'Escalate to STR';
|
||||
}
|
||||
|
||||
get notesLabel(): string {
|
||||
return this.isWhitelist ? 'Evidence detail' : 'What makes this transaction suspicious?';
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (this.reviewForm.invalid || this.composedTooLong) {
|
||||
this.reviewForm.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
this.submitting = true;
|
||||
this.errorMessage = '';
|
||||
this.amlService.reviewAlert(this.data.alert.id, this.data.disposition, this.composedNotes).subscribe({
|
||||
next: () => {
|
||||
this.submitting = false;
|
||||
this.dialogRef.close(this.data.disposition);
|
||||
},
|
||||
error: (err) => {
|
||||
this.submitting = false;
|
||||
this.errorMessage = this.readError(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
amount(): string {
|
||||
const value = Number(this.data.alert?.amount) || 0;
|
||||
return '₹' + value.toLocaleString('en-IN');
|
||||
}
|
||||
|
||||
private readError(err: any): string {
|
||||
const body = err?.error;
|
||||
const first = body?.errors?.[0];
|
||||
return (
|
||||
first?.defaultUserMessage || first?.developerMessage || body?.defaultUserMessage || 'Could not record the review.'
|
||||
);
|
||||
}
|
||||
}
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
<!--
|
||||
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="ra-intro">
|
||||
<mat-icon class="ra-intro-icon">policy</mat-icon>
|
||||
<span>
|
||||
Customers the engine has flagged more than once. One alert is a question about a transaction — a pattern is a
|
||||
question about the customer, which is what gets reviewed and recorded here.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- What counts as "regularly" is a policy call, so it is set on screen rather than baked in -->
|
||||
<div class="ra-controls">
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="ra-control">
|
||||
<mat-label>Flag after</mat-label>
|
||||
<mat-select [(ngModel)]="minAlerts" (ngModelChange)="load()">
|
||||
<mat-option *ngFor="let t of thresholdOptions" [value]="t">{{ t }}+ alerts</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="ra-control">
|
||||
<mat-label>Within</mat-label>
|
||||
<mat-select [(ngModel)]="windowDays" (ngModelChange)="load()">
|
||||
<mat-option *ngFor="let w of windowOptions" [value]="w.value">{{ w.label }}</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-checkbox [(ngModel)]="onlyNeedingReview" (ngModelChange)="load()" class="ra-check">
|
||||
Only awaiting review
|
||||
</mat-checkbox>
|
||||
|
||||
<span class="ra-spacer"></span>
|
||||
|
||||
<span *ngIf="needingReviewCount > 0" class="ra-pending-pill">
|
||||
<mat-icon>pending_actions</mat-icon>
|
||||
{{ needingReviewCount }} awaiting review
|
||||
</span>
|
||||
<mat-spinner *ngIf="loading" diameter="18"></mat-spinner>
|
||||
<button mat-icon-button type="button" matTooltip="Refresh" [disabled]="loading" (click)="load()">
|
||||
<mat-icon>refresh</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div *ngIf="loadFailed" class="ra-error">
|
||||
<mat-icon>cloud_off</mat-icon>
|
||||
<span>Could not load the review queue.</span>
|
||||
</div>
|
||||
|
||||
<!-- Named on screen, because sample rows must never be mistaken for engine output on an AML queue -->
|
||||
<div *ngIf="usingDemoData && rows.data.length > 0" class="ra-demo">
|
||||
<mat-icon>science</mat-icon>
|
||||
<span>
|
||||
Showing <strong>sample data</strong> — the AML engine returned no recurring alerts for this threshold and window.
|
||||
These rows are for demonstration only and are not reportable.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<table mat-table [dataSource]="rows" class="ra-table mat-elevation-z1" multiTemplateDataRows>
|
||||
<ng-container matColumnDef="customer">
|
||||
<th mat-header-cell *matHeaderCellDef>Customer</th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
<div class="ra-stacked">
|
||||
<span class="ra-name">{{ row.clientDisplayName }}</span>
|
||||
<span class="ra-sub">
|
||||
ID {{ row.clientId }}<span *ngIf="row.accountNo"> · {{ row.accountNo }}</span> · KYC risk
|
||||
<span [ngClass]="riskClass(row.clientRiskCategory)">{{ riskLabel(row.clientRiskCategory) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="alertCount">
|
||||
<th mat-header-cell *matHeaderCellDef class="col-center">Alerts</th>
|
||||
<td mat-cell *matCellDef="let row" class="col-center">
|
||||
<span class="ra-count">{{ row.alertCount }}</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="severity">
|
||||
<th mat-header-cell *matHeaderCellDef class="col-center">Severity</th>
|
||||
<td mat-cell *matCellDef="let row" class="col-center">
|
||||
<div class="ra-stacked ra-stacked-center">
|
||||
<span class="ra-high" [class.ra-high-zero]="row.highRiskCount === 0">{{ row.highRiskCount }} High</span>
|
||||
<span class="ra-sub">{{ row.openCount }} open · {{ row.escalatedCount }} escalated</span>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="rules">
|
||||
<th mat-header-cell *matHeaderCellDef>Rules Tripped</th>
|
||||
<td mat-cell *matCellDef="let row" class="ra-rules-cell">
|
||||
<div class="ra-stacked">
|
||||
<span class="ra-name">{{ row.distinctRules }} distinct</span>
|
||||
<span class="ra-sub" [matTooltip]="row.ruleCodes?.join(', ')">{{ row.ruleCodes?.join(', ') }}</span>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="totalAmount">
|
||||
<th mat-header-cell *matHeaderCellDef class="col-right">Total Value</th>
|
||||
<td mat-cell *matCellDef="let row" class="col-right ra-amount">{{ amount(row.totalAmount) }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="lastAlert">
|
||||
<th mat-header-cell *matHeaderCellDef>Activity</th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
<div class="ra-stacked">
|
||||
<span>{{ row.lastAlertOnDate ? (row.lastAlertOnDate | date: 'dd MMM yyyy') : '—' }}</span>
|
||||
<span class="ra-sub">
|
||||
first {{ row.firstAlertOnDate ? (row.firstAlertOnDate | date: 'dd MMM yyyy') : '—' }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="reviewState">
|
||||
<th mat-header-cell *matHeaderCellDef class="col-center">Review</th>
|
||||
<td mat-cell *matCellDef="let row" class="col-center">
|
||||
<span *ngIf="row.needsReview && !row.lastReviewedOn" class="ra-state ra-state-new">Never reviewed</span>
|
||||
<span
|
||||
*ngIf="row.needsReview && row.lastReviewedOn"
|
||||
class="ra-state ra-state-again"
|
||||
[matTooltip]="
|
||||
'Reviewed at ' + row.alertCountAtLastReview + ' alert(s); ' + row.alertCount + ' now — new activity since.'
|
||||
"
|
||||
>
|
||||
New activity
|
||||
</span>
|
||||
<div *ngIf="!row.needsReview" class="ra-stacked ra-stacked-center">
|
||||
<span class="ra-state ra-state-done">{{ row.lastReviewOutcome }}</span>
|
||||
<span class="ra-sub" [matTooltip]="row.lastReviewNotes">
|
||||
{{ row.lastReviewedByUsername }} · {{ row.lastReviewedOnDate | date: 'dd MMM' }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef class="col-center">Action</th>
|
||||
<td mat-cell *matCellDef="let row" class="col-center ra-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="ra-btn ra-btn-detail"
|
||||
[matTooltip]="isExpanded(row) ? 'Hide transactions' : 'Show the triggering transactions'"
|
||||
(click)="toggleExpanded(row)"
|
||||
>
|
||||
<mat-icon>{{ isExpanded(row) ? 'expand_less' : 'expand_more' }}</mat-icon>
|
||||
{{ row.alertCount }} txns
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="ra-btn ra-btn-review"
|
||||
*mifosxHasPermission="'REVIEW_AML_ALERT'"
|
||||
matTooltip="Record why this customer keeps surfacing"
|
||||
(click)="review(row)"
|
||||
>
|
||||
<mat-icon>fact_check</mat-icon>
|
||||
Review
|
||||
</button>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Inline detail: the transactions behind the count, so the reviewer never has to guess -->
|
||||
<ng-container matColumnDef="detail">
|
||||
<td mat-cell *matCellDef="let row" [attr.colspan]="displayedColumns.length" class="ra-detail-cell">
|
||||
<div class="ra-detail" *ngIf="isExpanded(row)">
|
||||
<table class="ra-detail-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Raised On</th>
|
||||
<th>Transaction</th>
|
||||
<th>Rule</th>
|
||||
<th class="col-right">Amount</th>
|
||||
<th>Status</th>
|
||||
<th>Review Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let a of row.alerts">
|
||||
<td>{{ a.raisedOnDate ? (a.raisedOnDate | date: 'dd MMM yyyy, HH:mm') : '—' }}</td>
|
||||
<td>{{ a.transactionType }} #{{ a.transactionId }}</td>
|
||||
<td>
|
||||
{{ a.ruleCode }}
|
||||
<span class="ra-sub">{{ a.ruleDescription }}</span>
|
||||
</td>
|
||||
<td class="col-right ra-amount">{{ amount(a.amount) }}</td>
|
||||
<td>{{ a.status }}</td>
|
||||
<td class="ra-sub">{{ a.reviewNotes || '—' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr
|
||||
mat-row
|
||||
*matRowDef="let row; columns: displayedColumns"
|
||||
class="ra-row"
|
||||
[class.ra-row-pending]="row.needsReview"
|
||||
></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: ['detail']" class="ra-detail-row"></tr>
|
||||
</table>
|
||||
|
||||
<div *ngIf="rows.data.length === 0 && !loading" class="ra-empty">
|
||||
<mat-icon>verified_user</mat-icon>
|
||||
<span>
|
||||
No customer has reached {{ minAlerts }} alerts in the selected window. Lower the threshold or widen the window to
|
||||
see borderline cases.
|
||||
</span>
|
||||
</div>
|
||||
+358
@@ -0,0 +1,358 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
.ra-intro {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 11px 14px;
|
||||
margin-bottom: 14px;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 8px;
|
||||
background: #eff6ff;
|
||||
color: #1e40af;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.ra-intro-icon {
|
||||
flex: 0 0 auto;
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.ra-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
padding-bottom: 14px;
|
||||
}
|
||||
|
||||
.ra-control {
|
||||
width: 170px;
|
||||
}
|
||||
|
||||
.ra-check {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ra-spacer {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.ra-pending-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 12px;
|
||||
border: 1px solid #fdba74;
|
||||
border-radius: 999px;
|
||||
background: #fff7ed;
|
||||
color: #c2410c;
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.ra-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 12px;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 6px;
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ra-demo {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 12px;
|
||||
border: 1px solid #fde68a;
|
||||
border-radius: 6px;
|
||||
background: #fffbeb;
|
||||
color: #92400e;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
|
||||
mat-icon {
|
||||
flex: 0 0 auto;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.ra-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: 12.5px;
|
||||
padding: 14px 16px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 12px 16px;
|
||||
font-size: 13px;
|
||||
color: #1e293b;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
}
|
||||
|
||||
.ra-row:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
/* Anything still owed a decision is tinted, so the queue reads at a glance. */
|
||||
.ra-row-pending {
|
||||
background: #fffbeb;
|
||||
|
||||
&:hover {
|
||||
background: #fef3c7;
|
||||
}
|
||||
}
|
||||
|
||||
.col-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.col-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.ra-stacked {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.ra-stacked-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ra-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ra-sub {
|
||||
color: #64748b;
|
||||
font-size: 11.5px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.ra-rules-cell {
|
||||
max-width: 260px;
|
||||
|
||||
.ra-sub {
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.ra-count {
|
||||
display: inline-block;
|
||||
min-width: 30px;
|
||||
padding: 3px 9px;
|
||||
border-radius: 999px;
|
||||
background: #1a237e;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.ra-high {
|
||||
font-weight: 700;
|
||||
color: #b91c1c;
|
||||
font-size: 12.5px;
|
||||
|
||||
&.ra-high-zero {
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.ra-amount {
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ra-risk {
|
||||
font-weight: 700;
|
||||
|
||||
&.ra-risk-low {
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
&.ra-risk-medium {
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
&.ra-risk-high {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
&.ra-risk-none {
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
.ra-state {
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
white-space: nowrap;
|
||||
|
||||
&.ra-state-new {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
&.ra-state-again {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
&.ra-state-done {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
}
|
||||
|
||||
.ra-actions {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ra-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 5px 11px;
|
||||
margin: 2px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid transparent;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
|
||||
mat-icon {
|
||||
font-size: 15px;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.ra-btn-detail {
|
||||
border-color: #cbd5e1;
|
||||
background: #fff;
|
||||
color: #334155;
|
||||
|
||||
&:hover {
|
||||
border-color: #1a237e;
|
||||
color: #1a237e;
|
||||
}
|
||||
}
|
||||
|
||||
.ra-btn-review {
|
||||
border-color: #c7d2fe;
|
||||
background: #eef2ff;
|
||||
color: #3730a3;
|
||||
|
||||
&:hover {
|
||||
background: #e0e7ff;
|
||||
border-color: #a5b4fc;
|
||||
}
|
||||
}
|
||||
|
||||
/* Detail row is always rendered by multiTemplateDataRows; the padding collapses when empty so a
|
||||
collapsed row leaves no gap. */
|
||||
.ra-detail-cell {
|
||||
padding: 0 !important;
|
||||
border-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.ra-detail {
|
||||
padding: 12px 16px 16px;
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.ra-detail-tbl {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12.5px;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
|
||||
th {
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
font-weight: 700;
|
||||
font-size: 10.5px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 8px 10px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 8px 10px;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
color: #1e293b;
|
||||
vertical-align: top;
|
||||
}
|
||||
}
|
||||
|
||||
.ra-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 48px 24px;
|
||||
color: #64748b;
|
||||
text-align: center;
|
||||
max-width: 520px;
|
||||
margin: 0 auto;
|
||||
|
||||
mat-icon {
|
||||
font-size: 48px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
+258
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* 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 } from '@angular/core';
|
||||
import { MatTableModule, MatTableDataSource } from '@angular/material/table';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
/** Custom Services */
|
||||
import { AmlService } from '../../../aml/aml.service';
|
||||
import { AlertService } from '../../../core/alert/alert.service';
|
||||
import { HasPermissionDirective } from '../../../directives/has-permission/has-permission.directive';
|
||||
import { AmlRecurringReviewDialogComponent } from './aml-recurring-review-dialog.component';
|
||||
import { amlRecurringAlertsDemoData } from './aml-recurring-alerts.demo-data';
|
||||
|
||||
/**
|
||||
* Customers whose transactions keep tripping the AML engine, queued for an employee to review why.
|
||||
*
|
||||
* A single alert is a transaction question and is handled on the Flagged Transactions tab. This tab
|
||||
* asks a different question — is this *customer's* pattern of behaviour acceptable — which no single
|
||||
* alert can answer. Reviews are recorded against the alert count seen, so a customer leaves the
|
||||
* queue once reviewed and only returns when the engine flags them again.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-aml-recurring-alerts',
|
||||
templateUrl: './aml-recurring-alerts.component.html',
|
||||
styleUrls: ['./aml-recurring-alerts.component.scss'],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatTableModule,
|
||||
MatIconModule,
|
||||
MatButtonModule,
|
||||
MatTooltipModule,
|
||||
MatFormFieldModule,
|
||||
MatSelectModule,
|
||||
MatCheckboxModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatDialogModule,
|
||||
HasPermissionDirective
|
||||
]
|
||||
})
|
||||
export class AmlRecurringAlertsComponent implements OnInit {
|
||||
private amlService = inject(AmlService);
|
||||
private dialog = inject(MatDialog);
|
||||
private alertService = inject(AlertService);
|
||||
|
||||
rows = new MatTableDataSource<any>([]);
|
||||
displayedColumns: string[] = [
|
||||
'customer',
|
||||
'alertCount',
|
||||
'severity',
|
||||
'rules',
|
||||
'totalAmount',
|
||||
'lastAlert',
|
||||
'reviewState',
|
||||
'actions'
|
||||
];
|
||||
|
||||
/**
|
||||
* Matches ClientRiskEscalationServiceImpl.ESCALATION_THRESHOLD, which already treats 3 cumulative
|
||||
* AML events as the point a customer's risk rating is reconsidered.
|
||||
*/
|
||||
minAlerts = 3;
|
||||
windowDays = 90;
|
||||
onlyNeedingReview = false;
|
||||
|
||||
readonly thresholdOptions = [
|
||||
2,
|
||||
3,
|
||||
5,
|
||||
10
|
||||
];
|
||||
readonly windowOptions = [
|
||||
{ value: 30, label: 'Last 30 days' },
|
||||
{ value: 90, label: 'Last 90 days' },
|
||||
{ value: 180, label: 'Last 6 months' },
|
||||
{ value: 365, label: 'Last 12 months' }
|
||||
];
|
||||
|
||||
loading = false;
|
||||
loadFailed = false;
|
||||
/** Set when the rows on screen are the sample set rather than engine output. */
|
||||
usingDemoData = false;
|
||||
/** Which customer's alert detail is expanded inline. */
|
||||
expandedClientId: number | null = null;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.loadFailed = false;
|
||||
this.expandedClientId = null;
|
||||
this.amlService.getRecurringAlerts(this.minAlerts, this.windowDays, this.onlyNeedingReview).subscribe({
|
||||
next: (data) => {
|
||||
this.loading = false;
|
||||
const rows = data || [];
|
||||
this.usingDemoData = rows.length === 0;
|
||||
this.rows.data = this.mapRows(this.usingDemoData ? this.demoRows() : rows);
|
||||
},
|
||||
error: () => {
|
||||
this.loading = false;
|
||||
this.loadFailed = true;
|
||||
this.usingDemoData = true;
|
||||
this.rows.data = this.mapRows(this.demoRows());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Parses the date fields the template renders. Shared by engine rows and the sample set. */
|
||||
private mapRows(rows: any[]): any[] {
|
||||
return rows.map((r: any) => ({
|
||||
...r,
|
||||
firstAlertOnDate: this.toDate(r.firstAlertOn),
|
||||
lastAlertOnDate: this.toDate(r.lastAlertOn),
|
||||
lastReviewedOnDate: this.toDate(r.lastReviewedOn),
|
||||
alerts: (r.alerts || []).map((a: any) => ({ ...a, raisedOnDate: this.toDate(a.raisedOn) }))
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* The sample set narrowed by the on-screen controls, so the threshold and window selectors behave
|
||||
* the way they would against the engine instead of returning the same three rows regardless.
|
||||
*/
|
||||
private demoRows(): any[] {
|
||||
const cutoff = Date.now() - this.windowDays * 24 * 60 * 60 * 1000;
|
||||
return amlRecurringAlertsDemoData()
|
||||
.map((row: any) => {
|
||||
const alerts = (row.alerts || []).filter((a: any) => new Date(a.raisedOn).getTime() >= cutoff);
|
||||
const ruleCodes = Array.from(new Set(alerts.map((a: any) => a.ruleCode)));
|
||||
return {
|
||||
...row,
|
||||
alerts,
|
||||
alertCount: alerts.length,
|
||||
openCount: alerts.filter((a: any) => a.status === 'OPEN').length,
|
||||
escalatedCount: alerts.filter((a: any) => a.status === 'ESCALATED').length,
|
||||
// Severity sits on the alert server-side; the sample rows carry a row total, so it is
|
||||
// capped at what survived the window rather than recomputed.
|
||||
highRiskCount: Math.min(row.highRiskCount, alerts.length),
|
||||
distinctRules: ruleCodes.length,
|
||||
ruleCodes,
|
||||
totalAmount: alerts.reduce((sum: number, a: any) => sum + (Number(a.amount) || 0), 0),
|
||||
firstAlertOn: alerts.length ? alerts[0].raisedOn : null,
|
||||
lastAlertOn: alerts.length ? alerts[alerts.length - 1].raisedOn : null
|
||||
};
|
||||
})
|
||||
.filter((row: any) => row.alertCount >= this.minAlerts)
|
||||
.filter((row: any) => !this.onlyNeedingReview || row.needsReview);
|
||||
}
|
||||
|
||||
toggleExpanded(row: any): void {
|
||||
this.expandedClientId = this.expandedClientId === row.clientId ? null : row.clientId;
|
||||
}
|
||||
|
||||
isExpanded(row: any): boolean {
|
||||
return this.expandedClientId === row.clientId;
|
||||
}
|
||||
|
||||
get needingReviewCount(): number {
|
||||
return this.rows.data.filter((r) => r.needsReview).length;
|
||||
}
|
||||
|
||||
review(row: any): void {
|
||||
const dialogRef = this.dialog.open(AmlRecurringReviewDialogComponent, {
|
||||
width: '780px',
|
||||
maxWidth: '95vw',
|
||||
autoFocus: false,
|
||||
disableClose: true,
|
||||
data: { row, windowDays: this.windowDays }
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe((outcome: string | undefined) => {
|
||||
if (!outcome) {
|
||||
return;
|
||||
}
|
||||
this.alertService.alert({
|
||||
type: 'Review Recorded',
|
||||
message: `${row.clientDisplayName} reviewed as ${outcome}.`
|
||||
});
|
||||
// Re-read: the review changes needsReview, and the count recorded server-side is what the
|
||||
// row must now reflect.
|
||||
this.load();
|
||||
});
|
||||
}
|
||||
|
||||
amount(value: any): string {
|
||||
return '₹' + (Number(value) || 0).toLocaleString('en-IN');
|
||||
}
|
||||
|
||||
riskLabel(category: any): string {
|
||||
switch (Number(category)) {
|
||||
case 1:
|
||||
return 'Low';
|
||||
case 2:
|
||||
return 'Medium';
|
||||
case 3:
|
||||
return 'High';
|
||||
default:
|
||||
return 'Unrated';
|
||||
}
|
||||
}
|
||||
|
||||
riskClass(category: any): string {
|
||||
switch (Number(category)) {
|
||||
case 1:
|
||||
return 'ra-risk ra-risk-low';
|
||||
case 2:
|
||||
return 'ra-risk ra-risk-medium';
|
||||
case 3:
|
||||
return 'ra-risk ra-risk-high';
|
||||
default:
|
||||
return 'ra-risk ra-risk-none';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fineract's Gson adapter emits LocalDateTime as [y, M, d, H, m, s] rather than an ISO string, so
|
||||
* the date pipe renders nothing unless it is converted first. Month is 1-based there, 0-based in Date.
|
||||
*/
|
||||
private toDate(value: any): Date | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const [
|
||||
y,
|
||||
M,
|
||||
d,
|
||||
H = 0,
|
||||
m = 0,
|
||||
s = 0
|
||||
] = value;
|
||||
if (y == null || M == null || d == null) {
|
||||
return null;
|
||||
}
|
||||
return new Date(y, M - 1, d, H, m, s);
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
return isNaN(parsed.getTime()) ? null : parsed;
|
||||
}
|
||||
}
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Sample recurring-alert rows for the AML review queue, used only when the engine returns nothing
|
||||
* for the selected window so the screen can be demonstrated and reviewed.
|
||||
*
|
||||
* Shaped exactly like the `/aml/recurring-alerts` response — dates as ISO strings, alerts nested —
|
||||
* so the rows travel through the component's normal mapping rather than a second code path.
|
||||
*
|
||||
* Dates are generated relative to today rather than hard-coded, otherwise every row would fall
|
||||
* outside the "last 90 days" window as soon as the file aged.
|
||||
*/
|
||||
|
||||
/** ISO timestamp `days` before now, at the given hour and minute. */
|
||||
function daysAgo(days: number, hour = 11, minute = 20): string {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - days);
|
||||
d.setHours(hour, minute, 0, 0);
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Three customers covering the three review states the table renders: never reviewed, reviewed but
|
||||
* flagged again since, and reviewed with an outcome recorded.
|
||||
*/
|
||||
export function amlRecurringAlertsDemoData(): any[] {
|
||||
return [
|
||||
// Never reviewed — the row the queue exists to surface.
|
||||
{
|
||||
clientId: 10078,
|
||||
clientDisplayName: 'Ravi Kumar Sharma',
|
||||
accountNo: '000000010078',
|
||||
clientRiskCategory: 3,
|
||||
alertCount: 5,
|
||||
highRiskCount: 3,
|
||||
openCount: 4,
|
||||
escalatedCount: 1,
|
||||
distinctRules: 3,
|
||||
ruleCodes: [
|
||||
'CASH_TXN_10L',
|
||||
'STRUCTURING',
|
||||
'RAPID_MOVEMENT'
|
||||
],
|
||||
totalAmount: 4785000,
|
||||
firstAlertOn: daysAgo(62, 10, 5),
|
||||
lastAlertOn: daysAgo(3, 15, 42),
|
||||
needsReview: true,
|
||||
lastReviewedOn: null,
|
||||
alertCountAtLastReview: null,
|
||||
lastReviewOutcome: null,
|
||||
lastReviewedByUsername: null,
|
||||
lastReviewNotes: null,
|
||||
alerts: [
|
||||
{
|
||||
alertId: 90121,
|
||||
raisedOn: daysAgo(62, 10, 5),
|
||||
transactionType: 'Savings Deposit',
|
||||
transactionId: 552301,
|
||||
ruleCode: 'CASH_TXN_10L',
|
||||
ruleDescription: 'Cash deposit at or above ₹10,00,000 in a single transaction',
|
||||
amount: 1200000,
|
||||
status: 'OPEN',
|
||||
reviewNotes: null
|
||||
},
|
||||
{
|
||||
alertId: 90144,
|
||||
raisedOn: daysAgo(48, 12, 18),
|
||||
transactionType: 'Savings Deposit',
|
||||
transactionId: 553980,
|
||||
ruleCode: 'STRUCTURING',
|
||||
ruleDescription: 'Multiple cash deposits just below the reporting threshold within 48 hours',
|
||||
amount: 890000,
|
||||
status: 'OPEN',
|
||||
reviewNotes: null
|
||||
},
|
||||
{
|
||||
alertId: 90190,
|
||||
raisedOn: daysAgo(29, 9, 55),
|
||||
transactionType: 'Savings Withdrawal',
|
||||
transactionId: 556114,
|
||||
ruleCode: 'RAPID_MOVEMENT',
|
||||
ruleDescription: 'Funds withdrawn within 24 hours of a large credit',
|
||||
amount: 1150000,
|
||||
status: 'ESCALATED',
|
||||
reviewNotes: 'Escalated to Principal Officer — source of funds not established.'
|
||||
},
|
||||
{
|
||||
alertId: 90233,
|
||||
raisedOn: daysAgo(14, 16, 30),
|
||||
transactionType: 'Savings Deposit',
|
||||
transactionId: 558702,
|
||||
ruleCode: 'CASH_TXN_10L',
|
||||
ruleDescription: 'Cash deposit at or above ₹10,00,000 in a single transaction',
|
||||
amount: 1045000,
|
||||
status: 'OPEN',
|
||||
reviewNotes: null
|
||||
},
|
||||
{
|
||||
alertId: 90275,
|
||||
raisedOn: daysAgo(3, 15, 42),
|
||||
transactionType: 'Savings Withdrawal',
|
||||
transactionId: 560488,
|
||||
ruleCode: 'STRUCTURING',
|
||||
ruleDescription: 'Multiple cash deposits just below the reporting threshold within 48 hours',
|
||||
amount: 500000,
|
||||
status: 'OPEN',
|
||||
reviewNotes: null
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
// Reviewed at 2 alerts, flagged twice since — returns to the queue as "New activity".
|
||||
{
|
||||
clientId: 10079,
|
||||
clientDisplayName: 'Priya Patel',
|
||||
accountNo: '000000010079',
|
||||
clientRiskCategory: 2,
|
||||
alertCount: 4,
|
||||
highRiskCount: 1,
|
||||
openCount: 2,
|
||||
escalatedCount: 0,
|
||||
distinctRules: 2,
|
||||
ruleCodes: [
|
||||
'HIGH_VALUE_TRANSFER',
|
||||
'ROUND_TRIPPING'
|
||||
],
|
||||
totalAmount: 2130000,
|
||||
firstAlertOn: daysAgo(71, 14, 10),
|
||||
lastAlertOn: daysAgo(6, 11, 5),
|
||||
needsReview: true,
|
||||
lastReviewedOn: daysAgo(34, 17, 0),
|
||||
alertCountAtLastReview: 2,
|
||||
lastReviewOutcome: 'MONITOR',
|
||||
lastReviewedByUsername: 'anjali.mehta',
|
||||
lastReviewNotes: 'Textile trading receipts, invoices seen. Keep under watch for one more quarter.',
|
||||
alerts: [
|
||||
{
|
||||
alertId: 90102,
|
||||
raisedOn: daysAgo(71, 14, 10),
|
||||
transactionType: 'Account Transfer',
|
||||
transactionId: 551002,
|
||||
ruleCode: 'HIGH_VALUE_TRANSFER',
|
||||
ruleDescription: 'Transfer above ₹5,00,000 to an unrelated account',
|
||||
amount: 620000,
|
||||
status: 'WHITELISTED',
|
||||
reviewNotes: 'Supplier payment, invoice verified.'
|
||||
},
|
||||
{
|
||||
alertId: 90158,
|
||||
raisedOn: daysAgo(41, 10, 45),
|
||||
transactionType: 'Account Transfer',
|
||||
transactionId: 554330,
|
||||
ruleCode: 'ROUND_TRIPPING',
|
||||
ruleDescription: 'Funds returned to the originating account within seven days',
|
||||
amount: 450000,
|
||||
status: 'WHITELISTED',
|
||||
reviewNotes: 'Advance refunded after order cancellation.'
|
||||
},
|
||||
{
|
||||
alertId: 90241,
|
||||
raisedOn: daysAgo(19, 13, 25),
|
||||
transactionType: 'Account Transfer',
|
||||
transactionId: 559011,
|
||||
ruleCode: 'HIGH_VALUE_TRANSFER',
|
||||
ruleDescription: 'Transfer above ₹5,00,000 to an unrelated account',
|
||||
amount: 540000,
|
||||
status: 'OPEN',
|
||||
reviewNotes: null
|
||||
},
|
||||
{
|
||||
alertId: 90268,
|
||||
raisedOn: daysAgo(6, 11, 5),
|
||||
transactionType: 'Account Transfer',
|
||||
transactionId: 560190,
|
||||
ruleCode: 'ROUND_TRIPPING',
|
||||
ruleDescription: 'Funds returned to the originating account within seven days',
|
||||
amount: 520000,
|
||||
status: 'OPEN',
|
||||
reviewNotes: null
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
// Reviewed with an outcome and no activity since — shows the settled state.
|
||||
{
|
||||
clientId: 10080,
|
||||
clientDisplayName: 'Arjun Deshmukh',
|
||||
accountNo: '000000010080',
|
||||
clientRiskCategory: 1,
|
||||
alertCount: 3,
|
||||
highRiskCount: 0,
|
||||
openCount: 0,
|
||||
escalatedCount: 0,
|
||||
distinctRules: 1,
|
||||
ruleCodes: ['CASH_TXN_10L'],
|
||||
totalAmount: 3420000,
|
||||
firstAlertOn: daysAgo(80, 9, 30),
|
||||
lastAlertOn: daysAgo(23, 12, 15),
|
||||
needsReview: false,
|
||||
lastReviewedOn: daysAgo(9, 15, 20),
|
||||
alertCountAtLastReview: 3,
|
||||
lastReviewOutcome: 'CLEARED',
|
||||
lastReviewedByUsername: 'vikram.singh',
|
||||
lastReviewNotes: 'Sugarcane crushing-season proceeds. Mill settlement statements filed with the branch.',
|
||||
alerts: [
|
||||
{
|
||||
alertId: 90088,
|
||||
raisedOn: daysAgo(80, 9, 30),
|
||||
transactionType: 'Savings Deposit',
|
||||
transactionId: 550210,
|
||||
ruleCode: 'CASH_TXN_10L',
|
||||
ruleDescription: 'Cash deposit at or above ₹10,00,000 in a single transaction',
|
||||
amount: 1180000,
|
||||
status: 'WHITELISTED',
|
||||
reviewNotes: 'Mill settlement for the 2025-26 crushing season.'
|
||||
},
|
||||
{
|
||||
alertId: 90165,
|
||||
raisedOn: daysAgo(52, 11, 0),
|
||||
transactionType: 'Savings Deposit',
|
||||
transactionId: 554901,
|
||||
ruleCode: 'CASH_TXN_10L',
|
||||
ruleDescription: 'Cash deposit at or above ₹10,00,000 in a single transaction',
|
||||
amount: 1090000,
|
||||
status: 'WHITELISTED',
|
||||
reviewNotes: 'Second instalment, statement on record.'
|
||||
},
|
||||
{
|
||||
alertId: 90212,
|
||||
raisedOn: daysAgo(23, 12, 15),
|
||||
transactionType: 'Savings Deposit',
|
||||
transactionId: 557640,
|
||||
ruleCode: 'CASH_TXN_10L',
|
||||
ruleDescription: 'Cash deposit at or above ₹10,00,000 in a single transaction',
|
||||
amount: 1150000,
|
||||
status: 'WHITELISTED',
|
||||
reviewNotes: 'Final instalment for the season.'
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
}
|
||||
+127
@@ -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/.
|
||||
-->
|
||||
|
||||
<h2 mat-dialog-title class="rr-dialog-title">
|
||||
<mat-icon>fact_check</mat-icon>
|
||||
Review Repeat Activity — {{ data.row.clientDisplayName }}
|
||||
</h2>
|
||||
|
||||
<mat-dialog-content class="rr-dialog-content">
|
||||
<div class="rr-summary">
|
||||
<div class="rr-summary-row">
|
||||
<span class="rr-label">Customer</span>
|
||||
<span class="rr-value">
|
||||
{{ data.row.clientDisplayName }}
|
||||
<span class="rr-muted"
|
||||
>(ID {{ data.row.clientId }}{{ data.row.accountNo ? ' · ' + data.row.accountNo : '' }})</span
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
<div class="rr-summary-row">
|
||||
<span class="rr-label">Alerts</span>
|
||||
<span class="rr-value">
|
||||
<strong>{{ data.row.alertCount }}</strong> in the last {{ data.windowDays }} days ·
|
||||
{{ data.row.highRiskCount }} High risk · {{ data.row.distinctRules }} distinct rule(s)
|
||||
</span>
|
||||
</div>
|
||||
<div class="rr-summary-row">
|
||||
<span class="rr-label">Value</span>
|
||||
<span class="rr-value rr-mono">{{ amount(data.row.totalAmount) }}</span>
|
||||
</div>
|
||||
<div class="rr-summary-row">
|
||||
<span class="rr-label">Rules</span>
|
||||
<span class="rr-value">{{ data.row.ruleCodes?.join(', ') || '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- The triggering transactions, so the decision is made against the evidence -->
|
||||
<div class="rr-alerts">
|
||||
<span class="rr-alerts-legend">Triggering transactions</span>
|
||||
<div class="rr-alerts-scroll">
|
||||
<table class="rr-alerts-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Raised On</th>
|
||||
<th>Transaction</th>
|
||||
<th>Rule</th>
|
||||
<th class="ta-r">Amount</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let a of data.row.alerts">
|
||||
<td>{{ a.raisedOnDate | date: 'dd MMM yyyy, HH:mm' }}</td>
|
||||
<td>{{ a.transactionType }} #{{ a.transactionId }}</td>
|
||||
<td>{{ a.ruleCode }}</td>
|
||||
<td class="ta-r rr-mono">{{ amount(a.amount) }}</td>
|
||||
<td>{{ a.status }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div *ngIf="data.row.lastReviewedOn" class="rr-prior">
|
||||
<mat-icon>history</mat-icon>
|
||||
<span>
|
||||
Last reviewed by <strong>{{ data.row.lastReviewedByUsername }}</strong> on
|
||||
{{ data.row.lastReviewedOnDate | date: 'dd MMM yyyy' }} at {{ data.row.alertCountAtLastReview }} alert(s) —
|
||||
{{ data.row.lastReviewOutcome }}. {{ data.row.lastReviewNotes }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div *ngIf="errorMessage" class="rr-error">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
<span>{{ errorMessage }}</span>
|
||||
</div>
|
||||
|
||||
<form [formGroup]="reviewForm" autocomplete="off">
|
||||
<div class="rr-outcome">
|
||||
<span class="rr-outcome-legend">Conclusion</span>
|
||||
<mat-radio-group formControlName="outcome" class="rr-outcome-options">
|
||||
<mat-radio-button *ngFor="let o of outcomes" [value]="o.value" class="rr-outcome-option">
|
||||
{{ o.label }}
|
||||
<span class="rr-muted">— {{ o.hint }}</span>
|
||||
</mat-radio-button>
|
||||
</mat-radio-group>
|
||||
</div>
|
||||
|
||||
<mat-form-field appearance="outline" class="rr-notes-field">
|
||||
<mat-label>Why does this customer keep surfacing?</mat-label>
|
||||
<textarea
|
||||
matInput
|
||||
rows="4"
|
||||
formControlName="notes"
|
||||
placeholder="What the pattern is, what was checked, and what was decided"
|
||||
></textarea>
|
||||
<mat-error *ngIf="reviewForm.get('notes')?.hasError('required')">
|
||||
Required — this is the audit record for the decision
|
||||
</mat-error>
|
||||
<mat-error *ngIf="reviewForm.get('notes')?.hasError('maxlength')">Keep this under 1000 characters</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<p class="rr-note">
|
||||
Recorded against the current count of {{ data.row.alertCount }} alert(s). The customer returns to this queue if
|
||||
the engine flags them again.
|
||||
</p>
|
||||
</form>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end" class="rr-dialog-actions">
|
||||
<button mat-stroked-button type="button" [disabled]="submitting" (click)="cancel()">Cancel</button>
|
||||
<button
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
type="button"
|
||||
[disabled]="reviewForm.invalid || submitting"
|
||||
(click)="submit()"
|
||||
>
|
||||
<mat-spinner *ngIf="submitting" diameter="18" class="rr-spinner"></mat-spinner>
|
||||
{{ submitting ? 'Saving…' : 'Record Review' }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
.rr-dialog-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: #1a237e;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rr-dialog-content {
|
||||
padding-top: 8px;
|
||||
min-width: 620px;
|
||||
max-width: 780px;
|
||||
}
|
||||
|
||||
.rr-summary {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.rr-summary-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 4px 0;
|
||||
font-size: 13px;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.rr-label {
|
||||
flex: 0 0 84px;
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.rr-value {
|
||||
flex: 1 1 auto;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.rr-muted {
|
||||
color: #64748b;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.rr-mono {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.rr-alerts {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.rr-alerts-legend,
|
||||
.rr-outcome-legend {
|
||||
display: block;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #64748b;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
/* Capped so a customer with twenty alerts cannot push the buttons off screen. */
|
||||
.rr-alerts-scroll {
|
||||
max-height: 190px;
|
||||
overflow: auto;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.rr-alerts-tbl {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12.5px;
|
||||
|
||||
th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
font-weight: 700;
|
||||
font-size: 10.5px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 8px 10px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 8px 10px;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
color: #1e293b;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.ta-r {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.rr-prior,
|
||||
.rr-error {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.45;
|
||||
|
||||
mat-icon {
|
||||
flex: 0 0 auto;
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.rr-prior {
|
||||
border: 1px solid #cbd5e1;
|
||||
background: #f8fafc;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.rr-error {
|
||||
border: 1px solid #fecaca;
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.rr-outcome {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.rr-outcome-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.rr-outcome-option {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.rr-notes-field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.rr-note {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.rr-dialog-actions {
|
||||
padding: 8px 24px 20px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rr-spinner {
|
||||
display: inline-block;
|
||||
margin-right: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@media (width <= 700px) {
|
||||
.rr-dialog-content {
|
||||
min-width: unset;
|
||||
}
|
||||
|
||||
.rr-summary-row {
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* 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, inject } from '@angular/core';
|
||||
import { FormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatRadioModule } from '@angular/material/radio';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
/** Custom Services */
|
||||
import { AmlService } from '../../../aml/aml.service';
|
||||
|
||||
export interface AmlRecurringReviewDialogData {
|
||||
row: any;
|
||||
windowDays: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records why a customer keeps tripping the AML engine.
|
||||
*
|
||||
* This is a judgement on the customer's pattern, not on any single transaction — the individual
|
||||
* alerts are still disposed of one by one on the Flagged Transactions tab. Choosing ESCALATED here
|
||||
* documents the reviewer's conclusion; it does not itself open an STR case, because a case is
|
||||
* opened against a specific alert.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-aml-recurring-review-dialog',
|
||||
templateUrl: './aml-recurring-review-dialog.component.html',
|
||||
styleUrls: ['./aml-recurring-review-dialog.component.scss'],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
MatDialogModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatRadioModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatProgressSpinnerModule
|
||||
]
|
||||
})
|
||||
export class AmlRecurringReviewDialogComponent {
|
||||
private formBuilder = inject(FormBuilder);
|
||||
private amlService = inject(AmlService);
|
||||
private dialogRef = inject(MatDialogRef<AmlRecurringReviewDialogComponent>);
|
||||
readonly data = inject<AmlRecurringReviewDialogData>(MAT_DIALOG_DATA);
|
||||
|
||||
submitting = false;
|
||||
errorMessage = '';
|
||||
|
||||
readonly outcomes = [
|
||||
{ value: 'CLEARED', label: 'Cleared', hint: 'Behaviour explained and acceptable' },
|
||||
{ value: 'MONITOR', label: 'Keep monitoring', hint: 'Not conclusive — watch this customer more closely' },
|
||||
{ value: 'ESCALATED', label: 'Escalate', hint: 'Warrants an STR case; escalate the alerts individually' }
|
||||
];
|
||||
|
||||
reviewForm = this.formBuilder.group({
|
||||
outcome: [
|
||||
'MONITOR',
|
||||
Validators.required
|
||||
],
|
||||
notes: [
|
||||
'',
|
||||
[
|
||||
Validators.required,
|
||||
Validators.maxLength(1000)
|
||||
]
|
||||
]
|
||||
});
|
||||
|
||||
submit(): void {
|
||||
if (this.reviewForm.invalid) {
|
||||
this.reviewForm.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
this.submitting = true;
|
||||
this.errorMessage = '';
|
||||
this.amlService
|
||||
.reviewRecurringAlert(
|
||||
this.data.row.clientId,
|
||||
this.reviewForm.value.outcome as 'CLEARED' | 'MONITOR' | 'ESCALATED',
|
||||
(this.reviewForm.value.notes || '').trim(),
|
||||
this.data.windowDays
|
||||
)
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.submitting = false;
|
||||
this.dialogRef.close(this.reviewForm.value.outcome);
|
||||
},
|
||||
error: (err) => {
|
||||
this.submitting = false;
|
||||
this.errorMessage = this.readError(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
amount(value: any): string {
|
||||
return '₹' + (Number(value) || 0).toLocaleString('en-IN');
|
||||
}
|
||||
|
||||
private readError(err: any): string {
|
||||
const body = err?.error;
|
||||
const first = body?.errors?.[0];
|
||||
return (
|
||||
first?.defaultUserMessage || first?.developerMessage || body?.defaultUserMessage || 'Could not record the review.'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -18,119 +18,192 @@
|
||||
</mat-card-subtitle>
|
||||
</div>
|
||||
</div>
|
||||
<button mat-flat-button type="button" class="add-rule-button" (click)="addRule()">
|
||||
<mat-icon>add</mat-icon>
|
||||
Add Rule
|
||||
</button>
|
||||
</mat-card-header>
|
||||
|
||||
<mat-card-content class="aml-card-content">
|
||||
<!-- Separate the rules by the window they are measured over -->
|
||||
<div class="period-filters">
|
||||
<span class="period-filters-label">Applies over</span>
|
||||
<button
|
||||
*ngFor="let p of periodFilters"
|
||||
type="button"
|
||||
class="period-chip"
|
||||
[class.active]="activePeriod === p.key"
|
||||
[disabled]="periodCount(p.key) === 0"
|
||||
(click)="setPeriod(p.key)"
|
||||
>
|
||||
{{ p.label }}
|
||||
<span class="period-chip-n">{{ periodCount(p.key) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<mat-tab-group class="aml-tabs" animationDuration="0ms">
|
||||
<mat-tab label="Rules Master">
|
||||
<div class="aml-tab-body">
|
||||
<!-- Free-text search across every column the table renders -->
|
||||
<div class="aml-search-bar">
|
||||
<mat-form-field appearance="outline" class="aml-search-field" subscriptSizing="dynamic">
|
||||
<mat-label>Search rules</mat-label>
|
||||
<mat-icon matPrefix class="aml-search-icon">search</mat-icon>
|
||||
<input
|
||||
matInput
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
placeholder="Rule, reason, risk, limit, window, status…"
|
||||
[ngModel]="searchTerm"
|
||||
(ngModelChange)="applySearch($event)"
|
||||
/>
|
||||
<button
|
||||
*ngIf="searchTerm"
|
||||
matSuffix
|
||||
mat-icon-button
|
||||
type="button"
|
||||
aria-label="Clear search"
|
||||
matTooltip="Clear search"
|
||||
(click)="clearSearch()"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</mat-form-field>
|
||||
<span *ngIf="isSearching" class="aml-search-count">
|
||||
Matching <strong>{{ matchedRules }}</strong> of {{ totalRules }} rules
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<!-- Separate the rules by the window they are measured over -->
|
||||
<div class="period-filters">
|
||||
<span class="period-filters-label">Applies over</span>
|
||||
<button
|
||||
*ngFor="let p of periodFilters"
|
||||
type="button"
|
||||
class="period-chip"
|
||||
[class.active]="activePeriod === p.key"
|
||||
[disabled]="periodCount(p.key) === 0 && activePeriod !== p.key"
|
||||
(click)="setPeriod(p.key)"
|
||||
>
|
||||
{{ p.label }}
|
||||
<span class="period-chip-n">{{ periodCount(p.key) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
<!-- 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>
|
||||
|
||||
<!-- What the min/max figures are measured against -->
|
||||
<ng-container matColumnDef="limitBasis">
|
||||
<th mat-header-cell *matHeaderCellDef>Limit Basis</th>
|
||||
<td mat-cell *matCellDef="let element" class="basis-cell">
|
||||
{{ getLimitBasis(element) }}
|
||||
<span *ngIf="getNonNumericCriterion(element)" class="basis-sub">
|
||||
{{ getNonNumericCriterion(element) }}
|
||||
</span>
|
||||
</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>
|
||||
|
||||
<!-- Minimum Limit Column -->
|
||||
<ng-container matColumnDef="minLimit">
|
||||
<th mat-header-cell *matHeaderCellDef class="col-right">Minimum Limit</th>
|
||||
<td mat-cell *matCellDef="let element" class="col-right amount-cell">{{ getMinLimit(element) }}</td>
|
||||
</ng-container>
|
||||
<!-- What the min/max figures are measured against -->
|
||||
<ng-container matColumnDef="limitBasis">
|
||||
<th mat-header-cell *matHeaderCellDef>Limit Basis</th>
|
||||
<td mat-cell *matCellDef="let element" class="basis-cell">
|
||||
{{ getLimitBasis(element) }}
|
||||
<span *ngIf="getNonNumericCriterion(element)" class="basis-sub">
|
||||
{{ getNonNumericCriterion(element) }}
|
||||
</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Maximum Limit Column -->
|
||||
<ng-container matColumnDef="maxLimit">
|
||||
<th mat-header-cell *matHeaderCellDef class="col-right">Maximum Limit</th>
|
||||
<td mat-cell *matCellDef="let element" class="col-right amount-cell">
|
||||
<span [class.no-ceiling]="getMaxLimit(element) === 'No upper limit'">{{ getMaxLimit(element) }}</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
<!-- Minimum Limit Column -->
|
||||
<ng-container matColumnDef="minLimit">
|
||||
<th mat-header-cell *matHeaderCellDef class="col-right">Minimum Limit</th>
|
||||
<td mat-cell *matCellDef="let element" class="col-right amount-cell">{{ getMinLimit(element) }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Observation window the rule is measured over -->
|
||||
<ng-container matColumnDef="appliesOver">
|
||||
<th mat-header-cell *matHeaderCellDef class="col-center">Applies Over</th>
|
||||
<td mat-cell *matCellDef="let element" class="col-center">
|
||||
<span [ngClass]="periodClass(element)">{{ periodLabel(element) }}</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
<!-- Maximum Limit Column -->
|
||||
<ng-container matColumnDef="maxLimit">
|
||||
<th mat-header-cell *matHeaderCellDef class="col-right">Maximum Limit</th>
|
||||
<td mat-cell *matCellDef="let element" class="col-right amount-cell">
|
||||
<span [class.no-ceiling]="getMaxLimit(element) === 'No upper limit'">{{ getMaxLimit(element) }}</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>
|
||||
<!-- Observation window the rule is measured over -->
|
||||
<ng-container matColumnDef="appliesOver">
|
||||
<th mat-header-cell *matHeaderCellDef class="col-center">Applies Over</th>
|
||||
<td mat-cell *matCellDef="let element" class="col-center">
|
||||
<span [ngClass]="periodClass(element)">{{ periodLabel(element) }}</span>
|
||||
</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>
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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>
|
||||
<!-- 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>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns" class="rule-row"></tr>
|
||||
</table>
|
||||
<!-- 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>
|
||||
|
||||
<!-- Mat Paginator for standard pagination -->
|
||||
<mat-paginator [pageSizeOptions]="[10, 25, 50, 100]" showFirstLastButtons class="aml-paginator"></mat-paginator>
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns" class="rule-row"></tr>
|
||||
</table>
|
||||
|
||||
<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 Paginator for standard pagination -->
|
||||
<mat-paginator
|
||||
[pageSizeOptions]="[10, 25, 50, 100]"
|
||||
showFirstLastButtons
|
||||
class="aml-paginator"
|
||||
></mat-paginator>
|
||||
|
||||
<div *ngIf="rules.filteredData.length === 0" class="no-rules-message">
|
||||
<mat-icon>{{ isSearching ? 'search_off' : 'info_outline' }}</mat-icon>
|
||||
<span *ngIf="!isSearching">No AML rules found in the database.</span>
|
||||
<ng-container *ngIf="isSearching">
|
||||
<span>No rules match “{{ searchTerm }}”.</span>
|
||||
<button mat-stroked-button type="button" (click)="clearSearch()">Clear search</button>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<!-- Live feed of what the engine has actually flagged. Lazily instantiated via
|
||||
matTabContent so the poll only runs while the officer is watching this tab. -->
|
||||
<mat-tab label="Flagged Transactions">
|
||||
<ng-template matTabContent>
|
||||
<div class="aml-tab-body">
|
||||
<mifosx-aml-flagged-transactions></mifosx-aml-flagged-transactions>
|
||||
</div>
|
||||
</ng-template>
|
||||
</mat-tab>
|
||||
|
||||
<!-- Customers who keep tripping the engine, queued for an employee to review why. -->
|
||||
<mat-tab label="Repeat Customers">
|
||||
<ng-template matTabContent>
|
||||
<div class="aml-tab-body">
|
||||
<mifosx-aml-recurring-alerts></mifosx-aml-recurring-alerts>
|
||||
</div>
|
||||
</ng-template>
|
||||
</mat-tab>
|
||||
</mat-tab-group>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
|
||||
@@ -26,6 +26,28 @@
|
||||
background: linear-gradient(135deg, #1e293b, #0f172a);
|
||||
color: #fff;
|
||||
padding: 24px;
|
||||
|
||||
/* Title on the left, the add action pinned to the right edge of the banner. */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.add-rule-button {
|
||||
flex: 0 0 auto;
|
||||
background: #38bdf8;
|
||||
color: #0f172a;
|
||||
font-weight: 600;
|
||||
|
||||
mat-icon {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #0ea5e9;
|
||||
}
|
||||
}
|
||||
|
||||
.aml-header-content {
|
||||
@@ -58,6 +80,18 @@
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* Rules Master / Flagged Transactions. Padding lives on the body rather than the group so the
|
||||
tab header rule still spans the full card width. */
|
||||
.aml-tabs {
|
||||
::ng-deep .mat-mdc-tab-body-wrapper {
|
||||
padding-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.aml-tab-body {
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.aml-table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
@@ -187,6 +221,35 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Free-text search over the rule list. */
|
||||
.aml-search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
padding: 0 0 14px;
|
||||
}
|
||||
|
||||
.aml-search-field {
|
||||
flex: 1 1 340px;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.aml-search-icon {
|
||||
color: #64748b;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.aml-search-count {
|
||||
font-size: 12.5px;
|
||||
color: #64748b;
|
||||
|
||||
strong {
|
||||
color: #1a237e;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
/* Chips that separate the rule list by window. */
|
||||
.period-filters {
|
||||
display: flex;
|
||||
|
||||
@@ -14,10 +14,20 @@ 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 { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
/** Custom Services */
|
||||
import { ClientsService } from '../../clients/clients.service';
|
||||
import { AlertService } from '../../core/alert/alert.service';
|
||||
import { AddAmlRuleDialogComponent } from './add-aml-rule-dialog/add-aml-rule-dialog.component';
|
||||
import { AmlFlaggedTransactionsComponent } from './aml-flagged-transactions/aml-flagged-transactions.component';
|
||||
import { AmlRecurringAlertsComponent } from './aml-recurring-alerts/aml-recurring-alerts.component';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-aml-rules',
|
||||
@@ -31,11 +41,21 @@ import { ClientsService } from '../../clients/clients.service';
|
||||
MatIconModule,
|
||||
MatButtonModule,
|
||||
MatCheckboxModule,
|
||||
MatPaginatorModule
|
||||
MatPaginatorModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatTooltipModule,
|
||||
MatDialogModule,
|
||||
MatTabsModule,
|
||||
FormsModule,
|
||||
AmlFlaggedTransactionsComponent,
|
||||
AmlRecurringAlertsComponent
|
||||
]
|
||||
})
|
||||
export class AmlRulesComponent implements OnInit {
|
||||
private clientsService = inject(ClientsService);
|
||||
private dialog = inject(MatDialog);
|
||||
private alertService = inject(AlertService);
|
||||
|
||||
rules = new MatTableDataSource<any>([]);
|
||||
displayedColumns: string[] = [
|
||||
@@ -65,9 +85,27 @@ export class AmlRulesComponent implements OnInit {
|
||||
];
|
||||
activePeriod = '';
|
||||
|
||||
/** Free-text search, applied on top of the period filter. */
|
||||
searchTerm = '';
|
||||
|
||||
/**
|
||||
* Rule count per period bucket, keyed by period. Precomputed on every filter change
|
||||
* rather than counted from the template — the chips are re-read on each change
|
||||
* detection pass and re-scanning every rule six times over is wasted work.
|
||||
*/
|
||||
private periodCounts: Record<string, number> = {};
|
||||
|
||||
@ViewChild(MatPaginator) paginator!: MatPaginator;
|
||||
|
||||
ngOnInit(): void {
|
||||
// The visible table is the rendered text, not the raw row — a reader searching
|
||||
// "high risk" or "monthly" is matching on labels this component derives, so the
|
||||
// predicate has to search the same strings it puts on screen.
|
||||
this.rules.filterPredicate = (rule: any, filter: string) => this.matchesSearch(rule, filter);
|
||||
this.loadRules();
|
||||
}
|
||||
|
||||
private loadRules(): void {
|
||||
this.clientsService.getClientRiskRules().subscribe({
|
||||
next: (data) => {
|
||||
this.allRules = data || [];
|
||||
@@ -78,15 +116,65 @@ export class AmlRulesComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
addRule(): void {
|
||||
const dialogRef = this.dialog.open(AddAmlRuleDialogComponent, {
|
||||
width: '760px',
|
||||
maxWidth: '95vw',
|
||||
autoFocus: false,
|
||||
// The officer has typed a full rule by the time they reach the buttons; a stray
|
||||
// backdrop click should not discard it.
|
||||
disableClose: true
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe((created: any) => {
|
||||
if (!created) {
|
||||
return;
|
||||
}
|
||||
this.alertService.alert({
|
||||
type: 'AML Rule Created',
|
||||
message: `Rule "${created.ruleName}" added to the catalog.`
|
||||
});
|
||||
// Re-fetch rather than splice the new rule in: order_position is assigned server side
|
||||
// and the transaction-rule join decides the Applies Over / limit columns.
|
||||
this.loadRules();
|
||||
});
|
||||
}
|
||||
|
||||
setPeriod(key: string): void {
|
||||
this.activePeriod = key;
|
||||
this.applyPeriodFilter();
|
||||
}
|
||||
|
||||
applySearch(value: string): void {
|
||||
this.searchTerm = value;
|
||||
this.rules.filter = value.trim().toLowerCase();
|
||||
this.recomputePeriodCounts();
|
||||
if (this.rules.paginator) {
|
||||
this.rules.paginator.firstPage();
|
||||
}
|
||||
}
|
||||
|
||||
clearSearch(): void {
|
||||
this.applySearch('');
|
||||
}
|
||||
|
||||
get totalRules(): number {
|
||||
return this.allRules.length;
|
||||
}
|
||||
|
||||
get matchedRules(): number {
|
||||
return this.rules.filteredData.length;
|
||||
}
|
||||
|
||||
get isSearching(): boolean {
|
||||
return !!this.rules.filter;
|
||||
}
|
||||
|
||||
private applyPeriodFilter(): void {
|
||||
this.rules.data = this.activePeriod
|
||||
? this.allRules.filter((r) => this.periodKey(r) === this.activePeriod)
|
||||
: this.allRules;
|
||||
this.recomputePeriodCounts();
|
||||
if (this.rules.paginator) {
|
||||
this.rules.paginator.firstPage();
|
||||
}
|
||||
@@ -94,7 +182,58 @@ export class AmlRulesComponent implements OnInit {
|
||||
|
||||
/** How many rules fall in each period bucket — shown on the filter chips. */
|
||||
periodCount(key: string): number {
|
||||
return key ? this.allRules.filter((r) => this.periodKey(r) === key).length : this.allRules.length;
|
||||
return this.periodCounts[key] ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts are taken over the search results, not the whole set, so a chip never
|
||||
* advertises rules the current search has filtered out.
|
||||
*/
|
||||
private recomputePeriodCounts(): void {
|
||||
const filter = this.rules.filter;
|
||||
const pool = filter ? this.allRules.filter((r) => this.matchesSearch(r, filter)) : this.allRules;
|
||||
this.periodCounts = { '': pool.length };
|
||||
for (const p of this.periodFilters) {
|
||||
if (p.key) {
|
||||
this.periodCounts[p.key] = pool.filter((r) => this.periodKey(r) === p.key).length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Every term must match, so "high monthly" narrows instead of widening. */
|
||||
private matchesSearch(rule: any, filter: string): boolean {
|
||||
const terms = filter.split(/\s+/).filter((t) => t.length > 0);
|
||||
if (terms.length === 0) {
|
||||
return true;
|
||||
}
|
||||
const haystack = this.searchableText(rule);
|
||||
return terms.every((t) => haystack.includes(t));
|
||||
}
|
||||
|
||||
/** Everything the row displays, flattened into one lower-case string. */
|
||||
private searchableText(rule: any): string {
|
||||
const min = this.getMinLimit(rule);
|
||||
const max = this.getMaxLimit(rule);
|
||||
return [
|
||||
rule.orderPosition,
|
||||
rule.ruleName,
|
||||
rule.reason,
|
||||
this.getRiskCategoryLabel(rule.riskCategory),
|
||||
this.getLimitBasis(rule),
|
||||
this.getNonNumericCriterion(rule),
|
||||
min,
|
||||
max,
|
||||
// Amounts are rendered grouped ("₹10,00,000"); also index the bare digits so
|
||||
// typing 1000000 finds them.
|
||||
min.replace(/\D/g, ''),
|
||||
max.replace(/\D/g, ''),
|
||||
this.periodLabel(rule),
|
||||
rule.amlCheckStatus || 'CLEAR',
|
||||
rule.bannedEntityMatch || 'NO'
|
||||
]
|
||||
.filter((v) => v != null && v !== '')
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user