feat: KYC risk profiling, AML flagged transactions bulk review, Risk Review report & Board Resolution register
This commit is contained in:
@@ -34,6 +34,17 @@ export class AmlService {
|
||||
return this.http.post(`/aml/alerts/${alertId}/review`, { disposition, notes });
|
||||
}
|
||||
|
||||
/**
|
||||
* Decides named rules within one alert, leaving its other rules open.
|
||||
*
|
||||
* <p>
|
||||
* The alert's own status follows from what is left undecided, so it is not sent: whitelisting five of six rules
|
||||
* leaves the alert open on the sixth.
|
||||
*/
|
||||
reviewAlertRules(alertId: number, ruleCodes: string[], disposition: string, notes: string): Observable<any> {
|
||||
return this.http.post(`/aml/alerts/${alertId}/review`, { disposition, notes, ruleCodes });
|
||||
}
|
||||
|
||||
// ═══ Recurring-alert review queue ═══
|
||||
|
||||
/**
|
||||
@@ -141,4 +152,38 @@ export class AmlService {
|
||||
getRiskCategoryHistory(clientId: number): Observable<any[]> {
|
||||
return this.http.get<any[]>(`/clients/${clientId}/risk-category-history`);
|
||||
}
|
||||
|
||||
// ═══ Risk-category changes (maker-checker) ═══
|
||||
//
|
||||
// The rating decides how often a customer is re-KYC'd and what AML thresholds apply to them, so
|
||||
// it moves only once a checker agrees rather than on one officer's say-so.
|
||||
|
||||
/** @param requestedCategory 1 Low, 2 Medium, 3 High */
|
||||
requestRiskChange(clientId: number, requestedCategory: number, reason: string): Observable<any> {
|
||||
return this.http.post('/aml/risk-change-requests', { clientId, requestedCategory, reason });
|
||||
}
|
||||
|
||||
getRiskChangeRequests(status?: string): Observable<any[]> {
|
||||
const params = status ? new HttpParams().set('status', status) : undefined;
|
||||
return this.http.get<any[]>('/aml/risk-change-requests', { params });
|
||||
}
|
||||
|
||||
approveRiskChange(requestId: number): Observable<any> {
|
||||
return this.http.post(`/aml/risk-change-requests/${requestId}/approve`, {});
|
||||
}
|
||||
|
||||
rejectRiskChange(requestId: number, rejectionReason: string): Observable<any> {
|
||||
return this.http.post(`/aml/risk-change-requests/${requestId}/reject`, { rejectionReason });
|
||||
}
|
||||
|
||||
/**
|
||||
* The latest behaviour window for each customer, as the nightly job computed it.
|
||||
*
|
||||
* One row per customer carrying the review period's figures and the rating they suggest. The Risk Review report
|
||||
* prefers these over its own per-alert reckoning, because a rating describes a period rather than the handful of
|
||||
* transactions that happened to trip a rule.
|
||||
*/
|
||||
getBehaviourWindows(): Observable<any[]> {
|
||||
return this.http.get<any[]>('/risk-review/behaviour-windows');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
<!--
|
||||
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="bor-root">
|
||||
<div class="bor-head">
|
||||
<div class="bor-head-left">
|
||||
<fa-icon icon="gavel" class="bor-head-ic"></fa-icon>
|
||||
<div>
|
||||
<h2 class="bor-head-t">Board of Resolution Register — Share Capital</h2>
|
||||
<p class="bor-head-s">
|
||||
Every share account with the board resolution that authorised the allotment. Accounts with no resolution on
|
||||
record sort to the top.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="bor-btn bor-btn--xls" type="button" [disabled]="exporting || !filtered.length" (click)="exportExcel()">
|
||||
<fa-icon icon="file-excel"></fa-icon>
|
||||
{{ exporting ? 'Exporting…' : 'Export' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (loading) {
|
||||
<div class="bor-note">Loading the register…</div>
|
||||
} @else if (loadError) {
|
||||
<div class="bor-note bor-note--warn"><fa-icon icon="exclamation-triangle"></fa-icon> {{ loadError }}</div>
|
||||
}
|
||||
|
||||
@if (!loading && !loadError) {
|
||||
<!-- Counted over the whole register, not the filtered view: it is a standing figure the
|
||||
secretary acts on, and hiding it behind a filter would hide the problem. -->
|
||||
@if (missingResolutionCount > 0) {
|
||||
<div class="bor-note bor-note--warn">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
{{ missingResolutionCount }} share account{{ missingResolutionCount === 1 ? '' : 's' }} carr{{
|
||||
missingResolutionCount === 1 ? 'ies' : 'y'
|
||||
}}
|
||||
no resolution number. Share capital allotted without a board resolution cannot be evidenced to the Registrar.
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="bor-kpis">
|
||||
<div class="bor-kpi">
|
||||
<div class="bor-kpi-n">{{ filtered.length | number }}</div>
|
||||
<div class="bor-kpi-l">Share Accounts</div>
|
||||
</div>
|
||||
<div class="bor-kpi bor-kpi--blue">
|
||||
<div class="bor-kpi-n">{{ distinctResolutions | number }}</div>
|
||||
<div class="bor-kpi-l">Resolutions</div>
|
||||
</div>
|
||||
<div class="bor-kpi bor-kpi--green">
|
||||
<div class="bor-kpi-n">{{ totalShares | number }}</div>
|
||||
<div class="bor-kpi-l">Shares Allotted</div>
|
||||
</div>
|
||||
<div class="bor-kpi bor-kpi--amber">
|
||||
<div class="bor-kpi-n">₹{{ totalValue | number: '1.0-0' }}</div>
|
||||
<div class="bor-kpi-l">Share Value</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bor-filters">
|
||||
<div class="bor-filter">
|
||||
<label>Resolution No.</label>
|
||||
<input class="bor-inp" type="text" [(ngModel)]="filterResolution" placeholder="e.g. BOR/2026-27/001" />
|
||||
</div>
|
||||
<div class="bor-filter">
|
||||
<label>Branch</label>
|
||||
<select class="bor-inp" [(ngModel)]="filterBranch">
|
||||
<option value="">All branches</option>
|
||||
@for (b of branches; track b) {
|
||||
<option [value]="b">{{ b }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="bor-filter">
|
||||
<label>Status</label>
|
||||
<select class="bor-inp" [(ngModel)]="filterStatus">
|
||||
<option [ngValue]="''">Any status</option>
|
||||
@for (s of statuses; track s) {
|
||||
<option [ngValue]="s">{{ statusText(s) }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="bor-filter bor-filter--grow">
|
||||
<label>Search</label>
|
||||
<input class="bor-inp" type="text" [(ngModel)]="search" placeholder="Member name, share A/c or CIF…" />
|
||||
</div>
|
||||
<label class="bor-check">
|
||||
<input type="checkbox" [(ngModel)]="missingOnly" />
|
||||
No resolution only
|
||||
</label>
|
||||
<button class="bor-btn" type="button" (click)="resetFilters()"><fa-icon icon="undo"></fa-icon> Reset</button>
|
||||
</div>
|
||||
|
||||
<div class="bor-table-wrap">
|
||||
<table class="bor-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Resolution No.</th>
|
||||
<th>Resolution Date</th>
|
||||
<th>Share A/c No.</th>
|
||||
<th>CIF No.</th>
|
||||
<th>Member Name</th>
|
||||
<th>Branch</th>
|
||||
<th>Product</th>
|
||||
<th class="num">Shares</th>
|
||||
<th class="num">Share Value</th>
|
||||
<th class="num">Tranches</th>
|
||||
<th>Status</th>
|
||||
<th>Activated On</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of filtered; track r.shareAccountId) {
|
||||
<tr [class.bor-row--missing]="!r.resolutionNumber">
|
||||
<td>
|
||||
@if (r.resolutionNumber) {
|
||||
<strong class="bor-res">{{ r.resolutionNumber }}</strong>
|
||||
} @else {
|
||||
<span class="bor-missing">Not recorded</span>
|
||||
}
|
||||
</td>
|
||||
<td class="mono">{{ r.resolutionDate || '—' }}</td>
|
||||
<td class="mono">{{ r.accountNo }}</td>
|
||||
<td class="mono">{{ r.cifNumber || '—' }}</td>
|
||||
<td>{{ r.clientName }}</td>
|
||||
<td>{{ r.officeName || '—' }}</td>
|
||||
<td>{{ r.productName || '—' }}</td>
|
||||
<td class="num">{{ r.approvedShares | number }}</td>
|
||||
<td class="num strong">{{ r.shareValue | number: '1.2-2' }}</td>
|
||||
<td class="num">{{ r.trancheCount }}</td>
|
||||
<td><span [class]="statusClass(r.statusEnum)">{{ statusText(r.statusEnum) }}</span></td>
|
||||
<td class="mono">{{ r.activatedDate || '—' }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="12" class="bor-empty">No share account matches the filters.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
.bor-root {
|
||||
padding: 20px 24px 40px;
|
||||
background: #f8fafc;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.bor-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.bor-head-left {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.bor-head-ic {
|
||||
font-size: 26px;
|
||||
color: #0f4f3c;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.bor-head-t {
|
||||
margin: 0 0 3px;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.bor-head-s {
|
||||
margin: 0;
|
||||
max-width: 780px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.bor-note {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 9px;
|
||||
padding: 11px 14px;
|
||||
margin-bottom: 14px;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 9px;
|
||||
background: #eff6ff;
|
||||
color: #1e3a8a;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
|
||||
fa-icon {
|
||||
flex: 0 0 auto;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
/* An allotment with no resolution behind it is the finding, not a detail. */
|
||||
&--warn {
|
||||
border-color: #fde68a;
|
||||
background: #fffbeb;
|
||||
color: #92400e;
|
||||
|
||||
fa-icon {
|
||||
color: #d97706;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bor-kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.bor-kpi {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-left: 4px solid #0f4f3c;
|
||||
border-radius: 11px;
|
||||
padding: 12px 16px;
|
||||
|
||||
&--blue {
|
||||
border-left-color: #2563eb;
|
||||
}
|
||||
|
||||
&--green {
|
||||
border-left-color: #15803d;
|
||||
}
|
||||
|
||||
&--amber {
|
||||
border-left-color: #d97706;
|
||||
}
|
||||
}
|
||||
|
||||
.bor-kpi-n {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.bor-kpi-l {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.bor-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 14px;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 11px;
|
||||
}
|
||||
|
||||
.bor-filter {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
label {
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
&--grow {
|
||||
flex: 1 1 240px;
|
||||
}
|
||||
}
|
||||
|
||||
.bor-inp {
|
||||
height: 34px;
|
||||
min-width: 170px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 7px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
color: #1e293b;
|
||||
background: #fff;
|
||||
|
||||
&:focus {
|
||||
outline: 2px solid #2563eb;
|
||||
outline-offset: -1px;
|
||||
}
|
||||
}
|
||||
|
||||
.bor-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 34px;
|
||||
font-size: 12.5px;
|
||||
color: #334155;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bor-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
height: 34px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 7px;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&--xls {
|
||||
border-color: #15803d;
|
||||
background: #16a34a;
|
||||
color: #fff;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #15803d;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bor-table-wrap {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 11px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.bor-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12.5px;
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 10px;
|
||||
background: #f1f5f9;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #475569;
|
||||
white-space: nowrap;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 11px 10px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
vertical-align: middle;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.num {
|
||||
text-align: right;
|
||||
font-family: 'DM Mono', monospace;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: 'DM Mono', monospace;
|
||||
}
|
||||
|
||||
.strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
}
|
||||
|
||||
/* The row an auditor is looking for, marked in the table as well as counted above it. */
|
||||
.bor-row--missing {
|
||||
background: #fffbeb;
|
||||
}
|
||||
|
||||
.bor-res {
|
||||
color: #0f4f3c;
|
||||
font-family: 'DM Mono', monospace;
|
||||
}
|
||||
|
||||
.bor-missing {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.bor-status {
|
||||
display: inline-block;
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
|
||||
&--active {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
&--submitted,
|
||||
&--approved {
|
||||
background: #fef3c7;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
&--rejected,
|
||||
&--closed {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
}
|
||||
|
||||
.bor-empty {
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
color: #94a3b8;
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
import { exportWorkbook } from '../cashier/cashier-export';
|
||||
import { BorRegisterRow, BorReportService } from './bor-report.service';
|
||||
|
||||
/** m_share_account.status_enum, as the register labels it. */
|
||||
const STATUS_LABEL: Record<number, string> = {
|
||||
100: 'Submitted',
|
||||
200: 'Approved',
|
||||
300: 'Active',
|
||||
400: 'Rejected',
|
||||
500: 'Closed',
|
||||
600: 'Inactive'
|
||||
};
|
||||
|
||||
/**
|
||||
* The Board of Resolution register for share capital.
|
||||
*
|
||||
* A co-operative allots shares only under a board resolution, and this is the list the Registrar or
|
||||
* an auditor asks for: every member's holding against the resolution that authorised it. The
|
||||
* account with no resolution recorded is the point of the register, so it sorts to the top and is
|
||||
* counted on its own rather than being left to be noticed.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-bor-report',
|
||||
templateUrl: './bor-report.component.html',
|
||||
styleUrls: ['./bor-report.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
FaIconComponent,
|
||||
FormsModule
|
||||
]
|
||||
})
|
||||
export class BorReportComponent implements OnInit {
|
||||
private borService = inject(BorReportService);
|
||||
|
||||
readonly statusLabel = STATUS_LABEL;
|
||||
|
||||
rows: BorRegisterRow[] = [];
|
||||
loading = false;
|
||||
loadError = '';
|
||||
|
||||
/** '' = every status. */
|
||||
filterStatus: number | '' = '';
|
||||
filterBranch = '';
|
||||
filterResolution = '';
|
||||
search = '';
|
||||
/** Only the accounts with no resolution on file — the ones that need chasing. */
|
||||
missingOnly = false;
|
||||
|
||||
exporting = false;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.loadError = '';
|
||||
this.borService.getRegister().subscribe({
|
||||
next: (rows) => {
|
||||
this.loading = false;
|
||||
this.rows = rows;
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.loading = false;
|
||||
this.rows = [];
|
||||
this.loadError = err?.error?.defaultUserMessage || 'Could not load the BOR register.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Branches present in the data, so the filter offers only what can actually be selected. */
|
||||
get branches(): string[] {
|
||||
return [...new Set(this.rows.map((r) => r.officeName).filter(Boolean))].sort();
|
||||
}
|
||||
|
||||
get statuses(): number[] {
|
||||
return [...new Set(this.rows.map((r) => r.statusEnum))].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
get filtered(): BorRegisterRow[] {
|
||||
const needle = this.search.trim().toLowerCase();
|
||||
const resolutionNeedle = this.filterResolution.trim().toLowerCase();
|
||||
return this.rows.filter((r) => {
|
||||
if (this.filterStatus !== '' && r.statusEnum !== this.filterStatus) {
|
||||
return false;
|
||||
}
|
||||
if (this.filterBranch && r.officeName !== this.filterBranch) {
|
||||
return false;
|
||||
}
|
||||
if (this.missingOnly && r.resolutionNumber) {
|
||||
return false;
|
||||
}
|
||||
if (resolutionNeedle && !r.resolutionNumber.toLowerCase().includes(resolutionNeedle)) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
needle &&
|
||||
!(
|
||||
r.clientName.toLowerCase().includes(needle) ||
|
||||
r.accountNo.toLowerCase().includes(needle) ||
|
||||
r.cifNumber.toLowerCase().includes(needle)
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
get totalShares(): number {
|
||||
return this.filtered.reduce((s, r) => s + r.approvedShares, 0);
|
||||
}
|
||||
|
||||
get totalValue(): number {
|
||||
return this.filtered.reduce((s, r) => s + r.shareValue, 0);
|
||||
}
|
||||
|
||||
/** How many resolutions the filtered holdings were authorised under. */
|
||||
get distinctResolutions(): number {
|
||||
return new Set(this.filtered.map((r) => r.resolutionNumber).filter(Boolean)).size;
|
||||
}
|
||||
|
||||
get missingResolutionCount(): number {
|
||||
return this.rows.filter((r) => !r.resolutionNumber).length;
|
||||
}
|
||||
|
||||
statusText(statusEnum: number): string {
|
||||
return STATUS_LABEL[statusEnum] || '—';
|
||||
}
|
||||
|
||||
statusClass(statusEnum: number): string {
|
||||
return 'bor-status bor-status--' + (STATUS_LABEL[statusEnum] || 'unknown').toLowerCase();
|
||||
}
|
||||
|
||||
resetFilters(): void {
|
||||
this.filterStatus = '';
|
||||
this.filterBranch = '';
|
||||
this.filterResolution = '';
|
||||
this.search = '';
|
||||
this.missingOnly = false;
|
||||
}
|
||||
|
||||
async exportExcel(): Promise<void> {
|
||||
this.exporting = true;
|
||||
try {
|
||||
const rows = this.filtered;
|
||||
await exportWorkbook(
|
||||
[
|
||||
{
|
||||
sheetName: 'BOR Register',
|
||||
title: 'Board of Resolution Register — Share Capital',
|
||||
subtitle: `${rows.length} share account(s) · ${this.distinctResolutions} resolution(s) · ${this.totalShares} shares`,
|
||||
headers: [
|
||||
'Resolution No.',
|
||||
'Resolution Date',
|
||||
'Share A/c No.',
|
||||
'CIF No.',
|
||||
'Member Name',
|
||||
'Branch',
|
||||
'Product',
|
||||
'Shares',
|
||||
'Unit Price',
|
||||
'Share Value',
|
||||
'Tranches',
|
||||
'Status',
|
||||
'Activated On'
|
||||
],
|
||||
widths: [
|
||||
20,
|
||||
16,
|
||||
18,
|
||||
16,
|
||||
30,
|
||||
18,
|
||||
18,
|
||||
10,
|
||||
12,
|
||||
14,
|
||||
10,
|
||||
12,
|
||||
14
|
||||
],
|
||||
rows: rows.map((r) => [
|
||||
// Named rather than left blank, because an empty cell in a filing reads as an
|
||||
// oversight in the export rather than a gap in the register.
|
||||
r.resolutionNumber || 'NOT RECORDED',
|
||||
r.resolutionDate || '—',
|
||||
r.accountNo,
|
||||
r.cifNumber,
|
||||
r.clientName,
|
||||
r.officeName,
|
||||
r.productName,
|
||||
r.approvedShares,
|
||||
r.unitPrice,
|
||||
r.shareValue,
|
||||
r.trancheCount,
|
||||
this.statusText(r.statusEnum),
|
||||
r.activatedDate || '—'
|
||||
])
|
||||
}
|
||||
],
|
||||
'BOR-Register'
|
||||
);
|
||||
} finally {
|
||||
this.exporting = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable, map } from 'rxjs';
|
||||
|
||||
/** Custom Helpers */
|
||||
import { toIsoDate } from '../core/utils/api-date';
|
||||
|
||||
/** One share account and the board resolution that authorised it. */
|
||||
export interface BorRegisterRow {
|
||||
shareAccountId: number;
|
||||
accountNo: string;
|
||||
clientId: number;
|
||||
cifNumber: string;
|
||||
clientName: string;
|
||||
officeId: number | null;
|
||||
officeName: string;
|
||||
productName: string;
|
||||
currencyCode: string;
|
||||
statusEnum: number;
|
||||
approvedShares: number;
|
||||
pendingShares: number;
|
||||
unitPrice: number;
|
||||
shareValue: number;
|
||||
resolutionNumber: string;
|
||||
/** ISO date, or '' where no meeting date was ever recorded against the allotment. */
|
||||
resolutionDate: string;
|
||||
/** How many purchase tranches make up the holding. */
|
||||
trancheCount: number;
|
||||
submittedDate: string;
|
||||
approvedDate: string;
|
||||
activatedDate: string;
|
||||
closedDate: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The BOR register.
|
||||
*
|
||||
* One call returns every account with its resolution. The share allotment report next door fetches
|
||||
* each account separately and stops at two hundred, which is a limit on a register rather than on a
|
||||
* page of one.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class BorReportService {
|
||||
private http = inject(HttpClient);
|
||||
|
||||
getRegister(status?: number, officeId?: number): Observable<BorRegisterRow[]> {
|
||||
let params = new HttpParams();
|
||||
if (status != null) {
|
||||
params = params.set('status', String(status));
|
||||
}
|
||||
if (officeId != null) {
|
||||
params = params.set('officeId', String(officeId));
|
||||
}
|
||||
return this.http
|
||||
.get<any[]>('/shareaccounts/bor-register', { params })
|
||||
.pipe(map((rows) => (rows || []).map((r) => this.toRow(r))));
|
||||
}
|
||||
|
||||
/** Dates arrive as [y, M, d] arrays; flattened to ISO so they sort and filter as strings. */
|
||||
private toRow(r: any): BorRegisterRow {
|
||||
return {
|
||||
shareAccountId: r.shareAccountId,
|
||||
accountNo: r.accountNo || '',
|
||||
clientId: r.clientId,
|
||||
cifNumber: r.cifNumber || '',
|
||||
clientName: r.clientName || '',
|
||||
officeId: r.officeId ?? null,
|
||||
officeName: r.officeName || '',
|
||||
productName: r.productName || '',
|
||||
currencyCode: r.currencyCode || 'INR',
|
||||
statusEnum: Number(r.statusEnum) || 0,
|
||||
approvedShares: Number(r.approvedShares) || 0,
|
||||
pendingShares: Number(r.pendingShares) || 0,
|
||||
unitPrice: Number(r.unitPrice) || 0,
|
||||
shareValue: Number(r.shareValue) || 0,
|
||||
resolutionNumber: (r.resolutionNumber || '').trim(),
|
||||
resolutionDate: toIsoDate(r.resolutionDate),
|
||||
trancheCount: Number(r.trancheCount) || 0,
|
||||
submittedDate: toIsoDate(r.submittedDate),
|
||||
approvedDate: toIsoDate(r.approvedDate),
|
||||
activatedDate: toIsoDate(r.activatedDate),
|
||||
closedDate: toIsoDate(r.closedDate)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1142,6 +1142,46 @@
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Risk profile. These are determinations about the customer rather than about anything they
|
||||
transacted, and the re-rating policy reads them directly: a PEP or a sanctions match holds the
|
||||
rating at High however ordinary the period's activity looks, and the declared income is what
|
||||
turnover is measured against. Until they are recorded the policy has nothing to test. -->
|
||||
<div class="full-span kyc-doc-fields">
|
||||
<div class="kyc-doc-fields__head">
|
||||
<h4 class="kyc-doc-fields__title">Risk Profile</h4>
|
||||
<span class="kyc-doc-fields__hint">Used by the periodic risk review</span>
|
||||
</div>
|
||||
|
||||
<div class="kyc-risk-flags">
|
||||
<mat-checkbox formControlName="isPep">
|
||||
Politically Exposed Person
|
||||
<span class="kyc-risk-hint">Holds the rating at High; never lowered on ordinary activity</span>
|
||||
</mat-checkbox>
|
||||
<mat-checkbox formControlName="sanctionsMatch">
|
||||
Sanctions / watch-list match
|
||||
<span class="kyc-risk-hint">UNSC, MHA or internal watch list</span>
|
||||
</mat-checkbox>
|
||||
<mat-checkbox formControlName="adverseMediaMatch">
|
||||
Adverse media / regulatory action
|
||||
<span class="kyc-risk-hint">FIR, court proceedings or published action</span>
|
||||
</mat-checkbox>
|
||||
</div>
|
||||
|
||||
<div class="kyc-doc-fields__grid">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Declared annual income (₹)</mat-label>
|
||||
<input matInput type="number" min="0" formControlName="declaredAnnualIncome" />
|
||||
<mat-hint>As stated by the customer</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Declared annual turnover (₹)</mat-label>
|
||||
<input matInput type="number" min="0" formControlName="declaredAnnualTurnover" />
|
||||
<mat-hint>For a business customer</mat-hint>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Officially Valid Document. Shown when one is on file or the alert is asking for
|
||||
it, so re-collecting an expired document is done on this form rather than
|
||||
sending the officer somewhere else. -->
|
||||
|
||||
@@ -1807,3 +1807,28 @@
|
||||
color: #15803d;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Risk profile ────────────────────────────────────────────────────────────
|
||||
Determinations about the customer, which the periodic risk review reads directly. Grouped with
|
||||
the document block so the form reads as "what we know about them" rather than scattering a PEP
|
||||
flag among the identifier fields. */
|
||||
|
||||
.kyc-doc-fields__hint {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.kyc-risk-flags {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 4px 0 14px;
|
||||
}
|
||||
|
||||
/* Sits under its checkbox label: the consequence of ticking the box matters more than the box. */
|
||||
.kyc-risk-hint {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@@ -140,6 +140,13 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
1,
|
||||
[Validators.required]
|
||||
],
|
||||
// Risk profile. Determinations about the customer, which the periodic risk review reads directly - a PEP or a
|
||||
// sanctions match holds the rating at High whatever the period's activity looks like.
|
||||
isPep: [false],
|
||||
sanctionsMatch: [false],
|
||||
adverseMediaMatch: [false],
|
||||
declaredAnnualIncome: [null],
|
||||
declaredAnnualTurnover: [null],
|
||||
// The officially valid document, so a lapsed one can be re-collected on this form. The
|
||||
// backend has always accepted these four; only the fields were missing.
|
||||
kycDocumentName: [''],
|
||||
@@ -221,6 +228,11 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
this.kycForm.patchValue({
|
||||
kycType: data.kycType || 1,
|
||||
riskCategory: data.riskCategory || 1,
|
||||
isPep: !!data.isPep,
|
||||
sanctionsMatch: !!data.sanctionsMatch,
|
||||
adverseMediaMatch: !!data.adverseMediaMatch,
|
||||
declaredAnnualIncome: data.declaredAnnualIncome ?? null,
|
||||
declaredAnnualTurnover: data.declaredAnnualTurnover ?? null,
|
||||
panNumber: data.panNumber || '',
|
||||
// Aadhaar is stored masked on the server; user must re-enter
|
||||
// the full 12-digit number to re-verify, so don't pre-fill.
|
||||
@@ -446,6 +458,28 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
return new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* The customer's risk profile, as every save has to carry it.
|
||||
*
|
||||
* <p>
|
||||
* Sent from every path that writes the KYC record rather than only the main Save, because the form holds one set of
|
||||
* values: a save triggered from the PAN declaration or the Aadhaar receipt would otherwise write the record without
|
||||
* these and silently clear a PEP flag the officer had just ticked.
|
||||
*/
|
||||
private buildRiskProfileFields() {
|
||||
const v = this.kycForm.value;
|
||||
return {
|
||||
isPep: !!v.isPep,
|
||||
sanctionsMatch: !!v.sanctionsMatch,
|
||||
adverseMediaMatch: !!v.adverseMediaMatch,
|
||||
// Blank stays null rather than becoming zero: an income nobody asked for is unknown, and a zero would read as a
|
||||
// customer who declared no income at all.
|
||||
declaredAnnualIncome: v.declaredAnnualIncome === '' || v.declaredAnnualIncome == null ? null : v.declaredAnnualIncome,
|
||||
declaredAnnualTurnover:
|
||||
v.declaredAnnualTurnover === '' || v.declaredAnnualTurnover == null ? null : v.declaredAnnualTurnover
|
||||
};
|
||||
}
|
||||
|
||||
private buildExceptionFields() {
|
||||
const panVerified = this.verificationStatus.pan === 'VERIFIED' || this.kycData?.panVerified === true;
|
||||
const aadhaarVerified = this.verificationStatus.aadhaar === 'VERIFIED' || this.kycData?.aadhaarVerified === true;
|
||||
@@ -484,7 +518,8 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
// dateFormat is required so the backend can parse exceptionResolvedOn when an exception closes.
|
||||
dateFormat: 'dd MMMM yyyy',
|
||||
...this.buildDocumentFields(),
|
||||
...this.buildExceptionFields()
|
||||
...this.buildRiskProfileFields(),
|
||||
...this.buildExceptionFields()
|
||||
};
|
||||
|
||||
if (!this.kycId) {
|
||||
@@ -530,6 +565,7 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
remarks: this.kycForm.value.remarks || null,
|
||||
riskCategory: this.kycForm.value.riskCategory || 1,
|
||||
dateFormat: 'dd MMMM yyyy',
|
||||
...this.buildRiskProfileFields(),
|
||||
...this.buildExceptionFields()
|
||||
};
|
||||
|
||||
@@ -729,7 +765,8 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
dateFormat,
|
||||
riskCategory: this.kycForm.value.riskCategory || 1,
|
||||
...declarationFields,
|
||||
...this.buildExceptionFields()
|
||||
...this.buildRiskProfileFields(),
|
||||
...this.buildExceptionFields()
|
||||
};
|
||||
|
||||
this.clientsService
|
||||
@@ -889,7 +926,8 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
dateFormat,
|
||||
riskCategory: this.kycForm.value.riskCategory || 1,
|
||||
...receiptFields,
|
||||
...this.buildExceptionFields()
|
||||
...this.buildRiskProfileFields(),
|
||||
...this.buildExceptionFields()
|
||||
};
|
||||
|
||||
this.clientsService
|
||||
|
||||
@@ -310,41 +310,6 @@
|
||||
</nav>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- Live KPI ticker items — referenced twice below for seamless marquee loop -->
|
||||
<ng-template #kpiTickerItems>
|
||||
<span class="ti"><span class="ti-k">CASH</span><span class="ti-v amber">₹14,82,500</span></span>
|
||||
<span class="ti-d">│</span>
|
||||
<span class="ti">
|
||||
<span class="ti-k">TXN TODAY</span>
|
||||
<span class="ti-v">1,247</span>
|
||||
<span class="ti-v up">↑ 8.2%</span>
|
||||
</span>
|
||||
<span class="ti-d">│</span>
|
||||
<span class="ti"><span class="ti-k">CLEARING IN</span><span class="ti-v">42</span></span>
|
||||
<span class="ti-d">│</span>
|
||||
<span class="ti"><span class="ti-k">CLEARING OUT</span><span class="ti-v">38</span></span>
|
||||
<span class="ti-d">│</span>
|
||||
<span class="ti">
|
||||
<span class="ti-k">NPA·GROSS</span>
|
||||
<span class="ti-v amber">3.47%</span>
|
||||
<span class="ti-v down">↑ 0.12</span>
|
||||
</span>
|
||||
<span class="ti-d">│</span>
|
||||
<span class="ti"><span class="ti-k">CASA</span><span class="ti-v">58.2%</span></span>
|
||||
<span class="ti-d">│</span>
|
||||
<span class="ti"><span class="ti-k">CD RATIO</span><span class="ti-v">71.8%</span></span>
|
||||
<span class="ti-d">│</span>
|
||||
<span class="ti">
|
||||
<span class="ti-k">RBI CIRC</span>
|
||||
<span class="ti-v">DBR.AML.NO.4/14.01.001</span>
|
||||
<span class="ti-v amber">NEW</span>
|
||||
</span>
|
||||
<span class="ti-d">│</span>
|
||||
<span class="ti"><span class="ti-k">REPO</span><span class="ti-v">6.50%</span></span>
|
||||
<span class="ti-d">│</span>
|
||||
</ng-template>
|
||||
|
||||
<!-- ============================================================
|
||||
Main Header — 2-Row Navigation
|
||||
============================================================
|
||||
@@ -623,18 +588,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- LIVE KPI TICKER (below the menu — pauses on hover) -->
|
||||
<div class="kpi-ticker" role="marquee" aria-label="Live key-performance ticker">
|
||||
<span class="kpi-live">
|
||||
<span class="kpi-live-dot"></span>
|
||||
LIVE
|
||||
</span>
|
||||
<div class="kpi-track">
|
||||
<!-- Render the item block twice so the marquee loops seamlessly. -->
|
||||
<ng-container *ngTemplateOutlet="kpiTickerItems"></ng-container>
|
||||
<ng-container *ngTemplateOutlet="kpiTickerItems"></ng-container>
|
||||
</div>
|
||||
</div>
|
||||
<!-- The KPI ticker that sat here has been removed. Every figure in it was written into the template — cash on
|
||||
hand, transactions today, CASA, CD ratio, gross NPA, a circular reference — and none came from the bank's
|
||||
own books. Under a "LIVE" badge on every screen in the application, that is not decoration: a figure a
|
||||
branch manager can read as the day's cash position, or an inspector as the NPA ratio, has to be the real
|
||||
one or must not be shown. The band returns when there is a source behind it. -->
|
||||
}
|
||||
<!-- end @if (!hideShellLayout) -->
|
||||
|
||||
@@ -713,6 +671,9 @@
|
||||
<button mat-menu-item routerLink="/reports/tds-remittance" tabindex="0">
|
||||
<i class="fa fa-landmark menu-item-icon"></i> TDS Remitted to Government
|
||||
</button>
|
||||
<button mat-menu-item routerLink="/reports/bor" tabindex="0">
|
||||
<i class="fa fa-gavel menu-item-icon"></i> BOR Register — Share Capital
|
||||
</button>
|
||||
</mat-menu>
|
||||
|
||||
<!-- Admin Mega Menu — 3-column rectangular panel grouping Setup,
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
@@ -25,8 +26,7 @@ import {
|
||||
KycIndividual,
|
||||
PoFinding,
|
||||
StrStatus,
|
||||
StrTransaction,
|
||||
STR_TXNS
|
||||
StrTransaction
|
||||
} from './fiu-data';
|
||||
|
||||
/** A FINnet file rendered as a grid — the same rows that go into the CSV. */
|
||||
@@ -130,6 +130,7 @@ export class FiuComponent implements OnInit {
|
||||
private amlService = inject(AmlService);
|
||||
private sanctionsLogsService = inject(SanctionsLogsService);
|
||||
private authenticationService = inject(AuthenticationService);
|
||||
private route = inject(ActivatedRoute);
|
||||
/** Active tab */
|
||||
activeTab: 'dashboard' | 'ctr' | 'str' | 'customers' | 'files' = 'dashboard';
|
||||
|
||||
@@ -154,7 +155,16 @@ export class FiuComponent implements OnInit {
|
||||
* is honestly empty rather than populated with rows that cannot be filed.
|
||||
*/
|
||||
tc1Txns: CardTransaction[] = [];
|
||||
strTxns: StrTransaction[] = STR_TXNS;
|
||||
/**
|
||||
* The STR transaction file.
|
||||
*
|
||||
* Empty, for the same reason TC1 and the customer cards are. The four rows that sat here named customers this bank
|
||||
* has never onboarded, against account numbers it does not hold, for sums it never received — on the screen an
|
||||
* officer files suspicious-transaction reports from. A filing built on invented rows is not a draft; it is a false
|
||||
* report to FIU-IND. The live alerts the engine has actually escalated are listed on this tab already, and the
|
||||
* file fills from those when the STR export is wired to them.
|
||||
*/
|
||||
strTxns: StrTransaction[] = [];
|
||||
/**
|
||||
* KC1 / KC2 — the customer cards that travel with the CTR bundle.
|
||||
*
|
||||
@@ -398,6 +408,14 @@ export class FiuComponent implements OnInit {
|
||||
this.isHeadOffice = this.authenticationService.isHeadOfficeUser();
|
||||
this.currentOfficeName = creds?.officeName || 'Branch Office';
|
||||
|
||||
// Opened on a particular tab when another screen sends the officer here for a reason - the Risk
|
||||
// Review Report links a case that is with the Principal Officer straight to the STR queue, so
|
||||
// landing on the dashboard would make them find it themselves.
|
||||
const wantedTab = this.route.snapshot.queryParamMap.get('tab');
|
||||
if (wantedTab === 'str' || wantedTab === 'ctr' || wantedTab === 'customers' || wantedTab === 'files') {
|
||||
this.activeTab = wantedTab;
|
||||
}
|
||||
|
||||
// The header named a hard-coded officer and a hard-coded month — "Karthik Kamath" and
|
||||
// "January 2026" — on a screen whose whole purpose is to say which month is being filed and
|
||||
// by whom. Both now come from the session and the clock.
|
||||
|
||||
+309
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, Inject, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormBuilder, FormArray, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
|
||||
import { AmlService } from 'app/aml/aml.service';
|
||||
|
||||
export interface AmlBulkReviewData {
|
||||
alerts: any[];
|
||||
disposition: 'WHITELIST' | 'ESCALATE';
|
||||
}
|
||||
|
||||
/**
|
||||
* One disposition applied to several alerts at once.
|
||||
*
|
||||
* <p>
|
||||
* Low and medium-severity alerts arrive as runs of the same thing - a customer whose ordinary cash
|
||||
* deposits each cross a threshold - and clearing them one dialog at a time is what keeps an officer
|
||||
* from reaching the alert that matters. The reason and evidence are asked once and written against
|
||||
* every alert in the batch, so each still carries its own record of why it was disposed of.
|
||||
*
|
||||
* <p>
|
||||
* High-severity alerts never reach here: the caller will not tick them. An alert in the high band is
|
||||
* a judgement about that particular transaction and gets its own decision.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-aml-bulk-review-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
MatDialogModule,
|
||||
MatButtonModule,
|
||||
MatCheckboxModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule
|
||||
],
|
||||
template: `
|
||||
<h2 mat-dialog-title>
|
||||
{{ isWhitelist ? 'Whitelist' : 'Escalate' }} {{ data.alerts.length }} Alert{{
|
||||
data.alerts.length === 1 ? '' : 's'
|
||||
}}
|
||||
</h2>
|
||||
<mat-dialog-content>
|
||||
<p class="bk-intro">
|
||||
The same decision, reason and evidence are recorded against each alert below. Every one stays
|
||||
individually auditable.
|
||||
</p>
|
||||
|
||||
<div class="bk-list">
|
||||
<div class="bk-row" *ngFor="let a of data.alerts">
|
||||
<span class="bk-txn">{{ a.transactionType }} #{{ a.transactionId }}</span>
|
||||
<span class="bk-client">{{ a.clientDisplayName }}</span>
|
||||
<span class="bk-rule">{{ a.ruleCode }}</span>
|
||||
<span class="bk-amt">₹{{ a.amount | number: '1.0-0' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form [formGroup]="form">
|
||||
<!-- Clearing an alert has to rest on something. Escalation does not: the officer is saying
|
||||
it needs investigating, which is the opposite of claiming it is explained. -->
|
||||
<div class="bk-evidence" *ngIf="isWhitelist">
|
||||
<div class="bk-label">Evidence relied on</div>
|
||||
<div formArrayName="evidence" class="bk-checks">
|
||||
<mat-checkbox *ngFor="let t of evidenceTypes; let i = index" [formControlName]="i">{{ t }}</mat-checkbox>
|
||||
</div>
|
||||
<p class="bk-err" *ngIf="evidenceMissing">Tick at least one piece of evidence</p>
|
||||
|
||||
<mat-form-field appearance="outline" class="bk-full">
|
||||
<mat-label>Reference (optional)</mat-label>
|
||||
<input matInput formControlName="evidenceRef" maxlength="100" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<mat-form-field appearance="outline" class="bk-full">
|
||||
<mat-label>{{ isWhitelist ? 'Evidence detail' : 'What makes these suspicious?' }}</mat-label>
|
||||
<textarea matInput formControlName="notes" rows="3" maxlength="800"></textarea>
|
||||
<mat-error *ngIf="form.get('notes')?.hasError('required')">A reason is required</mat-error>
|
||||
</mat-form-field>
|
||||
</form>
|
||||
|
||||
<p class="bk-effect" [class.bk-effect--warn]="!isWhitelist">
|
||||
{{
|
||||
isWhitelist
|
||||
? 'Cleared as false positives — no report is filed, and the evidence is retained against each alert.'
|
||||
: 'Each alert opens its own STR case for the Principal Officer.'
|
||||
}}
|
||||
</p>
|
||||
|
||||
<p class="bk-err" *ngIf="errorMessage">{{ errorMessage }}</p>
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-stroked-button type="button" (click)="dialogRef.close(false)">Cancel</button>
|
||||
<button
|
||||
mat-flat-button
|
||||
[color]="isWhitelist ? 'primary' : 'warn'"
|
||||
type="button"
|
||||
[disabled]="form.invalid || submitting"
|
||||
(click)="submit()"
|
||||
>
|
||||
{{ submitting ? 'Applying…' : isWhitelist ? 'Whitelist All' : 'Escalate All' }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.bk-intro {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
margin: 0 0 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.bk-list {
|
||||
max-height: 190px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
.bk-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1.1fr 1.4fr 1.6fr 0.9fr;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
border-bottom: 1px solid #eef2f7;
|
||||
}
|
||||
.bk-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.bk-txn {
|
||||
font-family: 'DM Mono', monospace;
|
||||
color: #334155;
|
||||
}
|
||||
.bk-client {
|
||||
color: #1e293b;
|
||||
}
|
||||
.bk-rule {
|
||||
color: #7c3aed;
|
||||
font-size: 11px;
|
||||
}
|
||||
.bk-amt {
|
||||
text-align: right;
|
||||
font-family: 'DM Mono', monospace;
|
||||
color: #1e293b;
|
||||
}
|
||||
.bk-label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #64748b;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.bk-checks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.bk-full {
|
||||
width: 100%;
|
||||
}
|
||||
.bk-effect {
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
color: #15803d;
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
.bk-effect--warn {
|
||||
color: #b91c1c;
|
||||
background: #fef2f2;
|
||||
border-color: #fecaca;
|
||||
}
|
||||
.bk-err {
|
||||
margin: 10px 0 0;
|
||||
color: #b91c1c;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
`
|
||||
]
|
||||
})
|
||||
export class AmlBulkReviewDialogComponent {
|
||||
private fb = inject(FormBuilder);
|
||||
private amlService = inject(AmlService);
|
||||
dialogRef = inject(MatDialogRef<AmlBulkReviewDialogComponent>);
|
||||
|
||||
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'
|
||||
];
|
||||
|
||||
form: FormGroup;
|
||||
submitting = false;
|
||||
errorMessage = '';
|
||||
|
||||
constructor(@Inject(MAT_DIALOG_DATA) public data: AmlBulkReviewData) {
|
||||
this.form = this.fb.group({
|
||||
evidence: this.fb.array(this.evidenceTypes.map(() => false)),
|
||||
evidenceRef: [
|
||||
'',
|
||||
Validators.maxLength(100)
|
||||
],
|
||||
notes: [
|
||||
'',
|
||||
[
|
||||
Validators.required,
|
||||
Validators.maxLength(800)
|
||||
]
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
get isWhitelist(): boolean {
|
||||
return this.data.disposition === 'WHITELIST';
|
||||
}
|
||||
|
||||
get evidenceArray(): FormArray {
|
||||
return this.form.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.touched && this.selectedEvidence.length === 0;
|
||||
}
|
||||
|
||||
/** Exactly what is written to each alert, so the record reads the same as a single review. */
|
||||
private composedNotes(): string {
|
||||
const v = this.form.value;
|
||||
const detail = (v.notes || '').trim();
|
||||
if (!this.isWhitelist) {
|
||||
return detail ? `Grounds: ${detail}` : '';
|
||||
}
|
||||
const parts: string[] = [];
|
||||
if (this.selectedEvidence.length) {
|
||||
parts.push(`Evidence: ${this.selectedEvidence.join('; ')}`);
|
||||
}
|
||||
if (v.evidenceRef) {
|
||||
parts.push(`Ref: ${(v.evidenceRef || '').trim()}`);
|
||||
}
|
||||
const head = parts.join(' | ');
|
||||
const composed = head && detail ? `${head} — ${detail}` : head || detail;
|
||||
// Batch note, so each alert says it was disposed of alongside others rather than on its own.
|
||||
return `${composed} [batch of ${this.data.alerts.length}]`.slice(0, 1000);
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
this.evidenceArray.markAllAsTouched();
|
||||
if (this.form.invalid || (this.isWhitelist && this.selectedEvidence.length === 0)) {
|
||||
this.form.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
this.submitting = true;
|
||||
this.errorMessage = '';
|
||||
const notes = this.composedNotes();
|
||||
|
||||
// Each alert is reviewed on its own call: one that fails - already disposed of by someone else,
|
||||
// say - must not take the rest of the batch with it. Failures are counted and reported.
|
||||
forkJoin(
|
||||
this.data.alerts.map((a) =>
|
||||
this.amlService
|
||||
.reviewAlert(a.id, this.data.disposition, notes)
|
||||
.pipe(catchError(() => of({ __failed: true, id: a.id })))
|
||||
)
|
||||
).subscribe((results: any[]) => {
|
||||
this.submitting = false;
|
||||
const failed = results.filter((r) => r?.__failed).length;
|
||||
if (failed === results.length) {
|
||||
this.errorMessage = 'None of the alerts could be updated. They may already have been reviewed.';
|
||||
return;
|
||||
}
|
||||
if (failed > 0) {
|
||||
this.errorMessage = `${results.length - failed} updated, ${failed} could not be — those may already have been reviewed.`;
|
||||
// Still closes as done: most of the batch landed, and the list behind must refresh.
|
||||
setTimeout(() => this.dialogRef.close(true), 2200);
|
||||
return;
|
||||
}
|
||||
this.dialogRef.close(true);
|
||||
});
|
||||
}
|
||||
}
|
||||
+258
-14
@@ -60,23 +60,113 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Severity mix of what the table is showing -->
|
||||
<!-- Severity mix of what the table is showing. Each tile narrows the table to its own band;
|
||||
clicking the one already in force clears it, as does Total Flagged. -->
|
||||
<div class="risk-summary">
|
||||
<div *ngFor="let tier of riskSummary" class="risk-tile" [ngClass]="tier.cls">
|
||||
<button
|
||||
*ngFor="let tier of riskSummary"
|
||||
type="button"
|
||||
class="risk-tile"
|
||||
[ngClass]="tier.cls"
|
||||
[class.tile-active]="isSeverityActive(tier.key)"
|
||||
[disabled]="tier.count === 0 && !isSeverityActive(tier.key)"
|
||||
[matTooltip]="isSeverityActive(tier.key) ? 'Show every severity again' : 'Show only ' + tier.label + ' alerts'"
|
||||
(click)="setSeverity(tier.key)"
|
||||
>
|
||||
<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">
|
||||
</button>
|
||||
<button
|
||||
*ngIf="unclassifiedCount > 0"
|
||||
type="button"
|
||||
class="risk-tile risk-none"
|
||||
[class.tile-active]="isSeverityActive(0)"
|
||||
matTooltip="Raised by a user, not by a catalog rule"
|
||||
(click)="setSeverity(0)"
|
||||
>
|
||||
<span class="risk-tile-n">{{ unclassifiedCount }}</span>
|
||||
<span class="risk-tile-l">Unclassified</span>
|
||||
</div>
|
||||
<div class="risk-tile risk-total">
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="risk-tile risk-total"
|
||||
[class.tile-active]="activeSeverity === null"
|
||||
matTooltip="Show every severity"
|
||||
(click)="clearSeverity()"
|
||||
>
|
||||
<span class="risk-tile-n">{{ summaryTotal }}</span>
|
||||
<span class="risk-tile-l">Total Flagged</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<table mat-table [dataSource]="alerts" class="flagged-table mat-elevation-z1">
|
||||
<!-- Appears only once something is ticked, so the table does not carry a permanently empty bar. -->
|
||||
@if (selectedCount > 0) {
|
||||
<div class="bulk-bar">
|
||||
<span class="bulk-count">{{ selectedCount }} selected</span>
|
||||
<span class="bulk-note">Low / medium only — a transaction that tripped a high-severity rule is decided on its own.</span>
|
||||
<span class="bulk-spacer"></span>
|
||||
<button mat-stroked-button type="button" (click)="selectedIds.clear()">Clear</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
color="primary"
|
||||
type="button"
|
||||
*mifosxHasPermission="'REVIEW_AML_ALERT'"
|
||||
(click)="reviewSelected('WHITELIST')"
|
||||
>
|
||||
Whitelist {{ selectedCount }}
|
||||
</button>
|
||||
<button
|
||||
mat-flat-button
|
||||
color="warn"
|
||||
type="button"
|
||||
*mifosxHasPermission="'REVIEW_AML_ALERT'"
|
||||
(click)="reviewSelected('ESCALATE')"
|
||||
>
|
||||
Escalate {{ selectedCount }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- multiTemplateDataRows: each alert renders through both row definitions — the alert row and its
|
||||
detail row beneath it. Without it MatTable renders one row per alert, the two definitions both
|
||||
claim it, and the table throws rather than rendering anything. All three tabs share one tab
|
||||
group, so that error blanked Repeat Customers and Rules Master as well. -->
|
||||
<table mat-table [dataSource]="alerts" multiTemplateDataRows class="flagged-table mat-elevation-z1">
|
||||
<!-- Ticked for a batch decision. Disabled where the transaction tripped a high-severity rule,
|
||||
where nothing it matched carried a severity (the CTR rules), or where it is already decided. -->
|
||||
<ng-container matColumnDef="select">
|
||||
<th mat-header-cell *matHeaderCellDef class="select-cell">
|
||||
<!-- Hidden outright when nothing here can be batched — filtered to high severity, say. -->
|
||||
@if (selectableRows.length) {
|
||||
<mat-checkbox
|
||||
[checked]="allSelectableSelected"
|
||||
(change)="toggleSelectAll()"
|
||||
matTooltip="Select every low / medium alert on this page"
|
||||
></mat-checkbox>
|
||||
}
|
||||
</th>
|
||||
<td mat-cell *matCellDef="let alert" class="select-cell" (click)="$event.stopPropagation()">
|
||||
<!-- No checkbox at all where the transaction cannot be part of a batch. A disabled box still
|
||||
reads as "this could be ticked", and on a high-severity row that is the wrong thing to
|
||||
suggest: the decision is never a batch one. A dash says the column does not apply. -->
|
||||
@if (isSelectable(alert)) {
|
||||
<mat-checkbox [checked]="isSelected(alert)" (change)="toggleSelected(alert)"></mat-checkbox>
|
||||
} @else {
|
||||
<span
|
||||
class="select-na"
|
||||
[matTooltip]="
|
||||
isHighSeverity(alert)
|
||||
? 'High severity — this transaction is decided on its own'
|
||||
: !canReview(alert)
|
||||
? 'Already decided'
|
||||
: 'A CTR is a statutory return, not a graded suspicion — decided on its own'
|
||||
"
|
||||
>—</span
|
||||
>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- When the engine raised the alert -->
|
||||
<ng-container matColumnDef="raisedOn">
|
||||
<th mat-header-cell *matHeaderCellDef>Raised On</th>
|
||||
@@ -113,6 +203,142 @@
|
||||
<div class="stacked-cell">
|
||||
<span class="rule-code">{{ alert.ruleCode }}</span>
|
||||
<span class="cell-sub">{{ alert.ruleDescription }}</span>
|
||||
<!-- The transaction is one case under one rule, but an analyst still needs to see
|
||||
everything it tripped — a deposit can be both reportable and on a new account. The
|
||||
detail row carries each one with its own severity; this is only the count. -->
|
||||
@if (matchedRuleCount(alert) > 1) {
|
||||
<button type="button" class="rule-more" (click)="toggleExpanded(alert); $event.stopPropagation()">
|
||||
<mat-icon>{{ isExpanded(alert) ? 'expand_less' : 'expand_more' }}</mat-icon>
|
||||
{{ matchedRuleCount(alert) }} rules matched
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- The expanded pane. One cell spanning the table, collapsed to zero height until its row is
|
||||
opened, so the detail sits directly under the row it belongs to. -->
|
||||
<ng-container matColumnDef="expandedDetail">
|
||||
<!-- No header cell: this column never appears in the header row, only in its own data row. -->
|
||||
<td mat-cell *matCellDef="let alert" [attr.colspan]="displayedColumns.length" class="detail-cell">
|
||||
<!-- The pane is inside a row that toggles on click, so anything interactive in here must keep its own
|
||||
clicks from reaching it — otherwise ticking a rule closes the pane. -->
|
||||
<div class="detail-pane" [class.detail-open]="isExpanded(alert)" (click)="$event.stopPropagation()">
|
||||
@if (isExpanded(alert)) {
|
||||
<div class="detail-inner">
|
||||
<div class="detail-head">
|
||||
<span>Rules matched by {{ alert.transactionType | lowercase }} #{{ alert.transactionId }} — {{ amount(alert) }}</span>
|
||||
@if (selectableRulesOf(alert).length > 1) {
|
||||
<button type="button" class="rule-select-all" (click)="toggleAllRules(alert)">
|
||||
{{ allRulesSelected(alert) ? 'Clear all' : 'Select all open' }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<!-- Governing rule first: it is the one the alert was raised under, and the one the
|
||||
disposition is recorded against. -->
|
||||
@for (rule of matchedRules(alert); track rule.ruleCode) {
|
||||
<div
|
||||
class="detail-rule"
|
||||
[class.detail-rule-governing]="rule.governing"
|
||||
[class.detail-rule-decided]="rule.disposition"
|
||||
>
|
||||
<!-- One checkbox per rule: a transaction can trip six rules where five are explained by the
|
||||
customer's business and the sixth is not, and those are different decisions. A rule already
|
||||
decided shows its verdict instead. -->
|
||||
@if (isRuleSelectable(rule)) {
|
||||
<mat-checkbox
|
||||
[checked]="isRuleSelected(rule)"
|
||||
(change)="toggleRuleSelected(rule)"
|
||||
*mifosxHasPermission="'REVIEW_AML_ALERT'"
|
||||
></mat-checkbox>
|
||||
} @else if (!isGradedRule(rule)) {
|
||||
<!-- A statutory return, not a graded suspicion: filed on the amount whatever the officer decides
|
||||
about the transaction, so there is nothing here to tick. -->
|
||||
<mat-icon
|
||||
class="rule-decided-icon decided-statutory"
|
||||
matTooltip="A statutory return — filed on the amount, whatever is decided about this transaction. Nothing to review here."
|
||||
matTooltipClass="evidence-tooltip"
|
||||
>
|
||||
gavel
|
||||
</mat-icon>
|
||||
} @else {
|
||||
<mat-icon
|
||||
class="rule-decided-icon"
|
||||
[class.decided-escalated]="rule.disposition === 'ESCALATE' && rule.directlyDecided"
|
||||
[class.decided-carried]="!rule.directlyDecided"
|
||||
[matTooltip]="
|
||||
(rule.directlyDecided ? ruleDispositionLabel(rule) : 'Closed with the case') +
|
||||
(rule.reviewedByUsername ? ' by ' + rule.reviewedByUsername : '') +
|
||||
(rule.reviewNotes ? ' — ' + rule.reviewNotes : '')
|
||||
"
|
||||
matTooltipClass="evidence-tooltip"
|
||||
>
|
||||
{{ !rule.directlyDecided ? 'remove_circle_outline' : rule.disposition === 'ESCALATE' ? 'report' : 'verified' }}
|
||||
</mat-icon>
|
||||
}
|
||||
<span class="sev-chip" [ngClass]="severityClass(rule.riskCategory)">{{ severityLabel(rule.riskCategory) }}</span>
|
||||
<div class="detail-rule-text">
|
||||
<span class="detail-rule-code">
|
||||
{{ rule.ruleCode }}
|
||||
@if (rule.governing) {
|
||||
<span class="governing-tag" matTooltip="The alert is raised and decided under this rule">governing</span>
|
||||
}
|
||||
<!-- Says where a statutory rule actually goes, so the absence of a checkbox does not read as the
|
||||
transaction being unreported. -->
|
||||
@if (!isGradedRule(rule)) {
|
||||
<span class="ctr-tag" matTooltip="Goes to the monthly CTR filing for FIU-IND, not to an STR">
|
||||
→ CTR filing
|
||||
</span>
|
||||
}
|
||||
<!-- The rule the officer named reads as their decision; a rule closed alongside it says so,
|
||||
because an audit of the finding turns on which one they put their name to. -->
|
||||
@if (rule.disposition) {
|
||||
<span
|
||||
class="rule-verdict"
|
||||
[class.verdict-escalated]="rule.disposition === 'ESCALATE'"
|
||||
[class.verdict-carried]="!rule.directlyDecided"
|
||||
[matTooltip]="
|
||||
rule.directlyDecided
|
||||
? ruleDispositionLabel(rule) +
|
||||
(rule.reviewedByUsername ? ' by ' + rule.reviewedByUsername : '')
|
||||
: 'Closed with the transaction when another rule was marked suspicious — not decided on its own'
|
||||
"
|
||||
>
|
||||
{{ rule.directlyDecided ? ruleDispositionLabel(rule) : 'Closed with case' }}
|
||||
</span>
|
||||
}
|
||||
</span>
|
||||
<span class="detail-rule-desc">{{ rule.ruleDescription }}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Acts on the ticked rules only. The alert's own status follows from what is left undecided, so
|
||||
clearing five of six rules leaves the case open on the sixth. -->
|
||||
@if (selectedRuleCount > 0) {
|
||||
<div class="rule-action-bar" *mifosxHasPermission="'REVIEW_AML_ALERT'">
|
||||
<span class="rule-action-count">{{ selectedRuleCount }} rule{{ selectedRuleCount > 1 ? 's' : '' }} selected</span>
|
||||
<span class="bulk-spacer"></span>
|
||||
<button mat-stroked-button type="button" (click)="selectedRuleCodes.clear()">Clear</button>
|
||||
<button mat-stroked-button color="primary" type="button" (click)="reviewSelectedRules(alert, 'WHITELIST')">
|
||||
Whitelist
|
||||
</button>
|
||||
<button mat-flat-button color="warn" type="button" (click)="reviewSelectedRules(alert, 'ESCALATE')">
|
||||
Suspicious
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
<div class="detail-foot">
|
||||
@if (isHighSeverity(alert)) {
|
||||
A high-severity rule fired, so this transaction is decided on its own rather than in a batch.
|
||||
} @else if (!alert.highestSeverity) {
|
||||
A statutory return rather than a graded suspicion — decided on its own.
|
||||
} @else {
|
||||
Every rule here is low or medium, so this transaction can be decided together with others.
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
@@ -132,7 +358,7 @@
|
||||
<!-- 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">
|
||||
<td mat-cell *matCellDef="let alert" class="col-center actions-cell" (click)="$event.stopPropagation()">
|
||||
<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. -->
|
||||
@@ -180,15 +406,33 @@
|
||||
</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>
|
||||
<!-- Clicking anywhere on the row opens its detail. The checkbox and the action buttons stop the
|
||||
event themselves, so ticking a row or disposing of it does not also expand it. -->
|
||||
<tr
|
||||
mat-row
|
||||
*matRowDef="let row; columns: displayedColumns"
|
||||
class="alert-row"
|
||||
[class.row-new]="isNew(row)"
|
||||
[class.row-expanded]="isExpanded(row)"
|
||||
(click)="toggleExpanded(row)"
|
||||
></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: ['expandedDetail']" class="detail-row"></tr>
|
||||
</table>
|
||||
|
||||
<mat-paginator [pageSizeOptions]="[10, 25, 50, 100]" showFirstLastButtons class="flagged-paginator"></mat-paginator>
|
||||
<!-- pageSize stated explicitly: without it the paginator starts at 0, and anything that touches the
|
||||
paginator before the data source has bound to it leaves that 0 in force, which pages every row
|
||||
out of the table. -->
|
||||
<mat-paginator
|
||||
[pageSize]="10"
|
||||
[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"
|
||||
<mat-icon>{{ activeStatus || activeSeverity !== null ? 'filter_alt_off' : 'verified_user' }}</mat-icon>
|
||||
<span *ngIf="!activeStatus && activeSeverity === null"
|
||||
>No transactions flagged. The feed updates automatically as the engine raises alerts.</span
|
||||
>
|
||||
<span *ngIf="activeStatus">No alerts in this status.</span>
|
||||
<span *ngIf="activeStatus || activeSeverity !== null">No alerts match the filters in force.</span>
|
||||
</div>
|
||||
|
||||
+366
-1
@@ -196,15 +196,43 @@
|
||||
padding: 0 0 16px;
|
||||
}
|
||||
|
||||
/* A button, not a div: each tile narrows the table to its own severity band. */
|
||||
.risk-tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-left-width: 4px;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
box-shadow 0.15s,
|
||||
transform 0.1s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
box-shadow: 0 2px 8px rgb(15 23 42 / 12%);
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
/* A band with nothing in it would filter to an empty table. */
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
/* The band currently narrowing the table. The ring reads on every tile colour, where a
|
||||
background change would fight the severity tint each tile already carries. */
|
||||
.risk-tile.tile-active {
|
||||
box-shadow: 0 0 0 2px #1a237e;
|
||||
}
|
||||
|
||||
.risk-tile-n {
|
||||
@@ -293,8 +321,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
.alert-row:hover {
|
||||
.alert-row {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
}
|
||||
|
||||
/* The open row and its pane read as one block. */
|
||||
.alert-row.row-expanded {
|
||||
background: #f8fafc;
|
||||
|
||||
td {
|
||||
border-bottom-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
/* A newly arrived alert stays tinted until acknowledged. */
|
||||
@@ -380,6 +421,285 @@
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Quieter than the governing rule: it is context on one case, not a second case. */
|
||||
.rule-also {
|
||||
margin-top: 3px;
|
||||
color: #92400e;
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fde68a;
|
||||
border-radius: 4px;
|
||||
padding: 1px 6px;
|
||||
font-size: 11px;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
/* Says how many rules fired and opens the detail. Replaces the block of codes that used to sit in
|
||||
this cell: the codes belong in the pane, where each can carry its own severity. */
|
||||
.rule-more {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
margin-top: 4px;
|
||||
padding: 2px 8px 2px 4px;
|
||||
align-self: flex-start;
|
||||
border: 1px solid #fde68a;
|
||||
border-radius: 999px;
|
||||
background: #fffbeb;
|
||||
color: #92400e;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
|
||||
mat-icon {
|
||||
font-size: 15px;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #fef3c7;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Expanded detail ─────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.detail-row {
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.detail-cell {
|
||||
padding: 0 !important;
|
||||
border-bottom: none !important;
|
||||
}
|
||||
|
||||
/* Animating max-height rather than height: the pane's height depends on how many rules fired, and
|
||||
a transition needs a figure to move between. */
|
||||
.detail-pane {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.2s ease;
|
||||
background: #f8fafc;
|
||||
|
||||
/* Generous enough for a transaction that tripped every rule it could, plus the action bar. Too low
|
||||
silently clips the last rule, which is the one an officer most needs to see. */
|
||||
&.detail-open {
|
||||
max-height: 1200px;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-inner {
|
||||
padding: 14px 16px 16px 60px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.detail-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #64748b;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.rule-select-all {
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: #1a237e;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
/* A rule that already carries a verdict is context, not a choice. */
|
||||
.detail-rule-decided {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.rule-decided-icon {
|
||||
flex: none;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: #15803d;
|
||||
cursor: help;
|
||||
|
||||
&.decided-escalated {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
/* Closed alongside another rule rather than judged on its own — stated, but not as a verdict. */
|
||||
&.decided-carried {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
/* A statutory return: not the officer's decision at all. */
|
||||
&.decided-statutory {
|
||||
color: #475569;
|
||||
}
|
||||
}
|
||||
|
||||
/* Where a statutory rule goes, said next to the rule rather than left to be inferred from a
|
||||
missing checkbox. */
|
||||
.ctr-tag {
|
||||
margin-left: 6px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
background: #e2e8f0;
|
||||
color: #334155;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rule-verdict {
|
||||
margin-left: 6px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
|
||||
&.verdict-escalated {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
/* Wins over the disposition colours above: this rule was not itself the finding, and colouring it
|
||||
like one would claim the officer condemned it. */
|
||||
&.verdict-carried {
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
/* Acts on the ticked rules only, and sits with them rather than at the top of the table. */
|
||||
.rule-action-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 8px;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.rule-action-count {
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
color: #1e3a8a;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.detail-rule {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 7px 10px;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
|
||||
& + & {
|
||||
margin-top: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
/* The rule the alert was raised under, and the one the disposition is recorded against. */
|
||||
.detail-rule-governing {
|
||||
border-color: #cbd5e1;
|
||||
box-shadow: inset 3px 0 0 #1a237e;
|
||||
}
|
||||
|
||||
.detail-rule-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.detail-rule-code {
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.detail-rule-desc {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.governing-tag {
|
||||
margin-left: 6px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
background: #e0e7ff;
|
||||
color: #3730a3;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Severity of one rule inside the pane. Same colours as the summary tiles, so a band reads the
|
||||
same wherever it appears. */
|
||||
.sev-chip {
|
||||
flex: none;
|
||||
min-width: 62px;
|
||||
text-align: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
|
||||
&.sev-high {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
&.sev-medium {
|
||||
background: #fef3c7;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
&.sev-low {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
&.sev-none {
|
||||
background: #e2e8f0;
|
||||
color: #475569;
|
||||
}
|
||||
}
|
||||
|
||||
/* Why this transaction can or cannot be decided in a batch, said where the officer is deciding. */
|
||||
.detail-foot {
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.rule-code {
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -537,3 +857,48 @@
|
||||
background: #fff;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
/* Batch disposition bar — only rendered while rows are ticked. */
|
||||
.bulk-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 16px;
|
||||
margin-bottom: 10px;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 10px;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.bulk-count {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #1e3a8a;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Says why some rows cannot be ticked, where the officer is looking rather than in a tooltip. */
|
||||
.bulk-note {
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.bulk-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.select-cell {
|
||||
width: 44px;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
/* Stands in for the checkbox on a row that cannot be batched, so the column still reads as a
|
||||
column rather than leaving a hole the eye has to account for. */
|
||||
.select-na {
|
||||
display: inline-block;
|
||||
width: 18px;
|
||||
text-align: center;
|
||||
color: #cbd5e1;
|
||||
cursor: help;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
+419
-19
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component, OnInit, OnDestroy, inject, ViewChild } from '@angular/core';
|
||||
import { Component, OnInit, AfterViewInit, 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';
|
||||
@@ -15,6 +15,7 @@ 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 { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Subject, timer, of } from 'rxjs';
|
||||
import { switchMap, takeUntil, catchError, filter } from 'rxjs/operators';
|
||||
@@ -24,6 +25,7 @@ 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';
|
||||
import { AmlBulkReviewDialogComponent } from './aml-bulk-review-dialog.component';
|
||||
|
||||
/**
|
||||
* Live feed of transactions the AML rule engine has flagged.
|
||||
@@ -48,10 +50,11 @@ import { AmlReviewAlertDialogComponent } from './aml-review-alert-dialog.compone
|
||||
MatTooltipModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatDialogModule,
|
||||
MatCheckboxModule,
|
||||
HasPermissionDirective
|
||||
]
|
||||
})
|
||||
export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy {
|
||||
export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
private amlService = inject(AmlService);
|
||||
private dialog = inject(MatDialog);
|
||||
private alertService = inject(AlertService);
|
||||
@@ -62,6 +65,7 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy {
|
||||
|
||||
alerts = new MatTableDataSource<any>([]);
|
||||
displayedColumns: string[] = [
|
||||
'select',
|
||||
'raisedOn',
|
||||
'transaction',
|
||||
'client',
|
||||
@@ -71,6 +75,283 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy {
|
||||
'actions'
|
||||
];
|
||||
|
||||
/**
|
||||
* Alerts ticked for a single disposition across several transactions.
|
||||
*
|
||||
* <p>
|
||||
* Low and medium-severity alerts arrive in numbers and are mostly the same decision repeated -
|
||||
* a run of ordinary cash deposits that each tripped a threshold. Dealing with them one dialog at
|
||||
* a time is what stops an officer getting to the one alert that matters. High severity is
|
||||
* deliberately excluded: an alert that reached the high band is a judgement about that customer's
|
||||
* transaction, and it gets its own decision.
|
||||
*/
|
||||
selectedIds = new Set<number>();
|
||||
|
||||
/** m_client_risk_rule.risk_category — 3 is the high band. */
|
||||
private static readonly HIGH_SEVERITY = 3;
|
||||
|
||||
/**
|
||||
* Whether any rule this transaction matched reached the high band.
|
||||
*
|
||||
* <p>
|
||||
* Read from highestSeverity, not from the governing rule's own riskCategory. One transaction
|
||||
* raises one alert under whichever rule most warrants attention, and a statutory CTR outranks
|
||||
* everything — so a high-severity rule can sit in "also matched" while the row itself reports the
|
||||
* CTR, which carries no severity at all. Judging the row by the governing rule would offer such a
|
||||
* transaction up for a batch decision.
|
||||
*/
|
||||
isHighSeverity(alert: any): boolean {
|
||||
return Number(alert?.highestSeverity) === AmlFlaggedTransactionsComponent.HIGH_SEVERITY;
|
||||
}
|
||||
|
||||
/**
|
||||
* The band this alert counts as, for the summary tiles.
|
||||
*
|
||||
* <p>
|
||||
* The same figure the checkbox is gated on, so the tiles and the table cannot disagree. Reading
|
||||
* riskCategory here put a ₹12,00,000 cash deposit that had tripped a high-severity rule under
|
||||
* "Unclassified", because the alert reports under the statutory CTR and a CTR carries no band of
|
||||
* its own. Alerts raised before highest_severity existed fall back to the governing rule's band.
|
||||
*/
|
||||
private severityBand(alert: any): number {
|
||||
const worst = Number(alert?.highestSeverity);
|
||||
if (worst === 1 || worst === 2 || worst === 3) {
|
||||
return worst;
|
||||
}
|
||||
const governing = Number(alert?.riskCategory);
|
||||
if (governing === 1 || governing === 2 || governing === 3) {
|
||||
return governing;
|
||||
}
|
||||
// 0, never NaN: the Unclassified tile filters on this value, and NaN === NaN is false, so a
|
||||
// NaN band would leave that tile showing a count it could never select.
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Only an open alert whose every matched rule sits in the low or medium band can be swept up with
|
||||
* others.
|
||||
*
|
||||
* <p>
|
||||
* Two things are held back. A transaction that tripped any high-severity rule, because that is a
|
||||
* judgement about the transaction itself. And one where no matched rule carried a severity at
|
||||
* all, which is how the CTR rules arrive - they are not graded, being returns the bank owes
|
||||
* rather than suspicions. Neither belongs in a batch disposed of on a single reason.
|
||||
*/
|
||||
isSelectable(alert: any): boolean {
|
||||
const band = Number(alert?.highestSeverity);
|
||||
const graded = band === 1 || band === 2;
|
||||
return this.canReview(alert) && graded;
|
||||
}
|
||||
|
||||
/** The alert whose detail row is open. One at a time: this is a reading pane, not a comparison. */
|
||||
expandedId: number | null = null;
|
||||
|
||||
|
||||
toggleExpanded(alert: any): void {
|
||||
this.expandedId = this.expandedId === alert.id ? null : alert.id;
|
||||
// Ticks belong to the alert they were made on, not to whichever pane is open next.
|
||||
this.selectedRuleCodes.clear();
|
||||
}
|
||||
|
||||
isExpanded(alert: any): boolean {
|
||||
return this.expandedId === alert.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every rule the transaction matched, governing one first.
|
||||
*
|
||||
* <p>
|
||||
* Served by the API. The fallback rebuilds the list from the flat fields for an alert read before the API carried
|
||||
* it, so an older row still expands to something rather than appearing to have matched nothing.
|
||||
*/
|
||||
matchedRules(alert: any): { ruleCode: string; ruleDescription: string; riskCategory: number | null; governing: boolean }[] {
|
||||
if (Array.isArray(alert?.matchedRules) && alert.matchedRules.length) {
|
||||
return alert.matchedRules;
|
||||
}
|
||||
const rules = [
|
||||
{
|
||||
ruleCode: alert?.ruleCode,
|
||||
ruleDescription: alert?.ruleDescription,
|
||||
riskCategory: alert?.riskCategory ?? null,
|
||||
governing: true
|
||||
}
|
||||
];
|
||||
for (const code of String(alert?.alsoMatchedRules || '')
|
||||
.split(',')
|
||||
.map((c: string) => c.trim())
|
||||
.filter((c: string) => c)) {
|
||||
rules.push({ ruleCode: code, ruleDescription: code, riskCategory: null, governing: false });
|
||||
}
|
||||
return rules;
|
||||
}
|
||||
|
||||
/** How many rules the transaction matched, for the count shown on the collapsed row. */
|
||||
matchedRuleCount(alert: any): number {
|
||||
return this.matchedRules(alert).length;
|
||||
}
|
||||
|
||||
severityLabel(band: number | null | undefined): string {
|
||||
switch (Number(band)) {
|
||||
case 1:
|
||||
return 'Low';
|
||||
case 2:
|
||||
return 'Medium';
|
||||
case 3:
|
||||
return 'High';
|
||||
default:
|
||||
return 'Statutory';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rule codes ticked inside the open detail pane.
|
||||
*
|
||||
* <p>
|
||||
* Cleared whenever a different alert is opened: the codes are only meaningful against the alert they were ticked on,
|
||||
* and carrying them across would apply one transaction's selection to another's rules.
|
||||
*/
|
||||
selectedRuleCodes = new Set<string>();
|
||||
|
||||
isRuleSelected(rule: any): boolean {
|
||||
return this.selectedRuleCodes.has(rule.ruleCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this rule is the officer's to decide.
|
||||
*
|
||||
* <p>
|
||||
* A rule already decided keeps its verdict. A rule with no severity band is not a graded suspicion at all - the CTR
|
||||
* rules are statutory returns the bank owes FIU-IND on any cash transaction at or above ten lakh, filed on the
|
||||
* amount whether or not the customer is above suspicion. There is no "whitelist" for one: clearing the transaction
|
||||
* does not stop the CTR, and escalating it does not describe what the rule found.
|
||||
*/
|
||||
isRuleSelectable(rule: any): boolean {
|
||||
return !rule?.disposition && this.isGradedRule(rule);
|
||||
}
|
||||
|
||||
/** A rule that carries a severity band, as against a statutory reporting rule that carries none. */
|
||||
isGradedRule(rule: any): boolean {
|
||||
const band = Number(rule?.riskCategory);
|
||||
return band === 1 || band === 2 || band === 3;
|
||||
}
|
||||
|
||||
toggleRuleSelected(rule: any): void {
|
||||
if (!this.isRuleSelectable(rule)) {
|
||||
return;
|
||||
}
|
||||
if (this.selectedRuleCodes.has(rule.ruleCode)) {
|
||||
this.selectedRuleCodes.delete(rule.ruleCode);
|
||||
} else {
|
||||
this.selectedRuleCodes.add(rule.ruleCode);
|
||||
}
|
||||
}
|
||||
|
||||
selectableRulesOf(alert: any): any[] {
|
||||
return this.matchedRules(alert).filter((r) => this.isRuleSelectable(r));
|
||||
}
|
||||
|
||||
get selectedRuleCount(): number {
|
||||
return this.selectedRuleCodes.size;
|
||||
}
|
||||
|
||||
allRulesSelected(alert: any): boolean {
|
||||
const open = this.selectableRulesOf(alert);
|
||||
return open.length > 0 && open.every((r) => this.selectedRuleCodes.has(r.ruleCode));
|
||||
}
|
||||
|
||||
toggleAllRules(alert: any): void {
|
||||
if (this.allRulesSelected(alert)) {
|
||||
this.selectedRuleCodes.clear();
|
||||
return;
|
||||
}
|
||||
for (const rule of this.selectableRulesOf(alert)) {
|
||||
this.selectedRuleCodes.add(rule.ruleCode);
|
||||
}
|
||||
}
|
||||
|
||||
ruleDispositionLabel(rule: any): string {
|
||||
return rule?.disposition === 'ESCALATE' ? 'Escalated' : rule?.disposition === 'WHITELIST' ? 'Whitelisted' : '';
|
||||
}
|
||||
|
||||
severityClass(band: number | null | undefined): string {
|
||||
switch (Number(band)) {
|
||||
case 1:
|
||||
return 'sev-low';
|
||||
case 2:
|
||||
return 'sev-medium';
|
||||
case 3:
|
||||
return 'sev-high';
|
||||
default:
|
||||
return 'sev-none';
|
||||
}
|
||||
}
|
||||
|
||||
isSelected(alert: any): boolean {
|
||||
return this.selectedIds.has(alert.id);
|
||||
}
|
||||
|
||||
toggleSelected(alert: any): void {
|
||||
if (!this.isSelectable(alert)) {
|
||||
return;
|
||||
}
|
||||
if (this.selectedIds.has(alert.id)) {
|
||||
this.selectedIds.delete(alert.id);
|
||||
} else {
|
||||
this.selectedIds.add(alert.id);
|
||||
}
|
||||
}
|
||||
|
||||
/** The rows a "select all" would act on: what is on screen, open, and below the high band. */
|
||||
get selectableRows(): any[] {
|
||||
return this.alerts.data.filter((a) => this.isSelectable(a));
|
||||
}
|
||||
|
||||
get allSelectableSelected(): boolean {
|
||||
const rows = this.selectableRows;
|
||||
return rows.length > 0 && rows.every((a) => this.selectedIds.has(a.id));
|
||||
}
|
||||
|
||||
toggleSelectAll(): void {
|
||||
if (this.allSelectableSelected) {
|
||||
this.selectedIds.clear();
|
||||
return;
|
||||
}
|
||||
for (const a of this.selectableRows) {
|
||||
this.selectedIds.add(a.id);
|
||||
}
|
||||
}
|
||||
|
||||
get selectedCount(): number {
|
||||
return this.selectedIds.size;
|
||||
}
|
||||
|
||||
private selectedAlerts(): any[] {
|
||||
return this.alerts.data.filter((a) => this.selectedIds.has(a.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops ticks that no longer stand, after every poll.
|
||||
*
|
||||
* <p>
|
||||
* The feed refreshes itself every few seconds while the officer works. Without this, an alert
|
||||
* another officer disposed of in the meantime — or one the status filter has since hidden — stays
|
||||
* in the set, and the bar goes on offering to decide it: the count reads higher than the ticks on
|
||||
* screen, and the batch carries alerts nobody chose.
|
||||
*/
|
||||
private pruneSelection(): void {
|
||||
const stillSelectable = new Set(this.selectableRows.map((a) => a.id));
|
||||
for (const id of Array.from(this.selectedIds)) {
|
||||
if (!stillSelectable.has(id)) {
|
||||
this.selectedIds.delete(id);
|
||||
}
|
||||
}
|
||||
// Same for the open detail pane: an alert filtered away or disposed of elsewhere should not
|
||||
// leave the table holding a pane for a row that is no longer on it.
|
||||
if (this.expandedId !== null && !this.alerts.data.some((a) => a.id === this.expandedId)) {
|
||||
this.expandedId = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Every alert as returned, before the status filter narrows the table. */
|
||||
private allAlerts: any[] = [];
|
||||
|
||||
@@ -84,12 +365,19 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy {
|
||||
activeStatus = '';
|
||||
private statusCounts: Record<string, number> = {};
|
||||
|
||||
/**
|
||||
* The severity tile currently narrowing the table: 1 Low, 2 Medium, 3 High, 0 unclassified, or
|
||||
* null for all. Independent of {@link activeStatus} — the two compose, so an officer can ask for
|
||||
* the high-severity alerts that are still open.
|
||||
*/
|
||||
activeSeverity: number | null = null;
|
||||
|
||||
/**
|
||||
* 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 }[] = [];
|
||||
riskSummary: { key: number; label: string; count: number; cls: string }[] = [];
|
||||
/** Alerts whose rule has no catalog ancestor (MANUAL_REFERRAL), so no severity to report. */
|
||||
unclassifiedCount = 0;
|
||||
|
||||
@@ -106,6 +394,18 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy {
|
||||
|
||||
@ViewChild(MatPaginator) paginator!: MatPaginator;
|
||||
|
||||
/**
|
||||
* Binds the paginator as soon as the view exists.
|
||||
*
|
||||
* <p>
|
||||
* The first poll can return before the view has initialised, and until this the data source was
|
||||
* only ever wired up as a side effect of a later filter pass — so the table could render every
|
||||
* alert unpaginated, then jump once the next poll arrived.
|
||||
*/
|
||||
ngAfterViewInit(): void {
|
||||
this.alerts.paginator = this.paginator;
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.fetch();
|
||||
timer(AmlFlaggedTransactionsComponent.POLL_INTERVAL_MS, AmlFlaggedTransactionsComponent.POLL_INTERVAL_MS)
|
||||
@@ -158,7 +458,22 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy {
|
||||
|
||||
setStatus(key: string): void {
|
||||
this.activeStatus = key;
|
||||
this.applyStatusFilter();
|
||||
this.applyStatusFilter(true);
|
||||
}
|
||||
|
||||
/** Clicking the tile already in force clears it, so the same click both narrows and undoes. */
|
||||
setSeverity(band: number): void {
|
||||
this.activeSeverity = this.activeSeverity === band ? null : band;
|
||||
this.applyStatusFilter(true);
|
||||
}
|
||||
|
||||
isSeverityActive(band: number): boolean {
|
||||
return this.activeSeverity === band;
|
||||
}
|
||||
|
||||
clearSeverity(): void {
|
||||
this.activeSeverity = null;
|
||||
this.applyStatusFilter(true);
|
||||
}
|
||||
|
||||
statusCount(key: string): number {
|
||||
@@ -203,47 +518,63 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy {
|
||||
this.allAlerts = list;
|
||||
this.recomputeStatusCounts();
|
||||
this.applyStatusFilter();
|
||||
this.pruneSelection();
|
||||
this.lastUpdated = new Date();
|
||||
}
|
||||
|
||||
private applyStatusFilter(): void {
|
||||
this.alerts.data = this.activeStatus
|
||||
/**
|
||||
* @param resetPage
|
||||
* true only when the user changed a filter. A poll must not reset the page: the feed
|
||||
* refreshes every few seconds, and an officer reading page 3 would be thrown back to
|
||||
* page 1 each time. Nor may it touch the paginator before the data source has bound to
|
||||
* it — doing that on the first pass left pageSize at 0 and emptied the table.
|
||||
*/
|
||||
private applyStatusFilter(resetPage = false): void {
|
||||
const byStatus = this.activeStatus
|
||||
? this.allAlerts.filter((a) => a.status === this.activeStatus)
|
||||
: this.allAlerts;
|
||||
this.alerts.data =
|
||||
this.activeSeverity === null ? byStatus : byStatus.filter((a) => this.severityBand(a) === this.activeSeverity);
|
||||
if (!this.alerts.paginator && this.paginator) {
|
||||
this.alerts.paginator = this.paginator;
|
||||
}
|
||||
this.recomputeRiskSummary();
|
||||
if (resetPage && this.alerts.paginator) {
|
||||
// Otherwise a filter applied while on page 2 leaves the table blank on a one-page result.
|
||||
this.alerts.paginator.firstPage();
|
||||
}
|
||||
// Counted before the severity filter, so every tile keeps its real figure and stays clickable.
|
||||
// Counting the filtered rows would zero the other tiles the moment one was picked, leaving no
|
||||
// way back to them.
|
||||
this.recomputeRiskSummary(byStatus);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
private recomputeRiskSummary(pool: any[]): void {
|
||||
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) => ({
|
||||
key: t.key,
|
||||
label: t.label,
|
||||
cls: t.cls,
|
||||
count: pool.filter((a) => Number(a.riskCategory) === t.key).length
|
||||
count: pool.filter((a) => this.severityBand(a) === 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;
|
||||
// Band 0 is everything ungraded, so the tiles always reconcile to the total.
|
||||
this.unclassifiedCount = pool.filter((a) => this.severityBand(a) === 0).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the status filter leaves, whatever severity tile is in force — so the tiles still
|
||||
* add up to the total while one of them is narrowing the table, and the total reads as the way
|
||||
* back to all of them.
|
||||
*/
|
||||
get summaryTotal(): number {
|
||||
return this.alerts.data.length;
|
||||
return this.activeStatus ? this.allAlerts.filter((a) => a.status === this.activeStatus).length : this.allAlerts.length;
|
||||
}
|
||||
|
||||
private recomputeStatusCounts(): void {
|
||||
@@ -330,4 +661,73 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy {
|
||||
this.fetch();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies one disposition to every ticked alert.
|
||||
*
|
||||
* <p>
|
||||
* Each still goes through the review dialog's own requirements - a reason, and for a whitelist
|
||||
* the evidence behind it - asked once and recorded against every alert in the batch. That is the
|
||||
* point of the batch: the officer is making one decision about one kind of activity, not the same
|
||||
* decision twenty times.
|
||||
*/
|
||||
/**
|
||||
* Applies one disposition to the rules ticked inside the open pane.
|
||||
*
|
||||
* <p>
|
||||
* The alert's own status is left to the server, which derives it from what remains: whitelisting five of six rules
|
||||
* leaves the case open on the sixth rather than closing it.
|
||||
*/
|
||||
reviewSelectedRules(alert: any, disposition: 'WHITELIST' | 'ESCALATE'): void {
|
||||
const codes = Array.from(this.selectedRuleCodes);
|
||||
if (!codes.length) {
|
||||
return;
|
||||
}
|
||||
this.dialog
|
||||
.open(AmlReviewAlertDialogComponent, {
|
||||
width: '560px',
|
||||
maxWidth: '95vw',
|
||||
autoFocus: false,
|
||||
disableClose: true,
|
||||
data: { alert, disposition, ruleCodes: codes }
|
||||
})
|
||||
.afterClosed()
|
||||
.subscribe((result: string | undefined) => {
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
this.alertService.alert({
|
||||
type: disposition === 'WHITELIST' ? 'Rules Whitelisted' : 'Rules Escalated',
|
||||
message: `${codes.length} rule${codes.length > 1 ? 's' : ''} on ${alert.transactionType} #${alert.transactionId} ${
|
||||
disposition === 'WHITELIST' ? 'cleared' : 'escalated'
|
||||
}.`
|
||||
});
|
||||
this.selectedRuleCodes.clear();
|
||||
// The alert's status may have changed with the last rule decided, so re-read rather than patching locally.
|
||||
this.fetch();
|
||||
});
|
||||
}
|
||||
|
||||
reviewSelected(disposition: 'WHITELIST' | 'ESCALATE'): void {
|
||||
const batch = this.selectedAlerts();
|
||||
if (!batch.length) {
|
||||
return;
|
||||
}
|
||||
this.dialog
|
||||
.open(AmlBulkReviewDialogComponent, {
|
||||
width: '620px',
|
||||
maxWidth: '95vw',
|
||||
autoFocus: false,
|
||||
disableClose: true,
|
||||
data: { alerts: batch, disposition }
|
||||
})
|
||||
.afterClosed()
|
||||
.subscribe((done: boolean | undefined) => {
|
||||
if (!done) {
|
||||
return;
|
||||
}
|
||||
this.selectedIds.clear();
|
||||
this.fetch();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+18
-1
@@ -31,6 +31,11 @@ import { AmlService } from '../../../aml/aml.service';
|
||||
export interface AmlReviewDialogData {
|
||||
alert: any;
|
||||
disposition: 'WHITELIST' | 'ESCALATE';
|
||||
/**
|
||||
* The rules being decided, when the officer picked some inside the expanded pane rather than acting on the whole
|
||||
* transaction. Absent means the decision covers the alert and every rule under it.
|
||||
*/
|
||||
ruleCodes?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -162,6 +167,13 @@ export class AmlReviewAlertDialogComponent {
|
||||
}
|
||||
|
||||
get title(): string {
|
||||
// Named differently when only some of the transaction's rules are being decided, so the officer is not told they
|
||||
// are clearing a transaction when five of its six rules stay open.
|
||||
const n = this.data.ruleCodes?.length ?? 0;
|
||||
if (n > 0) {
|
||||
const subject = n === 1 ? 'Rule' : `${n} Rules`;
|
||||
return this.isWhitelist ? `Whitelist ${subject}` : `Mark ${subject} as Suspicious`;
|
||||
}
|
||||
return this.isWhitelist ? 'Whitelist Transaction' : 'Mark as Suspicious';
|
||||
}
|
||||
|
||||
@@ -181,7 +193,12 @@ export class AmlReviewAlertDialogComponent {
|
||||
|
||||
this.submitting = true;
|
||||
this.errorMessage = '';
|
||||
this.amlService.reviewAlert(this.data.alert.id, this.data.disposition, this.composedNotes).subscribe({
|
||||
// Deciding named rules within the alert rather than the whole alert, when the caller passed some. The evidence
|
||||
// requirements are the same either way, which is why this dialog serves both.
|
||||
const request = this.data.ruleCodes?.length
|
||||
? this.amlService.reviewAlertRules(this.data.alert.id, this.data.ruleCodes, this.data.disposition, this.composedNotes)
|
||||
: this.amlService.reviewAlert(this.data.alert.id, this.data.disposition, this.composedNotes);
|
||||
request.subscribe({
|
||||
next: () => {
|
||||
this.submitting = false;
|
||||
this.dialogRef.close(this.data.disposition);
|
||||
|
||||
@@ -93,6 +93,11 @@ const routes: Routes = [
|
||||
loadComponent: () =>
|
||||
import('../tds-reports/tds-remittance-report.component').then((m) => m.TdsRemittanceReportComponent)
|
||||
},
|
||||
{
|
||||
path: 'bor',
|
||||
data: { title: 'BOR Register — Share Capital', breadcrumb: 'BOR Register' },
|
||||
loadComponent: () => import('../board-resolution/bor-report.component').then((m) => m.BorReportComponent)
|
||||
},
|
||||
{
|
||||
path: ':filter',
|
||||
data: { routeParamBreadcrumb: 'filter' },
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, Inject, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MAT_DIALOG_DATA, MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
|
||||
import { AmlService } from 'app/aml/aml.service';
|
||||
|
||||
const CATEGORY_LABEL: Record<number, string> = { 1: 'Low', 2: 'Medium', 3: 'High' };
|
||||
|
||||
/**
|
||||
* The approval queue for risk-category changes raised from the Risk Review Report.
|
||||
*
|
||||
* <p>
|
||||
* Approving here moves the customer's KYC rating and their re-KYC due date together, so the row
|
||||
* shows where the rating is going from and to, and why the reviewer asked - the checker is deciding
|
||||
* how closely the bank watches that customer, not clearing a queue item.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-risk-change-approvals',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatButtonModule,
|
||||
MatDialogModule,
|
||||
MatSnackBarModule
|
||||
],
|
||||
template: `
|
||||
<div class="rca-root">
|
||||
<div class="rca-head">
|
||||
<div>
|
||||
<h2 class="rca-title">Risk Category Approvals</h2>
|
||||
<p class="rca-sub">
|
||||
Changes to a customer's KYC risk category, raised from the Risk Review Report. Approving moves the rating
|
||||
and the re-KYC due date together, and writes the reason into the customer's risk history.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rca-filters">
|
||||
<button
|
||||
*ngFor="let s of statuses"
|
||||
type="button"
|
||||
class="rca-chip"
|
||||
[class.rca-chip--on]="selectedStatus === s"
|
||||
(click)="filterBy(s)"
|
||||
>
|
||||
{{ s === '' ? 'All' : s }} <span class="rca-count">({{ countOf(s) }})</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="rca-table-wrap">
|
||||
<table class="rca-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Req. ID</th>
|
||||
<th>Customer</th>
|
||||
<th>CIF</th>
|
||||
<th>Branch</th>
|
||||
<th>Rating change</th>
|
||||
<th>Reason</th>
|
||||
<th>Status</th>
|
||||
<th>Requested By</th>
|
||||
<th>Requested On</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let r of filtered">
|
||||
<td class="mono">{{ r.id }}</td>
|
||||
<td>
|
||||
<strong>{{ r.clientName }}</strong>
|
||||
</td>
|
||||
<td class="mono">{{ r.cifNumber || '—' }}</td>
|
||||
<td>{{ r.officeName || '—' }}</td>
|
||||
<td>
|
||||
<span class="rca-move">
|
||||
{{ labelOf(r.currentCategory) }} <span class="rca-arrow">→</span>
|
||||
<strong>{{ labelOf(r.requestedCategory) }}</strong>
|
||||
</span>
|
||||
</td>
|
||||
<td class="rca-reason">{{ r.reason }}</td>
|
||||
<td>
|
||||
<span class="rca-status" [ngClass]="'rca-status--' + (r.status || '').toLowerCase()">{{ r.status }}</span>
|
||||
</td>
|
||||
<td>{{ r.makerUsername }}</td>
|
||||
<td class="mono">{{ r.createdDate }}</td>
|
||||
<td>
|
||||
<div class="rca-actions" *ngIf="r.status === 'PENDING'">
|
||||
<button mat-flat-button color="primary" type="button" (click)="approve(r)">Approve</button>
|
||||
<button mat-stroked-button color="warn" type="button" (click)="reject(r)">Reject</button>
|
||||
</div>
|
||||
<span class="rca-done" *ngIf="r.status === 'APPROVED'">
|
||||
by {{ r.checkerUsername }}, {{ r.checkerDate }}
|
||||
</span>
|
||||
<span class="rca-done" *ngIf="r.status === 'REJECTED'">{{ r.rejectionReason }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr *ngIf="!filtered.length">
|
||||
<td colspan="10" class="rca-empty">No risk category change matches this filter.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.rca-root {
|
||||
padding: 20px 24px 40px;
|
||||
background: #f8fafc;
|
||||
min-height: 100%;
|
||||
}
|
||||
.rca-head {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.rca-title {
|
||||
margin: 0 0 3px;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
}
|
||||
.rca-sub {
|
||||
margin: 0;
|
||||
max-width: 800px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: #64748b;
|
||||
}
|
||||
.rca-filters {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.rca-chip {
|
||||
height: 32px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
cursor: pointer;
|
||||
}
|
||||
.rca-chip--on {
|
||||
border-color: #1e3a8a;
|
||||
background: #eff6ff;
|
||||
color: #1e3a8a;
|
||||
}
|
||||
.rca-count {
|
||||
color: #94a3b8;
|
||||
font-weight: 500;
|
||||
}
|
||||
.rca-table-wrap {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 11px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.rca-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.rca-table th {
|
||||
text-align: left;
|
||||
padding: 10px;
|
||||
background: #f1f5f9;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #475569;
|
||||
white-space: nowrap;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
.rca-table td {
|
||||
padding: 11px 10px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
vertical-align: middle;
|
||||
color: #334155;
|
||||
}
|
||||
.mono {
|
||||
font-family: 'DM Mono', monospace;
|
||||
}
|
||||
.rca-move {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.rca-arrow {
|
||||
color: #94a3b8;
|
||||
margin: 0 4px;
|
||||
}
|
||||
.rca-reason {
|
||||
max-width: 280px;
|
||||
}
|
||||
.rca-status {
|
||||
display: inline-block;
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
}
|
||||
.rca-status--pending {
|
||||
background: #fef3c7;
|
||||
color: #b45309;
|
||||
}
|
||||
.rca-status--approved {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
.rca-status--rejected {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
.rca-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.rca-done {
|
||||
font-size: 11.5px;
|
||||
color: #64748b;
|
||||
}
|
||||
.rca-empty {
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
color: #94a3b8;
|
||||
}
|
||||
`
|
||||
]
|
||||
})
|
||||
export class RiskChangeApprovalsComponent implements OnInit {
|
||||
private amlService = inject(AmlService);
|
||||
private dialog = inject(MatDialog);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
|
||||
rows: any[] = [];
|
||||
readonly statuses = ['PENDING', 'APPROVED', 'REJECTED', ''];
|
||||
selectedStatus = 'PENDING';
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.amlService.getRiskChangeRequests().subscribe({
|
||||
next: (rows) => (this.rows = rows || []),
|
||||
error: (err: any) =>
|
||||
this.snackBar.open('Could not load requests: ' + (err?.error?.defaultUserMessage || err.message), 'Close', {
|
||||
duration: 5000
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
get filtered(): any[] {
|
||||
return this.selectedStatus ? this.rows.filter((r) => r.status === this.selectedStatus) : this.rows;
|
||||
}
|
||||
|
||||
countOf(status: string): number {
|
||||
return status ? this.rows.filter((r) => r.status === status).length : this.rows.length;
|
||||
}
|
||||
|
||||
filterBy(status: string): void {
|
||||
this.selectedStatus = status;
|
||||
}
|
||||
|
||||
labelOf(category: number | null): string {
|
||||
return category == null ? 'Unrated' : CATEGORY_LABEL[category] || '—';
|
||||
}
|
||||
|
||||
approve(r: any): void {
|
||||
this.amlService.approveRiskChange(r.id).subscribe({
|
||||
next: () => {
|
||||
this.snackBar.open(
|
||||
`${r.clientName} moved to ${this.labelOf(r.requestedCategory)} — re-KYC due date updated.`,
|
||||
'Close',
|
||||
{ duration: 6000 }
|
||||
);
|
||||
this.load();
|
||||
},
|
||||
error: (err: any) =>
|
||||
this.snackBar.open('Error: ' + (err?.error?.defaultUserMessage || err.message), 'Close', { duration: 7000 })
|
||||
});
|
||||
}
|
||||
|
||||
reject(r: any): void {
|
||||
this.dialog
|
||||
.open(RejectRiskChangeDialogComponent, { width: '450px', data: { request: r }, disableClose: true })
|
||||
.afterClosed()
|
||||
.subscribe((done) => {
|
||||
if (done) {
|
||||
this.load();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── REJECT DIALOG ──
|
||||
@Component({
|
||||
selector: 'mifosx-reject-risk-change-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
MatDialogModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatButtonModule
|
||||
],
|
||||
template: `
|
||||
<h2 mat-dialog-title>Reject Risk Category Change</h2>
|
||||
<mat-dialog-content>
|
||||
<p class="hint">
|
||||
{{ data.request.clientName }} stays on their current rating. The reason is shown to the reviewer who raised it.
|
||||
</p>
|
||||
<form [formGroup]="form">
|
||||
<mat-form-field appearance="outline" class="full">
|
||||
<mat-label>Rejection reason</mat-label>
|
||||
<textarea matInput formControlName="rejectionReason" rows="3"></textarea>
|
||||
<mat-error *ngIf="form.get('rejectionReason')?.hasError('required')">A reason is required</mat-error>
|
||||
</mat-form-field>
|
||||
</form>
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-stroked-button type="button" (click)="dialogRef.close(false)">Cancel</button>
|
||||
<button mat-flat-button color="warn" type="button" [disabled]="form.invalid || busy" (click)="submit()">
|
||||
Reject
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.full {
|
||||
width: 100%;
|
||||
}
|
||||
.hint {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
margin: 0 0 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
`
|
||||
]
|
||||
})
|
||||
export class RejectRiskChangeDialogComponent {
|
||||
private fb = inject(FormBuilder);
|
||||
private amlService = inject(AmlService);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
dialogRef = inject(MatDialogRef<RejectRiskChangeDialogComponent>);
|
||||
|
||||
form: FormGroup;
|
||||
busy = false;
|
||||
|
||||
constructor(@Inject(MAT_DIALOG_DATA) public data: { request: any }) {
|
||||
this.form = this.fb.group({ rejectionReason: ['', Validators.required] });
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (this.form.invalid) {
|
||||
return;
|
||||
}
|
||||
this.busy = true;
|
||||
this.amlService.rejectRiskChange(this.data.request.id, this.form.value.rejectionReason).subscribe({
|
||||
next: () => {
|
||||
this.busy = false;
|
||||
this.snackBar.open('Risk category change rejected', 'Close', { duration: 3000 });
|
||||
this.dialogRef.close(true);
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.busy = false;
|
||||
this.snackBar.open('Error: ' + (err?.error?.defaultUserMessage || err.message), 'Close', { duration: 6000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, Inject, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
|
||||
import { AmlService } from 'app/aml/aml.service';
|
||||
|
||||
export interface RiskChangeDialogData {
|
||||
clientId: number;
|
||||
customerName: string;
|
||||
/** 1 Low, 2 Medium, 3 High; null where the customer carries no rating yet. */
|
||||
currentCategory: number | null;
|
||||
/** What the report's own computation says the rating should be, used as the default. */
|
||||
recommendedCategory: number | null;
|
||||
/** The one-line explanation the report already shows against the row. */
|
||||
recommendationReason: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Raises a change to a customer's KYC risk category for approval.
|
||||
*
|
||||
* <p>
|
||||
* The rating governs how often the customer is re-KYC'd and what AML thresholds they are measured
|
||||
* against, so it is not something one officer moves on their own. The dialog collects what it
|
||||
* should become and why; a checker applies it.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-risk-change-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
MatDialogModule,
|
||||
MatButtonModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatSelectModule
|
||||
],
|
||||
template: `
|
||||
<h2 mat-dialog-title>Change Risk Category</h2>
|
||||
<mat-dialog-content>
|
||||
<div class="rc-summary">
|
||||
<div class="rc-row">
|
||||
<span class="rc-label">Customer</span>
|
||||
<strong class="rc-value">{{ data.customerName }}</strong>
|
||||
</div>
|
||||
<div class="rc-row">
|
||||
<span class="rc-label">Rating on file</span>
|
||||
<strong class="rc-value">{{ labelOf(data.currentCategory) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- The report already worked this out; showing it saves the reviewer re-deriving it, and
|
||||
says plainly that the figure is a recommendation rather than a decision. -->
|
||||
<p class="rc-reason" *ngIf="data.recommendationReason">
|
||||
<span class="rc-label">Why this came up</span><br />{{ data.recommendationReason }}
|
||||
</p>
|
||||
|
||||
<form [formGroup]="form" class="rc-form">
|
||||
<mat-form-field appearance="outline" class="rc-full">
|
||||
<mat-label>New risk category</mat-label>
|
||||
<mat-select formControlName="requestedCategory">
|
||||
<mat-option [value]="1">Low</mat-option>
|
||||
<mat-option [value]="2">Medium</mat-option>
|
||||
<mat-option [value]="3">High</mat-option>
|
||||
</mat-select>
|
||||
<mat-error *ngIf="form.get('requestedCategory')?.hasError('required')">Pick the new category</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="rc-full">
|
||||
<mat-label>Reason</mat-label>
|
||||
<textarea
|
||||
matInput
|
||||
formControlName="reason"
|
||||
rows="3"
|
||||
placeholder="e.g. Repeated large cash deposits inconsistent with declared income"
|
||||
></textarea>
|
||||
<mat-hint>Recorded against the customer's risk history</mat-hint>
|
||||
<mat-error *ngIf="form.get('reason')?.hasError('required')">A reason is required</mat-error>
|
||||
</mat-form-field>
|
||||
</form>
|
||||
|
||||
<p class="rc-effect">
|
||||
The rating does not move now. It goes to a checker, and on approval the customer's KYC record and re-KYC due
|
||||
date are updated together.
|
||||
</p>
|
||||
|
||||
<p class="rc-error" *ngIf="errorMessage">{{ errorMessage }}</p>
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-stroked-button type="button" (click)="dialogRef.close(false)">Cancel</button>
|
||||
<button mat-flat-button color="primary" type="button" [disabled]="form.invalid || submitting" (click)="submit()">
|
||||
{{ submitting ? 'Sending…' : 'Send for Approval' }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.rc-summary {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 14px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
.rc-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 16px;
|
||||
padding: 3px 0;
|
||||
}
|
||||
.rc-label {
|
||||
color: #64748b;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.rc-value {
|
||||
color: #1e293b;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
.rc-reason {
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
color: #334155;
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
.rc-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.rc-full {
|
||||
width: 100%;
|
||||
}
|
||||
.rc-effect {
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
color: #1e3a8a;
|
||||
background: #eff6ff;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
.rc-error {
|
||||
margin: 12px 0 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 6px;
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
`
|
||||
]
|
||||
})
|
||||
export class RiskChangeDialogComponent {
|
||||
private fb = inject(FormBuilder);
|
||||
private amlService = inject(AmlService);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
dialogRef = inject(MatDialogRef<RiskChangeDialogComponent>);
|
||||
|
||||
form: FormGroup;
|
||||
submitting = false;
|
||||
errorMessage = '';
|
||||
|
||||
constructor(@Inject(MAT_DIALOG_DATA) public data: RiskChangeDialogData) {
|
||||
this.form = this.fb.group({
|
||||
// Defaulted to what the report recommends, but left editable: the reviewer has seen the
|
||||
// customer and the computation has not.
|
||||
requestedCategory: [
|
||||
data.recommendedCategory ?? null,
|
||||
Validators.required
|
||||
],
|
||||
reason: [
|
||||
'',
|
||||
Validators.required
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
labelOf(category: number | null): string {
|
||||
return category === 1 ? 'Low' : category === 2 ? 'Medium' : category === 3 ? 'High' : 'Unrated';
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (this.form.invalid) {
|
||||
this.form.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
this.submitting = true;
|
||||
this.errorMessage = '';
|
||||
const v = this.form.value;
|
||||
this.amlService.requestRiskChange(this.data.clientId, v.requestedCategory, (v.reason || '').trim()).subscribe({
|
||||
next: () => {
|
||||
this.submitting = false;
|
||||
this.snackBar.open(
|
||||
`Risk change to ${this.labelOf(v.requestedCategory)} sent for approval — ${this.data.customerName}`,
|
||||
'Close',
|
||||
{ duration: 5000 }
|
||||
);
|
||||
this.dialogRef.close(true);
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.submitting = false;
|
||||
this.errorMessage = err?.error?.defaultUserMessage || err?.error?.message || 'Could not send the request.';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -372,13 +372,29 @@ export const MATERIALITY = {
|
||||
* Rules whose seriousness does not depend on the amount. A sanctions match or a
|
||||
* deliberate structuring pattern is grave at any value, so activity being small
|
||||
* is not a reason to bring the rating down.
|
||||
*
|
||||
* Matched as prefixes, not as whole codes. The rule set names these by variant —
|
||||
* STRUCTURING_BELOW_50K, STRUCTURING_BELOW_10L, PEP_ACCOUNT_MONITORING — so an
|
||||
* exact-match Set never matched any of them and the protection was dead: a
|
||||
* customer caught structuring could be recommended for downgrade purely because
|
||||
* the amounts involved were small, which is the one case structuring exists to
|
||||
* catch.
|
||||
*/
|
||||
export const NON_MITIGABLE_RULES = new Set([
|
||||
'SANCTIONS_HIT',
|
||||
export const NON_MITIGABLE_RULE_PREFIXES = [
|
||||
'SANCTIONS',
|
||||
'PEP',
|
||||
'STRUCTURING',
|
||||
'HIGH_RISK_COUNTRY'
|
||||
]);
|
||||
'STRUCTURED',
|
||||
'HIGH_RISK_COUNTRY',
|
||||
'ADVERSE_MEDIA',
|
||||
'WILFUL_DEFAULTER'
|
||||
];
|
||||
|
||||
/** Whether a rule code names activity that stays grave however small the amounts are. */
|
||||
export function isNonMitigableRule(ruleCode: string): boolean {
|
||||
const code = (ruleCode || '').toUpperCase();
|
||||
return NON_MITIGABLE_RULE_PREFIXES.some((prefix) => code.startsWith(prefix));
|
||||
}
|
||||
|
||||
/** Shape of one element of GET /aml/alerts. */
|
||||
export interface LiveAmlAlert {
|
||||
|
||||
@@ -304,7 +304,9 @@
|
||||
reason sits under it rather than under the button, because it explains
|
||||
this conclusion and not the act of opening the KYC screen. -->
|
||||
<td class="rr-move-cell">
|
||||
<span class="rr-move" [class]="'rr-move--' + c.direction">
|
||||
<!-- An unrated customer is its own state: no rating exists, so "same" would be claiming the
|
||||
rating on file matches the behaviour when there is no rating on file. -->
|
||||
<span class="rr-move" [class]="'rr-move--' + (c.currentRisk ? c.direction : 'unrated')">
|
||||
<fa-icon [icon]="directionIcon(c)"></fa-icon>
|
||||
{{ directionText(c) }}
|
||||
</span>
|
||||
@@ -323,15 +325,32 @@
|
||||
<!-- Only the control. The verdict and its reason moved to their own column,
|
||||
so this no longer wraps to two lines under the button. -->
|
||||
<td class="rr-action-cell">
|
||||
<button
|
||||
type="button"
|
||||
class="rr-kyc-link"
|
||||
[class.rr-kyc-link--act]="c.direction !== 'same' && !c.escalated"
|
||||
(click)="openKyc(c, $event)"
|
||||
[attr.aria-label]="kycActionText(c) + ' for ' + c.customerName"
|
||||
>
|
||||
<fa-icon icon="id-card"></fa-icon> {{ kycActionText(c) }}
|
||||
</button>
|
||||
<!-- Raises the change for approval rather than sending the reviewer to the
|
||||
KYC screen to make it themselves. A case with the Principal Officer is
|
||||
held where it is until the STR concludes, so it only offers the record. -->
|
||||
@if (canChangeRisk(c)) {
|
||||
<button
|
||||
type="button"
|
||||
class="rr-kyc-link"
|
||||
[class.rr-kyc-link--act]="c.direction !== 'same'"
|
||||
(click)="changeRisk(c, $event)"
|
||||
[attr.aria-label]="'Change risk category for ' + c.customerName"
|
||||
>
|
||||
<fa-icon icon="shield-alt"></fa-icon> Change Risk
|
||||
</button>
|
||||
} @else {
|
||||
<!-- The rating cannot move while the STR is open, so the case itself is
|
||||
what this row can usefully offer. -->
|
||||
<button
|
||||
type="button"
|
||||
class="rr-kyc-link"
|
||||
(click)="openStrCase(c, $event)"
|
||||
title="The rating is held until the Principal Officer concludes the STR — open the case"
|
||||
[attr.aria-label]="'Open the STR case for ' + c.customerName"
|
||||
>
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon> Open STR Case
|
||||
</button>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
@@ -937,6 +937,13 @@
|
||||
background: var(--r-g100);
|
||||
color: var(--r-g500);
|
||||
}
|
||||
|
||||
/* An unrated customer reads neither as settled nor as a movement: there is no rating to move. Amber says the row
|
||||
is outstanding without claiming the customer is a concern — the gap is the bank's, not theirs. */
|
||||
&--unrated {
|
||||
background: var(--r-amber-l, #fef3c7);
|
||||
color: var(--r-amber, #b45309);
|
||||
}
|
||||
}
|
||||
|
||||
.rr-move-why {
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { catchError, map } from 'rxjs/operators';
|
||||
|
||||
@@ -38,12 +39,14 @@ import {
|
||||
RiskLevel,
|
||||
channelLabel,
|
||||
mapLiveAlert,
|
||||
riskFromCategory
|
||||
riskFromCategory,
|
||||
riskRank
|
||||
} from './risk-review-data';
|
||||
import {
|
||||
DateRange,
|
||||
RatingDirection,
|
||||
RiskAlertView,
|
||||
BehaviourWindowByClient,
|
||||
RiskCustomerRow,
|
||||
RiskReviewFilter,
|
||||
RiskReviewModel,
|
||||
@@ -53,6 +56,7 @@ import {
|
||||
monthRange,
|
||||
weekRange
|
||||
} from './risk-review.compute';
|
||||
import { RiskChangeDialogComponent, RiskChangeDialogData } from './risk-change-dialog.component';
|
||||
|
||||
type PeriodMode = 'day' | 'week' | 'month' | 'range';
|
||||
type ViewMode = 'customer' | 'alert' | 'txn';
|
||||
@@ -109,6 +113,7 @@ export class RiskReviewReportComponent implements OnInit {
|
||||
private clients = inject(ClientsService);
|
||||
private scope = inject(BranchScopeService);
|
||||
private router = inject(Router);
|
||||
private dialog = inject(MatDialog);
|
||||
|
||||
readonly period = GL_PERIOD;
|
||||
readonly branches = GL_BRANCHES;
|
||||
@@ -181,21 +186,37 @@ export class RiskReviewReportComponent implements OnInit {
|
||||
|
||||
// ── Loading ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The nightly job's verdict per customer, keyed on client id.
|
||||
*
|
||||
* Empty until the first load, and stays empty on a tenant where the job has not run - the report then falls back to
|
||||
* its own per-alert reckoning rather than showing nothing.
|
||||
*/
|
||||
private behaviourWindows: BehaviourWindowByClient = {};
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.aml
|
||||
.getAlerts()
|
||||
.pipe(catchError(() => of([] as LiveAmlAlert[])))
|
||||
.subscribe((live) => {
|
||||
const rows = (live || []).map((a) => mapLiveAlert(a as unknown as LiveAmlAlert));
|
||||
this.liveAlerts = rows;
|
||||
this.compose();
|
||||
if (!rows.length) {
|
||||
this.loading = false;
|
||||
return;
|
||||
// Both in one pass: the windows decide what each row recommends, so composing before they arrive would show a
|
||||
// recommendation the screen then silently replaced.
|
||||
forkJoin({
|
||||
alerts: this.aml.getAlerts().pipe(catchError(() => of([] as LiveAmlAlert[]))),
|
||||
windows: this.aml.getBehaviourWindows().pipe(catchError(() => of([] as any[])))
|
||||
}).subscribe(({ alerts: live, windows }) => {
|
||||
this.behaviourWindows = {};
|
||||
for (const w of windows || []) {
|
||||
if (w?.clientId != null) {
|
||||
this.behaviourWindows[w.clientId] = w;
|
||||
}
|
||||
this.enrichCurrentRisk(rows);
|
||||
});
|
||||
}
|
||||
const rows = (live || []).map((a) => mapLiveAlert(a as unknown as LiveAmlAlert));
|
||||
this.liveAlerts = rows;
|
||||
this.compose();
|
||||
if (!rows.length) {
|
||||
this.loading = false;
|
||||
return;
|
||||
}
|
||||
this.enrichCurrentRisk(rows);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -336,9 +357,11 @@ export class RiskReviewReportComponent implements OnInit {
|
||||
private memo: RiskReviewModel = { alerts: [], customers: [], txns: [], summary: EMPTY_SUMMARY };
|
||||
|
||||
private get model(): RiskReviewModel {
|
||||
const key = JSON.stringify(this.filter) + `|${this.alerts.length}`;
|
||||
// The window count belongs in the key: the windows decide what each row recommends, and they arrive in the same
|
||||
// load as the alerts. Keying on the alerts alone would serve the memo computed before they landed.
|
||||
const key = JSON.stringify(this.filter) + `|${this.alerts.length}|${Object.keys(this.behaviourWindows).length}`;
|
||||
if (key !== this.memoKey) {
|
||||
this.memo = buildRiskReview(this.alerts, this.filter);
|
||||
this.memo = buildRiskReview(this.alerts, this.filter, this.behaviourWindows);
|
||||
this.memoKey = key;
|
||||
}
|
||||
return this.memo;
|
||||
@@ -364,10 +387,63 @@ export class RiskReviewReportComponent implements OnInit {
|
||||
* a regulatory clock, an under-rated customer is the real compliance gap, an over-rated one is
|
||||
* only a correction, and matching ratings need nothing at all.
|
||||
*/
|
||||
/**
|
||||
* A report row for a customer the behaviour review named but no alert brought onto the report.
|
||||
*
|
||||
* <p>
|
||||
* The alert-derived fields are empty because there are none in the window, and that is the point being shown: the
|
||||
* case rests on the period, not on anything that fired. The figures the verdict was drawn from travel on
|
||||
* behaviourWindow, so the row can still explain itself.
|
||||
*/
|
||||
private rowFromWindow(w: any): RiskCustomerRow {
|
||||
const RANK: Record<number, RiskLevel> = { 1: 'LOW', 2: 'MEDIUM', 3: 'HIGH' };
|
||||
const current = RANK[w.riskCategoryAtReview] ?? null;
|
||||
const recommended = RANK[w.suggestedCategory] ?? current ?? 'LOW';
|
||||
return {
|
||||
clientId: w.clientId,
|
||||
customerName: w.clientName || `Client ${w.clientId}`,
|
||||
accountNo: w.accountNo || '',
|
||||
branchCode: w.officeName || '',
|
||||
currentRisk: current,
|
||||
alerts: [],
|
||||
alertCount: 0,
|
||||
totalAmount: Number(w.creditTotal || 0) + Number(w.debitTotal || 0),
|
||||
largestAmount: Number(w.largestTxn || 0),
|
||||
proposedRisk: recommended,
|
||||
recommendedRisk: recommended,
|
||||
direction: w.suggestedDirection === 'UP' ? 'up' : w.suggestedDirection === 'DOWN' ? 'down' : 'same',
|
||||
directionReason: w.suggestionReason || '',
|
||||
ruleLabels: [],
|
||||
firstAlertDate: '',
|
||||
lastAlertDate: '',
|
||||
openCount: 0,
|
||||
escalated: false,
|
||||
needsUpgrade: w.suggestedDirection === 'UP',
|
||||
needsDowngrade: w.suggestedDirection === 'DOWN',
|
||||
behaviourWindow: w
|
||||
};
|
||||
}
|
||||
|
||||
get customerGroups(): RiskCustomerGroup[] {
|
||||
const rows = this.customerRows;
|
||||
const escalated = rows.filter((c) => c.escalated);
|
||||
const rest = rows.filter((c) => !c.escalated);
|
||||
// An escalated customer whose rating still has to move belongs with the work, not with the cases that are waiting
|
||||
// on someone else. An open STR holds a rating at High, and if the customer is not yet at High that is a change
|
||||
// somebody has to apply - filing the STR does not make it. Only a customer whose rating already matches sits under
|
||||
// the Principal Officer with nothing to do.
|
||||
const escalated = rows.filter((c) => c.escalated && c.direction === 'same');
|
||||
const rest = rows.filter((c) => !(c.escalated && c.direction === 'same'));
|
||||
|
||||
// Customers the nightly review says to move who raised no alert inside the filter window.
|
||||
//
|
||||
// The report is built from alerts, so a customer appears only once a rule has fired on them recently. That is the
|
||||
// wrong test for a downgrade: the whole case for lowering a rating is that nothing has fired for a year, so the
|
||||
// customer with the strongest case is precisely the one the alert feed has stopped mentioning. Without this they
|
||||
// would sit in the behaviour table with a verdict nobody ever sees.
|
||||
const onReport = new Set(rows.map((r) => r.clientId));
|
||||
const offReport: RiskCustomerRow[] = Object.values(this.behaviourWindows)
|
||||
.filter((w: any) => w?.clientId != null && !onReport.has(w.clientId))
|
||||
.filter((w: any) => w.suggestedDirection === 'UP' || w.suggestedDirection === 'DOWN')
|
||||
.map((w: any) => this.rowFromWindow(w));
|
||||
|
||||
const groups: RiskCustomerGroup[] = [
|
||||
{
|
||||
@@ -378,25 +454,42 @@ export class RiskReviewReportComponent implements OnInit {
|
||||
rows: escalated
|
||||
},
|
||||
{
|
||||
key: 'up',
|
||||
title: 'Rating needs raising',
|
||||
hint: 'The rules point higher than the rating on file. These are under-rated customers — the real gap.',
|
||||
// Raising and lowering are one section, not two. The heading answers "is there anything to do about this
|
||||
// customer", and which way the rating moves is already on every row - as a verdict, an arrow and a reason.
|
||||
// Splitting them put the same answer in two places and left an officer reading two lists to find one day's
|
||||
// work. Raises sort first within the section, because an under-rated customer is the more urgent gap.
|
||||
key: 'change',
|
||||
title: 'Change needed',
|
||||
hint: 'Every condition for moving the rating is met. Apply the change; it goes to a checker for approval.',
|
||||
tone: 'up',
|
||||
rows: rest.filter((c) => c.direction === 'up')
|
||||
rows: [
|
||||
...rest.filter((c) => c.currentRisk && c.direction === 'up'),
|
||||
...offReport.filter((c) => c.direction === 'up'),
|
||||
...rest.filter((c) => c.currentRisk && c.direction === 'down'),
|
||||
...offReport.filter((c) => c.direction === 'down')
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'down',
|
||||
title: 'Rating can be lowered',
|
||||
hint: 'Activity no longer supports the rating on file.',
|
||||
tone: 'down',
|
||||
rows: rest.filter((c) => c.direction === 'down')
|
||||
// Its own section, and above "no change needed", because it is the opposite of that. An unrated customer
|
||||
// whose money is moving has no rating for the behaviour to be compared against, so nothing here can say
|
||||
// whether it is in keeping - the work is to rate them, and grouping them under "no change needed" buried the
|
||||
// one action the report was asking for.
|
||||
key: 'unrated',
|
||||
title: 'Rating needed',
|
||||
hint: 'These customers are transacting with no rating on file. Nothing can be judged against a rating that does not exist — rate them first.',
|
||||
tone: 'up',
|
||||
rows: rest.filter((c) => !c.currentRisk)
|
||||
},
|
||||
{
|
||||
// Everything an alert put on the report that the policy will not move. "No change needed" read as though
|
||||
// these customers were fine, which is not what it means: a High-rated customer with four open alerts is on
|
||||
// this list, and the reason is that the conditions for lowering the rating are not met - not that there is
|
||||
// nothing to look at. The reason on each row says which condition fell short.
|
||||
key: 'same',
|
||||
title: 'No change needed',
|
||||
hint: 'The rating on file already matches the behaviour. Listed for the record.',
|
||||
title: 'Rating holds — no change this period',
|
||||
hint: 'An alert brought these customers onto the report, but the conditions for moving the rating are not met. The reason on each row says what is missing.',
|
||||
tone: 'hold',
|
||||
rows: rest.filter((c) => c.direction === 'same')
|
||||
rows: rest.filter((c) => c.currentRisk && c.direction === 'same')
|
||||
}
|
||||
];
|
||||
|
||||
@@ -575,6 +668,67 @@ export class RiskReviewReportComponent implements OnInit {
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a case that is with the Principal Officer to the STR queue.
|
||||
*
|
||||
* The rating cannot be moved while an STR is open, so the useful thing to offer is the case
|
||||
* itself. Sending the reviewer to the customer's KYC record instead made them land on a screen
|
||||
* where the one action they might take is the one thing they must not do yet.
|
||||
*/
|
||||
openStrCase(c: RiskCustomerRow, event: Event): void {
|
||||
event.stopPropagation();
|
||||
this.router.navigate(['/fiu'], { queryParams: { tab: 'str' } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this row's rating can be moved from here.
|
||||
*
|
||||
* A case with the Principal Officer is deliberately excluded: the rating is held where it is
|
||||
* until the STR concludes, which is what the section heading on that group says. Offering the
|
||||
* button there would invite a change nobody is in a position to make yet.
|
||||
*/
|
||||
canChangeRisk(c: RiskCustomerRow): boolean {
|
||||
return !c.escalated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Raises a rating change for approval, rather than sending the reviewer to the KYC screen to make
|
||||
* it themselves.
|
||||
*
|
||||
* The button used to navigate away: the reviewer left the report, found the customer, edited the
|
||||
* record, and the change took effect on their say-so alone with no second pair of eyes and no
|
||||
* record of why. The rating governs re-KYC frequency and AML thresholds, so it goes through a
|
||||
* checker like every other decision of that weight.
|
||||
*/
|
||||
changeRisk(c: RiskCustomerRow, event: Event): void {
|
||||
event.stopPropagation();
|
||||
if (!this.canChangeRisk(c)) {
|
||||
return;
|
||||
}
|
||||
this.dialog
|
||||
.open(RiskChangeDialogComponent, {
|
||||
width: '520px',
|
||||
maxWidth: '95vw',
|
||||
disableClose: true,
|
||||
data: {
|
||||
clientId: c.clientId,
|
||||
customerName: c.customerName,
|
||||
currentCategory: riskRank(c.currentRisk) || null,
|
||||
recommendedCategory: riskRank(c.recommendedRisk) || null,
|
||||
recommendationReason: c.directionReason
|
||||
} as RiskChangeDialogData
|
||||
})
|
||||
.afterClosed()
|
||||
.subscribe((sent) => {
|
||||
if (sent) {
|
||||
// The rating has not moved yet - only a request exists - so the figures on screen are
|
||||
// still correct. Reloaded anyway so a second click cannot raise a duplicate request the
|
||||
// server would refuse.
|
||||
this.load();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* What the reviewer is going to the KYC screen to do.
|
||||
*
|
||||
@@ -589,25 +743,41 @@ export class RiskReviewReportComponent implements OnInit {
|
||||
* change nobody is authorised to make yet.
|
||||
*/
|
||||
directionText(c: RiskCustomerRow): string {
|
||||
if (c.escalated) {
|
||||
// Escalation is only the whole story when the rating already matches. An STR holds a customer at High, so one
|
||||
// filed against a Medium-rated customer leaves a rating that still has to be moved - saying "held" there reports
|
||||
// the case and hides the work, which is how this row came to sit under "Change needed" announcing that nothing
|
||||
// needed doing.
|
||||
if (c.escalated && c.direction === 'same') {
|
||||
return 'Held — with Principal Officer';
|
||||
}
|
||||
if (c.direction === 'up') {
|
||||
return `Raise to ${RISK_LABEL[c.recommendedRisk]}`;
|
||||
// Both ends of the move, not just the destination. "Lower to Medium" reads as a verdict on its own and leaves the
|
||||
// officer joining it to the rating column to see what is actually changing; "High → Medium" is the whole fact in
|
||||
// one glance, and on an exported sheet it still says what happened without the column beside it.
|
||||
if (c.direction === 'up' || c.direction === 'down') {
|
||||
const from = c.currentRisk ? RISK_LABEL[c.currentRisk] : 'Unrated';
|
||||
return `${from} → ${RISK_LABEL[c.recommendedRisk]}`;
|
||||
}
|
||||
if (c.direction === 'down') {
|
||||
return `Lower to ${RISK_LABEL[c.recommendedRisk]}`;
|
||||
}
|
||||
return c.currentRisk ? `Keep at ${RISK_LABEL[c.currentRisk]}` : 'No rating on record';
|
||||
// An unrated customer is told what to do, not what was concluded. "No rating on record" states the gap and stops
|
||||
// there, which on a worklist reads as though the row needs nothing - and it is in fact the row that needs the most.
|
||||
return c.currentRisk ? `Keep at ${RISK_LABEL[c.currentRisk]}` : 'Rate this customer';
|
||||
}
|
||||
|
||||
directionIcon(c: RiskCustomerRow): string {
|
||||
if (c.escalated) {
|
||||
// Same test as directionText: a padlock says the case is out of the officer's hands, which is untrue while the
|
||||
// rating still has to be moved.
|
||||
if (c.escalated && c.direction === 'same') {
|
||||
return 'lock';
|
||||
}
|
||||
// 'check' rather than an equals sign: faEquals is not in the shared library, and an
|
||||
// unregistered icon throws at render and takes the whole row down with it.
|
||||
return c.direction === 'up' ? 'arrow-up' : c.direction === 'down' ? 'arrow-down' : 'check';
|
||||
if (c.direction === 'up') {
|
||||
return 'arrow-up';
|
||||
}
|
||||
if (c.direction === 'down') {
|
||||
return 'arrow-down';
|
||||
}
|
||||
// A tick claims the row is settled. An unrated customer is not settled, so it gets the mark that asks a question
|
||||
// rather than the one that closes it. 'exclamation-circle' rather than plain 'exclamation': only the registered
|
||||
// names resolve, and an unregistered icon throws at render and takes the whole row down with it.
|
||||
return c.currentRisk ? 'check' : 'exclamation-circle';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
AmlRuleMeta,
|
||||
CTR_THRESHOLD,
|
||||
MATERIALITY,
|
||||
NON_MITIGABLE_RULES,
|
||||
isNonMitigableRule,
|
||||
REVIEW_STATUS_META,
|
||||
RISK_LABEL,
|
||||
RiskAlertRow,
|
||||
@@ -109,6 +109,8 @@ export interface RiskCustomerRow {
|
||||
needsUpgrade: boolean;
|
||||
/** True when the rating on record is higher than the activity warrants. */
|
||||
needsDowngrade: boolean;
|
||||
/** The review period the recommendation was drawn from, where the nightly job has computed one. */
|
||||
behaviourWindow?: BehaviourWindowByClient[number] | null;
|
||||
}
|
||||
|
||||
export interface RiskReviewSummary {
|
||||
@@ -158,7 +160,7 @@ function recommendRating(
|
||||
ruleCodes: string[]
|
||||
): { risk: RiskLevel; reason: string } {
|
||||
const implied = RANK_TO_LEVEL[impliedRank] ?? 'LOW';
|
||||
const grave = ruleCodes.find((c) => NON_MITIGABLE_RULES.has(c));
|
||||
const grave = ruleCodes.find((c) => isNonMitigableRule(c));
|
||||
const material = largestAmount >= CTR_THRESHOLD || totalAmount >= MATERIALITY.highTotal;
|
||||
const small = largestAmount < MATERIALITY.smallTxn && totalAmount < MATERIALITY.smallTotal;
|
||||
|
||||
@@ -271,7 +273,36 @@ export function riskAlertViews(alerts: RiskAlertRow[], filter: RiskReviewFilter)
|
||||
* escalated, then rated below what the rules point at, then most alerts —
|
||||
* come to the top of the worklist.
|
||||
*/
|
||||
export function riskCustomerRows(views: RiskAlertView[]): RiskCustomerRow[] {
|
||||
/**
|
||||
* The behaviour window the nightly job computed for one customer, where there is one.
|
||||
*
|
||||
* Keyed on client id. A customer with no window - one the job has not seen yet, or a tenant where the job has never
|
||||
* run - falls back to the per-alert reckoning below, so the report renders either way.
|
||||
*/
|
||||
export interface BehaviourWindowByClient {
|
||||
[clientId: number]: {
|
||||
clientId: number;
|
||||
clientName: string | null;
|
||||
accountNo: string | null;
|
||||
officeName: string | null;
|
||||
riskCategoryAtReview: number | null;
|
||||
txnCount: number | null;
|
||||
distinctMonths: number | null;
|
||||
windowFrom: any;
|
||||
windowTo: any;
|
||||
creditTotal: number | null;
|
||||
debitTotal: number | null;
|
||||
largestTxn: number | null;
|
||||
cashTotal: number | null;
|
||||
alertCount: number | null;
|
||||
strCaseCount: number | null;
|
||||
suggestedDirection: string | null;
|
||||
suggestedCategory: number | null;
|
||||
suggestionReason: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export function riskCustomerRows(views: RiskAlertView[], windows: BehaviourWindowByClient = {}): RiskCustomerRow[] {
|
||||
const byClient = new Map<number, RiskAlertView[]>();
|
||||
for (const v of views) {
|
||||
const list = byClient.get(v.clientId);
|
||||
@@ -297,19 +328,53 @@ export function riskCustomerRows(views: RiskAlertView[]): RiskCustomerRow[] {
|
||||
const largestAmount = Math.max(...alerts.map((a) => a.amount));
|
||||
const escalated = alerts.some((a) => a.status === 'ESCALATED_STR' || a.status === 'STR_FILED');
|
||||
|
||||
const recommendation = recommendRating(
|
||||
proposedRank,
|
||||
currentRisk,
|
||||
largestAmount,
|
||||
totalAmount,
|
||||
escalated,
|
||||
alerts.map((a) => a.ruleCode)
|
||||
);
|
||||
// The server's own reckoning, where the nightly job has produced one.
|
||||
//
|
||||
// It is the better answer, and not because it is newer: it is drawn from a review period rather than from the
|
||||
// alerts on this screen. A customer appears here only once a rule has fired, so judging them on those alerts
|
||||
// alone describes the exceptions and never the ordinary months around them - which is exactly what a rating is
|
||||
// supposed to reflect. The job also tests the things this screen cannot see at all: how many separate months the
|
||||
// activity falls in, how long the customer has held the rating, whether an STR inside the block window forbids
|
||||
// lowering it.
|
||||
//
|
||||
// Without a window - a tenant where the job has not run, or a customer it has not reached - the per-alert
|
||||
// reckoning below still stands, so the report never renders blank while the two are in step.
|
||||
// A window is only good for as long as the rating it was computed against. The job takes a snapshot each night
|
||||
// and records the rating the customer held at the time; once an officer moves that rating the snapshot describes
|
||||
// a customer who no longer exists, and the screen would go on asking for a change that has already been applied
|
||||
// and approved. Dropping it here falls back to the per-alert reckoning until tonight's job catches up.
|
||||
const raw = windows[clientId];
|
||||
const staleWindow =
|
||||
raw?.riskCategoryAtReview != null && currentRisk != null && RANK_TO_LEVEL[raw.riskCategoryAtReview] !== currentRisk;
|
||||
const window = staleWindow ? undefined : raw;
|
||||
const recommendation = window?.suggestedDirection
|
||||
? {
|
||||
risk: RANK_TO_LEVEL[window.suggestedCategory ?? 0] ?? currentRisk ?? 'LOW',
|
||||
reason: window.suggestionReason ?? ''
|
||||
}
|
||||
: recommendRating(
|
||||
proposedRank,
|
||||
currentRisk,
|
||||
largestAmount,
|
||||
totalAmount,
|
||||
escalated,
|
||||
alerts.map((a) => a.ruleCode)
|
||||
);
|
||||
|
||||
// Where the customer carries no rating there is nothing to move, so an
|
||||
// unrated customer is never counted as an upgrade or a downgrade.
|
||||
const delta = currentRisk ? riskRank(recommendation.risk) - riskRank(currentRisk) : 0;
|
||||
const direction: RatingDirection = delta > 0 ? 'up' : delta < 0 ? 'down' : 'same';
|
||||
const direction: RatingDirection = window?.suggestedDirection
|
||||
? window.suggestedDirection === 'UP'
|
||||
? 'up'
|
||||
: window.suggestedDirection === 'DOWN'
|
||||
? 'down'
|
||||
: 'same'
|
||||
: delta > 0
|
||||
? 'up'
|
||||
: delta < 0
|
||||
? 'down'
|
||||
: 'same';
|
||||
|
||||
rows.push({
|
||||
clientId,
|
||||
@@ -332,7 +397,10 @@ export function riskCustomerRows(views: RiskAlertView[]): RiskCustomerRow[] {
|
||||
openCount: alerts.filter((a) => REVIEW_STATUS_META[a.status].open).length,
|
||||
escalated,
|
||||
needsUpgrade: direction === 'up',
|
||||
needsDowngrade: direction === 'down'
|
||||
needsDowngrade: direction === 'down',
|
||||
// Carried through so the screen can show the period a recommendation was drawn from. A rating an officer is
|
||||
// asked to approve has to show its working, and the alerts on this row are not it.
|
||||
behaviourWindow: window ?? null
|
||||
});
|
||||
}
|
||||
|
||||
@@ -461,9 +529,13 @@ export interface RiskReviewModel {
|
||||
* the summary all run once per filter change rather than once per template
|
||||
* binding, which matters because every view reads from the same result.
|
||||
*/
|
||||
export function buildRiskReview(alerts: RiskAlertRow[], filter: RiskReviewFilter): RiskReviewModel {
|
||||
export function buildRiskReview(
|
||||
alerts: RiskAlertRow[],
|
||||
filter: RiskReviewFilter,
|
||||
windows: BehaviourWindowByClient = {}
|
||||
): RiskReviewModel {
|
||||
const base = riskAlertViews(alerts, filter);
|
||||
const narrowed = applyDirectionFilter(base, riskCustomerRows(base), filter.direction);
|
||||
const narrowed = applyDirectionFilter(base, riskCustomerRows(base, windows), filter.direction);
|
||||
|
||||
return {
|
||||
alerts: narrowed.views,
|
||||
|
||||
@@ -232,6 +232,12 @@
|
||||
<fa-icon icon="ban" class="ac-tab__ic"></fa-icon>
|
||||
<span>DD Revoke Requests</span>
|
||||
</a>
|
||||
<!-- Raised from the Risk Review Report. Approving moves the customer's KYC rating and their
|
||||
re-KYC due date together, so it belongs with the other maker-checker queues. -->
|
||||
<a class="ac-tab" routerLink="./risk-change-approvals" routerLinkActive="ac-tab--active">
|
||||
<fa-icon icon="shield-alt" class="ac-tab__ic"></fa-icon>
|
||||
<span>Risk Category Changes</span>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div class="ac-tab-panel">
|
||||
|
||||
@@ -243,6 +243,12 @@ const routes: Routes = [
|
||||
(m) => m.DdRevokeApprovalsComponent
|
||||
),
|
||||
data: { title: 'DD Revoke Approvals' }
|
||||
},
|
||||
{
|
||||
path: 'risk-change-approvals',
|
||||
loadComponent: () =>
|
||||
import('../risk-review/risk-change-approvals.component').then((m) => m.RiskChangeApprovalsComponent),
|
||||
data: { title: 'Risk Category Approvals' }
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
/* tslint:disable */
|
||||
export default {
|
||||
'mifos_x': {
|
||||
'version': '260914',
|
||||
'hash': 'ee5a430b'
|
||||
'version': '260916',
|
||||
'hash': 'c01615ee'
|
||||
},
|
||||
'allow_switching_backend_instance': true
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user