564 lines
24 KiB
HTML
564 lines
24 KiB
HTML
<!--
|
|
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="flagged-toolbar">
|
|
<div class="flagged-live">
|
|
<span class="live-dot" [class.paused]="!live"></span>
|
|
<span class="live-label">{{ live ? 'Live' : 'Paused' }}</span>
|
|
<span *ngIf="lastUpdated" class="live-updated">Updated {{ lastUpdated | date: 'HH:mm:ss' }}</span>
|
|
<mat-spinner *ngIf="loading" diameter="16" class="live-spinner"></mat-spinner>
|
|
</div>
|
|
|
|
<div class="flagged-actions">
|
|
<button
|
|
*ngIf="newAlertCount > 0"
|
|
type="button"
|
|
class="new-alert-pill"
|
|
matTooltip="Clear the new-alert highlight"
|
|
(click)="acknowledgeNew()"
|
|
>
|
|
<mat-icon>notifications_active</mat-icon>
|
|
{{ newAlertCount }} new
|
|
</button>
|
|
<button
|
|
mat-icon-button
|
|
type="button"
|
|
[matTooltip]="live ? 'Pause live updates' : 'Resume live updates'"
|
|
(click)="toggleLive()"
|
|
>
|
|
<mat-icon>{{ live ? 'pause_circle' : 'play_circle' }}</mat-icon>
|
|
</button>
|
|
<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>
|
|
|
|
<div *ngIf="loadFailed" class="flagged-error">
|
|
<mat-icon>cloud_off</mat-icon>
|
|
<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>
|
|
<button
|
|
*ngFor="let s of statusFilters"
|
|
type="button"
|
|
class="status-chip"
|
|
[class.active]="activeStatus === s.key"
|
|
[disabled]="statusCount(s.key) === 0 && activeStatus !== s.key"
|
|
(click)="setStatus(s.key)"
|
|
>
|
|
{{ s.label }}
|
|
<span class="status-chip-n">{{ statusCount(s.key) }}</span>
|
|
</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">
|
|
<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>
|
|
</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>
|
|
</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>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- 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>
|
|
<td mat-cell *matCellDef="let alert" class="raised-cell">
|
|
<span class="raised-time">{{
|
|
alert.raisedOnDate ? (alert.raisedOnDate | date: 'dd MMM yyyy, HH:mm') : '—'
|
|
}}</span>
|
|
<span *ngIf="isNew(alert)" class="new-badge">NEW</span>
|
|
</td>
|
|
</ng-container>
|
|
|
|
<!-- Which transaction tripped the rule -->
|
|
<ng-container matColumnDef="transaction">
|
|
<th mat-header-cell *matHeaderCellDef>Transaction</th>
|
|
<td mat-cell *matCellDef="let alert">
|
|
<span [ngClass]="txnTypeClass(alert.transactionType)">{{ alert.transactionType }}</span>
|
|
<span class="txn-id">#{{ alert.transactionId }}</span>
|
|
</td>
|
|
</ng-container>
|
|
|
|
<ng-container matColumnDef="client">
|
|
<th mat-header-cell *matHeaderCellDef>Client</th>
|
|
<td mat-cell *matCellDef="let alert">
|
|
<div class="stacked-cell">
|
|
<span>{{ alert.clientDisplayName || '—' }}</span>
|
|
<span *ngIf="alert.clientId" class="cell-sub">ID {{ alert.clientId }}</span>
|
|
</div>
|
|
</td>
|
|
</ng-container>
|
|
|
|
<ng-container matColumnDef="rule">
|
|
<th mat-header-cell *matHeaderCellDef>Rule Triggered</th>
|
|
<td mat-cell *matCellDef="let alert" class="rule-cell">
|
|
<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>
|
|
|
|
<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>
|
|
</ng-container>
|
|
|
|
<ng-container matColumnDef="status">
|
|
<th mat-header-cell *matHeaderCellDef class="col-center">Status</th>
|
|
<td mat-cell *matCellDef="let alert" class="col-center">
|
|
<span [ngClass]="statusClass(alert.status)">{{ alert.status }}</span>
|
|
</td>
|
|
</ng-container>
|
|
|
|
<!-- 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" (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. -->
|
|
<div class="action-group" *mifosxHasPermission="'REVIEW_AML_ALERT'">
|
|
<button
|
|
type="button"
|
|
class="action-btn action-whitelist"
|
|
matTooltip="Clear this transaction with no report"
|
|
(click)="review(alert, 'WHITELIST')"
|
|
>
|
|
<mat-icon>verified</mat-icon>
|
|
Whitelist
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="action-btn action-suspicious"
|
|
matTooltip="Raise as suspicious — opens an STR case"
|
|
(click)="review(alert, 'ESCALATE')"
|
|
>
|
|
<mat-icon>report</mat-icon>
|
|
Suspicious
|
|
</button>
|
|
</div>
|
|
</ng-container>
|
|
<ng-template #reviewed>
|
|
<!-- 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>
|
|
|
|
<tr mat-header-row *matHeaderRowDef="displayedColumns"></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>
|
|
|
|
<!-- 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 || 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 || activeSeverity !== null">No alerts match the filters in force.</span>
|
|
</div>
|