feat(aml,risk-review,accounting): update financial activity mapping options, AML flagged transactions filters, and risk review report
This commit is contained in:
+149
-24
@@ -36,6 +36,17 @@
|
||||
<button mat-icon-button type="button" matTooltip="Refresh now" [disabled]="loading" (click)="fetch()">
|
||||
<mat-icon>refresh</mat-icon>
|
||||
</button>
|
||||
<!-- Exports what is on screen, not the whole feed: the workbook has to reconcile with the report it came from. -->
|
||||
<button
|
||||
type="button"
|
||||
class="export-btn"
|
||||
[disabled]="exporting || !alerts.data.length"
|
||||
[matTooltip]="alerts.data.length ? 'Download the alerts currently in view as Excel' : 'Nothing in view to export'"
|
||||
(click)="exportExcel()"
|
||||
>
|
||||
<mat-icon>file_download</mat-icon>
|
||||
{{ exporting ? 'Preparing…' : 'Excel' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -44,6 +55,52 @@
|
||||
<span>Could not reach the alert feed. Retrying automatically.</span>
|
||||
</div>
|
||||
|
||||
<!-- Narrow the feed to one day. The feed accumulates, so without this an officer asking "what did we decide today"
|
||||
reads past a week of older alerts to find out. Composes with the status chips below: Today + Escalated answers
|
||||
"what did we mark suspicious today". -->
|
||||
<div class="status-filters">
|
||||
<span class="status-filters-label">Period</span>
|
||||
@for (m of periodModes; track m.key) {
|
||||
<button type="button" class="status-chip" [class.active]="periodMode === m.key" (click)="setPeriodMode(m.key)">
|
||||
{{ m.label }}
|
||||
</button>
|
||||
}
|
||||
|
||||
<!-- One control per mode, so the picker always matches the granularity chosen. A week is picked by naming any day
|
||||
inside it; the resolved Monday-to-Sunday span is printed beside it so the officer sees what was selected. -->
|
||||
@if (periodMode === 'day') {
|
||||
<input type="date" class="day-input" [(ngModel)]="filterDay" (ngModelChange)="onPeriodChange()" aria-label="Day" />
|
||||
}
|
||||
@if (periodMode === 'week') {
|
||||
<input
|
||||
type="date"
|
||||
class="day-input"
|
||||
[(ngModel)]="filterWeek"
|
||||
(ngModelChange)="onPeriodChange()"
|
||||
aria-label="Any day in the week"
|
||||
/>
|
||||
}
|
||||
@if (periodMode === 'month') {
|
||||
<input
|
||||
type="month"
|
||||
class="day-input"
|
||||
[(ngModel)]="filterMonth"
|
||||
(ngModelChange)="onPeriodChange()"
|
||||
aria-label="Month"
|
||||
/>
|
||||
}
|
||||
@if (periodMode === 'year') {
|
||||
<select class="day-input" [(ngModel)]="filterYear" (ngModelChange)="onPeriodChange()" aria-label="Year">
|
||||
@for (y of availableYears; track y) {
|
||||
<option [value]="y">{{ y }}</option>
|
||||
}
|
||||
</select>
|
||||
}
|
||||
@if (periodLabel) {
|
||||
<span class="period-resolved">{{ periodLabel }}</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Narrow the feed by where each alert sits in the review workflow -->
|
||||
<div class="status-filters">
|
||||
<span class="status-filters-label">Status</span>
|
||||
@@ -60,6 +117,34 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Narrow to the rules firing most often. A rule tripping repeatedly is either a real pattern in the book or a rule
|
||||
set too tight and filling the queue with noise — either way it is what an officer reads first, and the long tail
|
||||
below it can wait. Ranked over the period and status already chosen. -->
|
||||
<div class="status-filters">
|
||||
<span class="status-filters-label">Top rules</span>
|
||||
@for (n of topRuleOptions; track n) {
|
||||
<button type="button" class="status-chip" [class.active]="topRules === n" (click)="setTopRules(n)">
|
||||
{{ n === null ? 'All rules' : 'Top ' + n }}
|
||||
</button>
|
||||
}
|
||||
@if (topRules !== null) {
|
||||
<span class="period-resolved">{{ distinctRuleCount }} rule{{ distinctRuleCount === 1 ? '' : 's' }} in view</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Which rules are driving the queue, and by how much. Ranking without the counts narrows the table and leaves the
|
||||
officer to work out what it kept. -->
|
||||
@if (ruleFrequency.length > 1) {
|
||||
<div class="rule-freq">
|
||||
@for (r of ruleFrequency; track r.code) {
|
||||
<span class="rule-freq-item">
|
||||
{{ r.code }}
|
||||
<span class="rule-freq-n">{{ r.count }}</span>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- 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">
|
||||
@@ -103,7 +188,9 @@
|
||||
@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-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
|
||||
@@ -227,7 +314,10 @@
|
||||
@if (isExpanded(alert)) {
|
||||
<div class="detail-inner">
|
||||
<div class="detail-head">
|
||||
<span>Rules matched by {{ alert.transactionType | lowercase }} #{{ alert.transactionId }} — {{ amount(alert) }}</span>
|
||||
<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' }}
|
||||
@@ -273,15 +363,25 @@
|
||||
"
|
||||
matTooltipClass="evidence-tooltip"
|
||||
>
|
||||
{{ !rule.directlyDecided ? 'remove_circle_outline' : rule.disposition === 'ESCALATE' ? 'report' : 'verified' }}
|
||||
{{
|
||||
!rule.directlyDecided
|
||||
? 'remove_circle_outline'
|
||||
: rule.disposition === 'ESCALATE'
|
||||
? 'report'
|
||||
: 'verified'
|
||||
}}
|
||||
</mat-icon>
|
||||
}
|
||||
<span class="sev-chip" [ngClass]="severityClass(rule.riskCategory)">{{ severityLabel(rule.riskCategory) }}</span>
|
||||
<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>
|
||||
<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. -->
|
||||
@@ -317,10 +417,17 @@
|
||||
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="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')">
|
||||
<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')">
|
||||
@@ -384,23 +491,41 @@
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-template #reviewed>
|
||||
<div class="reviewed-cell">
|
||||
<span class="reviewed-by">
|
||||
{{ alert.reviewedByUsername ? 'by ' + alert.reviewedByUsername : 'Reviewed' }}
|
||||
</span>
|
||||
<!-- The evidence recorded at review time, so the trail is readable without leaving the feed -->
|
||||
<mat-icon
|
||||
*ngIf="alert.reviewNotes"
|
||||
class="reviewed-evidence-icon"
|
||||
[matTooltip]="alert.reviewNotes"
|
||||
matTooltipClass="evidence-tooltip"
|
||||
>
|
||||
description
|
||||
</mat-icon>
|
||||
<span *ngIf="alert.status === 'ESCALATED'" class="str-routed" matTooltip="An STR case is open for this alert">
|
||||
→ STR
|
||||
</span>
|
||||
</div>
|
||||
<!-- Held by somebody else: not a verdict, so it does not read like one. The endpoint refuses a disposition
|
||||
from anyone but the officer who has it, and this says who to ask. -->
|
||||
@if (heldByAnother(alert)) {
|
||||
<div class="reviewed-cell">
|
||||
<mat-icon
|
||||
class="held-icon"
|
||||
[matTooltip]="'Open with ' + heldBy(alert) + ' — they decide it or hand it back'"
|
||||
>
|
||||
lock
|
||||
</mat-icon>
|
||||
<span class="held-by">with {{ heldBy(alert) }}</span>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="reviewed-cell">
|
||||
<span class="reviewed-by">
|
||||
{{ alert.reviewedByUsername ? 'by ' + alert.reviewedByUsername : 'Reviewed' }}
|
||||
</span>
|
||||
<!-- The evidence recorded at review time, so the trail is readable without leaving the feed -->
|
||||
<mat-icon
|
||||
*ngIf="alert.reviewNotes"
|
||||
class="reviewed-evidence-icon"
|
||||
[matTooltip]="alert.reviewNotes"
|
||||
matTooltipClass="evidence-tooltip"
|
||||
>
|
||||
description
|
||||
</mat-icon>
|
||||
<span
|
||||
*ngIf="alert.status === 'ESCALATED'"
|
||||
class="str-routed"
|
||||
matTooltip="An STR case is open for this alert"
|
||||
>
|
||||
→ STR
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</ng-template>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
Reference in New Issue
Block a user