Add CC accounts tracking, AML rule trial and branch approval fixes

This commit is contained in:
2026-10-19 18:16:22 +05:30
parent 885b5897af
commit 8bf74afe24
100 changed files with 14954 additions and 2692 deletions
@@ -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 {