feat: KYC risk profiling, AML flagged transactions bulk review, Risk Review report & Board Resolution register
This commit is contained in:
+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>
|
||||
|
||||
Reference in New Issue
Block a user