KYC management: stage-wise notices, pagination, RD base chart

This commit is contained in:
2026-10-20 17:23:42 +05:30
parent 3e5cc26e49
commit 357f666421
108 changed files with 15001 additions and 2721 deletions
@@ -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;
}
}
@@ -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. -->
@@ -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;
}
@@ -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,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) {