KYC management: stage-wise notices, pagination, RD base chart
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
<!--
|
||||
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="cc-intro">
|
||||
<mat-icon>account_balance_wallet</mat-icon>
|
||||
<div>
|
||||
A Cash Credit (CC) account is a current account that may draw an overdraft against a sanctioned limit. For each one,
|
||||
this shows how much came in and how much went out, against the turnover the customer declared and the limit the bank
|
||||
sanctioned. It is read from the posted transactions, so it is live now, while the scenarios for CC are still being
|
||||
added to the engine.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cc-controls">
|
||||
<label class="cc-field">
|
||||
<span>Window</span>
|
||||
<select [(ngModel)]="days" (ngModelChange)="load()">
|
||||
@for (w of windows; track w.days) {
|
||||
<option [ngValue]="w.days">{{ w.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label class="cc-check">
|
||||
<input type="checkbox" [(ngModel)]="ccOnly" />
|
||||
CC (overdraft) accounts only
|
||||
</label>
|
||||
<span class="cc-asof">
|
||||
@if (asOf) {
|
||||
{{ from | date: 'dd MMM yyyy' }} to {{ asOf | date: 'dd MMM yyyy' }}
|
||||
}
|
||||
</span>
|
||||
<button type="button" class="cc-refresh" (click)="load()" matTooltip="Reload">
|
||||
<mat-icon>refresh</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="cc-cards">
|
||||
<div class="cc-card cc-card--in">
|
||||
<div class="cc-card__n">₹{{ totalIn | number: '1.2-2' }}</div>
|
||||
<div class="cc-card__l">Came in</div>
|
||||
</div>
|
||||
<div class="cc-card cc-card--out">
|
||||
<div class="cc-card__n">₹{{ totalOut | number: '1.2-2' }}</div>
|
||||
<div class="cc-card__l">Went out</div>
|
||||
</div>
|
||||
<div class="cc-card">
|
||||
<div class="cc-card__n">{{ shown.length }}</div>
|
||||
<div class="cc-card__l">Accounts shown ({{ ccCount }} CC)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (loading) {
|
||||
<div class="cc-note">Loading the CC account figures…</div>
|
||||
} @else if (error) {
|
||||
<div class="cc-note cc-note--warn">{{ error }}</div>
|
||||
} @else if (!shown.length) {
|
||||
<div class="cc-note">
|
||||
@if (ccOnly && !ccCount) {
|
||||
No CC account yet. A CC account is a current account opened with the overdraft facility switched on; untick the
|
||||
box to see every current account.
|
||||
} @else {
|
||||
No active current account in your branch.
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="cc-table-wrap">
|
||||
<table class="cc-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Customer / Account</th>
|
||||
<th>Branch</th>
|
||||
<th class="r">Limit</th>
|
||||
<th class="r">Drawn</th>
|
||||
<th class="r">Declared / month</th>
|
||||
<th class="r">Came in</th>
|
||||
<th class="r">Went out</th>
|
||||
<th class="r">Net</th>
|
||||
<th class="r">In ÷ declared</th>
|
||||
<th class="r">Cash in</th>
|
||||
<th>Last txn</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (a of shown; track a.accountId; let i = $index) {
|
||||
<tr>
|
||||
<td>{{ i + 1 }}</td>
|
||||
<td>
|
||||
<div class="cc-name">
|
||||
{{ a.clientName }}
|
||||
<span class="cc-badge" [class.cc-badge--cc]="a.isCc">{{ a.isCc ? 'CC' : 'Current' }}</span>
|
||||
</div>
|
||||
<div class="cc-sub mono">{{ a.accountNo }}</div>
|
||||
</td>
|
||||
<td>{{ a.branchName || '—' }}</td>
|
||||
<td class="r">{{ a.isCc ? '₹' + (a.sanctionedLimit | number: '1.0-0') : '—' }}</td>
|
||||
<td class="r">{{ a.limitDrawnPercent != null ? a.limitDrawnPercent + '%' : '—' }}</td>
|
||||
<td class="r">
|
||||
{{
|
||||
a.declaredMonthlyTurnover != null ? '₹' + (a.declaredMonthlyTurnover | number: '1.0-0') : 'not declared'
|
||||
}}
|
||||
</td>
|
||||
<td class="r in">
|
||||
₹{{ a.credits | number: '1.2-2' }}
|
||||
<div class="cc-sub">{{ a.creditCount }} txn</div>
|
||||
</td>
|
||||
<td class="r out">
|
||||
₹{{ a.debits | number: '1.2-2' }}
|
||||
<div class="cc-sub">{{ a.debitCount }} txn</div>
|
||||
</td>
|
||||
<td class="r" [class.in]="a.net > 0" [class.out]="a.net < 0">₹{{ a.net | number: '1.2-2' }}</td>
|
||||
<td class="r">
|
||||
@if (a.creditsVsDeclared != null) {
|
||||
<span
|
||||
class="cc-ratio"
|
||||
[class.cc-ratio--hi]="isSpike(a.creditsVsDeclared)"
|
||||
[matTooltip]="isSpike(a.creditsVsDeclared) ? 'Well above the declared turnover for this window' : ''"
|
||||
>
|
||||
{{ a.creditsVsDeclared }}×
|
||||
</span>
|
||||
} @else {
|
||||
—
|
||||
}
|
||||
</td>
|
||||
<td class="r">{{ a.cashCreditPercent != null ? a.cashCreditPercent + '%' : '—' }}</td>
|
||||
<td>{{ a.lastTxnDate ? (a.lastTxnDate | date: 'dd MMM yyyy') : '—' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="cc-foot">
|
||||
Busiest first. “In ÷ declared” compares what came in with the turnover the customer declared, scaled to the window;
|
||||
{{ spikeMultiple }}× or more is highlighted for a look. It is an indicative starting value, not a finding.
|
||||
</p>
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.cc-intro {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 14px;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 8px;
|
||||
background: #eff6ff;
|
||||
color: #1e3a8a;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
|
||||
mat-icon {
|
||||
flex-shrink: 0;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
}
|
||||
|
||||
.cc-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.cc-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
|
||||
select {
|
||||
padding: 6px 10px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
color: #0f172a;
|
||||
}
|
||||
}
|
||||
|
||||
.cc-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.cc-asof {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.cc-refresh {
|
||||
margin-left: auto;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.cc-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.cc-card {
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-left: 4px solid #1a237e;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
|
||||
&--in {
|
||||
border-left-color: #15803d;
|
||||
}
|
||||
|
||||
&--out {
|
||||
border-left-color: #b91c1c;
|
||||
}
|
||||
|
||||
&__n {
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
&__l {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
.cc-note {
|
||||
padding: 14px 16px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
color: #334155;
|
||||
font-size: 13px;
|
||||
|
||||
&--warn {
|
||||
border-color: #fcd34d;
|
||||
background: #fffbeb;
|
||||
color: #78350f;
|
||||
}
|
||||
}
|
||||
|
||||
.cc-table-wrap {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cc-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
||||
th {
|
||||
padding: 12px 10px;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 10px;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
font-size: 13px;
|
||||
color: #1e293b;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.r {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.in {
|
||||
color: #15803d;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.out {
|
||||
color: #b91c1c;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.cc-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cc-sub {
|
||||
margin-top: 1px;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
|
||||
.cc-badge {
|
||||
display: inline-block;
|
||||
margin-left: 6px;
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
background: #e2e8f0;
|
||||
color: #475569;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
|
||||
&--cc {
|
||||
background: #dbeafe;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
}
|
||||
|
||||
.cc-ratio {
|
||||
display: inline-block;
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
background: #f1f5f9;
|
||||
font-weight: 700;
|
||||
|
||||
&--hi {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
}
|
||||
|
||||
.cc-foot {
|
||||
margin: 10px 2px 0;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* 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 { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { AmlService } from '../../../aml/aml.service';
|
||||
|
||||
/**
|
||||
* How much came into and went out of each Cash Credit account, against what the customer declared and what the bank
|
||||
* sanctioned. Read from the posted current-account transactions, so it works without the AML engine being fed them.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-aml-cc-tracking',
|
||||
templateUrl: './aml-cc-tracking.component.html',
|
||||
styleUrls: ['./aml-cc-tracking.component.scss'],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatIconModule,
|
||||
MatTooltipModule
|
||||
]
|
||||
})
|
||||
export class AmlCcTrackingComponent implements OnInit {
|
||||
private amlService = inject(AmlService);
|
||||
|
||||
readonly windows = [
|
||||
{ days: 7, label: 'Last 7 days' },
|
||||
{ days: 30, label: 'Last 30 days' },
|
||||
{ days: 90, label: 'Last 90 days' }
|
||||
];
|
||||
days = 30;
|
||||
/** CC accounts only, or every current account (a plain current account is not hidden by default). */
|
||||
ccOnly = false;
|
||||
|
||||
loading = false;
|
||||
error = '';
|
||||
asOf = '';
|
||||
from = '';
|
||||
accounts: any[] = [];
|
||||
|
||||
/** A credit or debit this many times the declared turnover for the window is flagged for a look. Indicative only. */
|
||||
readonly spikeMultiple = 3;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
this.amlService.getCcTracking(this.days).subscribe({
|
||||
next: (res) => {
|
||||
this.loading = false;
|
||||
this.accounts = res?.accounts || [];
|
||||
this.asOf = res?.asOf || '';
|
||||
this.from = res?.from || '';
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.loading = false;
|
||||
this.accounts = [];
|
||||
this.error = err?.error?.defaultUserMessage || 'Could not load the CC account figures.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get shown(): any[] {
|
||||
return this.ccOnly ? this.accounts.filter((a) => a.isCc) : this.accounts;
|
||||
}
|
||||
|
||||
get ccCount(): number {
|
||||
return this.accounts.filter((a) => a.isCc).length;
|
||||
}
|
||||
|
||||
get totalIn(): number {
|
||||
return this.shown.reduce((sum, a) => sum + (Number(a.credits) || 0), 0);
|
||||
}
|
||||
|
||||
get totalOut(): number {
|
||||
return this.shown.reduce((sum, a) => sum + (Number(a.debits) || 0), 0);
|
||||
}
|
||||
|
||||
/** True when a side of the account's activity is well beyond what the customer declared for the window. */
|
||||
isSpike(ratio: number | null): boolean {
|
||||
return ratio != null && ratio >= this.spikeMultiple;
|
||||
}
|
||||
}
|
||||
+139
-148
@@ -303,153 +303,6 @@
|
||||
</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>
|
||||
|
||||
<ng-container matColumnDef="amount">
|
||||
<th mat-header-cell *matHeaderCellDef class="col-right">Amount</th>
|
||||
<td mat-cell *matCellDef="let alert" class="col-right amount-cell">{{ amount(alert) }}</td>
|
||||
@@ -541,9 +394,147 @@
|
||||
[class.row-expanded]="isExpanded(row)"
|
||||
(click)="toggleExpanded(row)"
|
||||
></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: ['expandedDetail']" class="detail-row"></tr>
|
||||
</table>
|
||||
|
||||
<!-- Every rule a transaction tripped, in a popup rather than a pane pushed in under the row. Declared here so it keeps
|
||||
this component's state and styles; opened by toggleExpanded(). -->
|
||||
<ng-template #rulesDialog>
|
||||
<div class="rules-dialog">
|
||||
<button type="button" class="rules-dialog-close" mat-icon-button mat-dialog-close aria-label="Close">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
@if (expandedAlert; as 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>
|
||||
</ng-template>
|
||||
|
||||
<!-- 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. -->
|
||||
|
||||
+27
-1
@@ -6,6 +6,12 @@
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.flagged-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -744,11 +750,13 @@
|
||||
}
|
||||
|
||||
.actions-cell {
|
||||
white-space: nowrap;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.action-group {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
@@ -1011,3 +1019,21 @@
|
||||
color: #1e293b;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* The rules popup. Its content is the same markup the inline pane used, so it only needs room and a close button. */
|
||||
.rules-dialog {
|
||||
position: relative;
|
||||
padding: 8px 0 0;
|
||||
|
||||
.detail-inner {
|
||||
padding: 18px 24px 22px;
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.rules-dialog-close {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 8px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
+18
-3
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component, OnInit, AfterViewInit, OnDestroy, inject, ViewChild } from '@angular/core';
|
||||
import { Component, OnInit, AfterViewInit, OnDestroy, inject, ViewChild, TemplateRef } from '@angular/core';
|
||||
import { MatTableModule, MatTableDataSource } from '@angular/material/table';
|
||||
import { MatPaginatorModule, MatPaginator } from '@angular/material/paginator';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
@@ -167,9 +167,24 @@ export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, O
|
||||
* and the one state the flag existed to make visible — this one is actually being worked — became meaningless.
|
||||
*/
|
||||
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.expandedId = alert.id;
|
||||
// Ticks belong to the alert they were made on, not to whichever popup is open next.
|
||||
this.selectedRuleCodes.clear();
|
||||
this.dialog
|
||||
.open(this.rulesDialogTpl, { width: '780px', maxWidth: '95vw', maxHeight: '90vh', autoFocus: false })
|
||||
.afterClosed()
|
||||
.subscribe(() => {
|
||||
this.expandedId = null;
|
||||
this.selectedRuleCodes.clear();
|
||||
});
|
||||
}
|
||||
|
||||
/** The popup that lists every rule the open alert tripped. */
|
||||
@ViewChild('rulesDialog') rulesDialogTpl!: TemplateRef<unknown>;
|
||||
|
||||
/** The alert the popup is showing, looked up fresh so a feed refresh behind it does not leave it stale. */
|
||||
get expandedAlert(): any {
|
||||
return this.alerts.data.find((a: any) => a.id === this.expandedId) ?? null;
|
||||
}
|
||||
|
||||
isExpanded(alert: any): boolean {
|
||||
|
||||
+6
@@ -6,6 +6,12 @@
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.ra-intro {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
|
||||
@@ -58,6 +58,22 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Separate the rules by the kind of account they watch -->
|
||||
<div class="period-filters">
|
||||
<span class="period-filters-label">Account</span>
|
||||
<button
|
||||
*ngFor="let a of accountFilters"
|
||||
type="button"
|
||||
class="period-chip"
|
||||
[class.active]="activeAccount === a.key"
|
||||
[disabled]="accountCount(a.key) === 0 && activeAccount !== a.key"
|
||||
(click)="setAccount(a.key)"
|
||||
>
|
||||
{{ a.label }}
|
||||
<span class="period-chip-n">{{ accountCount(a.key) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Separate the rules by the window they are measured over -->
|
||||
<div class="period-filters">
|
||||
<span class="period-filters-label">Applies over</span>
|
||||
@@ -203,6 +219,15 @@
|
||||
</div>
|
||||
</ng-template>
|
||||
</mat-tab>
|
||||
|
||||
<!-- How much each CC account has taken in and paid out. Lazily instantiated so it only loads when opened. -->
|
||||
<mat-tab label="CC Accounts">
|
||||
<ng-template matTabContent>
|
||||
<div class="aml-tab-body">
|
||||
<mifosx-aml-cc-tracking></mifosx-aml-cc-tracking>
|
||||
</div>
|
||||
</ng-template>
|
||||
</mat-tab>
|
||||
</mat-tab-group>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
@@ -86,10 +86,30 @@
|
||||
::ng-deep .mat-mdc-tab-body-wrapper {
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
/* Material makes each tab body a horizontal scroller, so the few pixels the tables overshoot by showed up as a
|
||||
scrollbar under every tab. The tables are width: 100% and their cells wrap, so nothing real sits out there. */
|
||||
::ng-deep .mat-mdc-tab-body-content {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
::ng-deep .mat-mdc-tab-body-wrapper,
|
||||
::ng-deep .mat-mdc-tab-body {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.aml-tab-body {
|
||||
padding-top: 16px;
|
||||
|
||||
/* The tab's own box, in case something inside it still overshoots by a few pixels. */
|
||||
overflow-x: hidden;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* stylelint-disable-next-line no-duplicate-selectors */
|
||||
.aml-card-content {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.aml-table {
|
||||
|
||||
@@ -28,6 +28,7 @@ import { AlertService } from '../../core/alert/alert.service';
|
||||
import { AddAmlRuleDialogComponent } from './add-aml-rule-dialog/add-aml-rule-dialog.component';
|
||||
import { AmlFlaggedTransactionsComponent } from './aml-flagged-transactions/aml-flagged-transactions.component';
|
||||
import { AmlRecurringAlertsComponent } from './aml-recurring-alerts/aml-recurring-alerts.component';
|
||||
import { AmlCcTrackingComponent } from './aml-cc-tracking/aml-cc-tracking.component';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-aml-rules',
|
||||
@@ -49,7 +50,8 @@ import { AmlRecurringAlertsComponent } from './aml-recurring-alerts/aml-recurrin
|
||||
MatTabsModule,
|
||||
FormsModule,
|
||||
AmlFlaggedTransactionsComponent,
|
||||
AmlRecurringAlertsComponent
|
||||
AmlRecurringAlertsComponent,
|
||||
AmlCcTrackingComponent
|
||||
]
|
||||
})
|
||||
export class AmlRulesComponent implements OnInit {
|
||||
@@ -85,6 +87,18 @@ export class AmlRulesComponent implements OnInit {
|
||||
];
|
||||
activePeriod = '';
|
||||
|
||||
/**
|
||||
* Which kind of account the rules are for. CC is a Cash Credit account: a current account allowed to draw an
|
||||
* overdraft against a sanctioned limit. The chip lists every scenario that watches such an account, whatever its
|
||||
* coverage status, so the officer sees what is and is not yet being checked.
|
||||
*/
|
||||
readonly accountFilters: { key: string; label: string }[] = [
|
||||
{ key: '', label: 'All accounts' },
|
||||
{ key: 'CC', label: 'CC' }
|
||||
];
|
||||
activeAccount = '';
|
||||
private accountCounts: Record<string, number> = {};
|
||||
|
||||
/** Free-text search, applied on top of the period filter. */
|
||||
searchTerm = '';
|
||||
|
||||
@@ -145,6 +159,21 @@ export class AmlRulesComponent implements OnInit {
|
||||
this.applyPeriodFilter();
|
||||
}
|
||||
|
||||
setAccount(key: string): void {
|
||||
this.activeAccount = key;
|
||||
this.applyPeriodFilter();
|
||||
}
|
||||
|
||||
/** How many rules belong to each account chip — shown on the chips. */
|
||||
accountCount(key: string): number {
|
||||
return this.accountCounts[key] ?? 0;
|
||||
}
|
||||
|
||||
/** Whether a rule is for the account kind the officer has picked. */
|
||||
private inAccountScope(rule: any): boolean {
|
||||
return !this.activeAccount || (this.activeAccount === 'CC' && !!rule.ccRelevant);
|
||||
}
|
||||
|
||||
applySearch(value: string): void {
|
||||
this.searchTerm = value;
|
||||
this.rules.filter = value.trim().toLowerCase();
|
||||
@@ -171,9 +200,9 @@ export class AmlRulesComponent implements OnInit {
|
||||
}
|
||||
|
||||
private applyPeriodFilter(): void {
|
||||
this.rules.data = this.activePeriod
|
||||
? this.allRules.filter((r) => this.periodKey(r) === this.activePeriod)
|
||||
: this.allRules;
|
||||
this.rules.data = this.allRules.filter(
|
||||
(r) => this.inAccountScope(r) && (!this.activePeriod || this.periodKey(r) === this.activePeriod)
|
||||
);
|
||||
this.recomputePeriodCounts();
|
||||
if (this.rules.paginator) {
|
||||
this.rules.paginator.firstPage();
|
||||
@@ -191,7 +220,11 @@ export class AmlRulesComponent implements OnInit {
|
||||
*/
|
||||
private recomputePeriodCounts(): void {
|
||||
const filter = this.rules.filter;
|
||||
const pool = filter ? this.allRules.filter((r) => this.matchesSearch(r, filter)) : this.allRules;
|
||||
const searched = filter ? this.allRules.filter((r) => this.matchesSearch(r, filter)) : this.allRules;
|
||||
// The account chips count within the search and the chosen period; the period chips within the chosen account.
|
||||
const inPeriod = searched.filter((r) => !this.activePeriod || this.periodKey(r) === this.activePeriod);
|
||||
this.accountCounts = { '': inPeriod.length, CC: inPeriod.filter((r) => !!r.ccRelevant).length };
|
||||
const pool = searched.filter((r) => this.inAccountScope(r));
|
||||
this.periodCounts = { '': pool.length };
|
||||
for (const p of this.periodFilters) {
|
||||
if (p.key) {
|
||||
|
||||
Reference in New Issue
Block a user