feat: KYC risk profiling, AML flagged transactions bulk review, Risk Review report & Board Resolution register
This commit is contained in:
+419
-19
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component, OnInit, OnDestroy, inject, ViewChild } from '@angular/core';
|
||||
import { Component, OnInit, AfterViewInit, OnDestroy, inject, ViewChild } from '@angular/core';
|
||||
import { MatTableModule, MatTableDataSource } from '@angular/material/table';
|
||||
import { MatPaginatorModule, MatPaginator } from '@angular/material/paginator';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
@@ -15,6 +15,7 @@ import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Subject, timer, of } from 'rxjs';
|
||||
import { switchMap, takeUntil, catchError, filter } from 'rxjs/operators';
|
||||
@@ -24,6 +25,7 @@ import { AmlService } from '../../../aml/aml.service';
|
||||
import { AlertService } from '../../../core/alert/alert.service';
|
||||
import { HasPermissionDirective } from '../../../directives/has-permission/has-permission.directive';
|
||||
import { AmlReviewAlertDialogComponent } from './aml-review-alert-dialog.component';
|
||||
import { AmlBulkReviewDialogComponent } from './aml-bulk-review-dialog.component';
|
||||
|
||||
/**
|
||||
* Live feed of transactions the AML rule engine has flagged.
|
||||
@@ -48,10 +50,11 @@ import { AmlReviewAlertDialogComponent } from './aml-review-alert-dialog.compone
|
||||
MatTooltipModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatDialogModule,
|
||||
MatCheckboxModule,
|
||||
HasPermissionDirective
|
||||
]
|
||||
})
|
||||
export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy {
|
||||
export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
private amlService = inject(AmlService);
|
||||
private dialog = inject(MatDialog);
|
||||
private alertService = inject(AlertService);
|
||||
@@ -62,6 +65,7 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy {
|
||||
|
||||
alerts = new MatTableDataSource<any>([]);
|
||||
displayedColumns: string[] = [
|
||||
'select',
|
||||
'raisedOn',
|
||||
'transaction',
|
||||
'client',
|
||||
@@ -71,6 +75,283 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy {
|
||||
'actions'
|
||||
];
|
||||
|
||||
/**
|
||||
* Alerts ticked for a single disposition across several transactions.
|
||||
*
|
||||
* <p>
|
||||
* Low and medium-severity alerts arrive in numbers and are mostly the same decision repeated -
|
||||
* a run of ordinary cash deposits that each tripped a threshold. Dealing with them one dialog at
|
||||
* a time is what stops an officer getting to the one alert that matters. High severity is
|
||||
* deliberately excluded: an alert that reached the high band is a judgement about that customer's
|
||||
* transaction, and it gets its own decision.
|
||||
*/
|
||||
selectedIds = new Set<number>();
|
||||
|
||||
/** m_client_risk_rule.risk_category — 3 is the high band. */
|
||||
private static readonly HIGH_SEVERITY = 3;
|
||||
|
||||
/**
|
||||
* Whether any rule this transaction matched reached the high band.
|
||||
*
|
||||
* <p>
|
||||
* Read from highestSeverity, not from the governing rule's own riskCategory. One transaction
|
||||
* raises one alert under whichever rule most warrants attention, and a statutory CTR outranks
|
||||
* everything — so a high-severity rule can sit in "also matched" while the row itself reports the
|
||||
* CTR, which carries no severity at all. Judging the row by the governing rule would offer such a
|
||||
* transaction up for a batch decision.
|
||||
*/
|
||||
isHighSeverity(alert: any): boolean {
|
||||
return Number(alert?.highestSeverity) === AmlFlaggedTransactionsComponent.HIGH_SEVERITY;
|
||||
}
|
||||
|
||||
/**
|
||||
* The band this alert counts as, for the summary tiles.
|
||||
*
|
||||
* <p>
|
||||
* The same figure the checkbox is gated on, so the tiles and the table cannot disagree. Reading
|
||||
* riskCategory here put a ₹12,00,000 cash deposit that had tripped a high-severity rule under
|
||||
* "Unclassified", because the alert reports under the statutory CTR and a CTR carries no band of
|
||||
* its own. Alerts raised before highest_severity existed fall back to the governing rule's band.
|
||||
*/
|
||||
private severityBand(alert: any): number {
|
||||
const worst = Number(alert?.highestSeverity);
|
||||
if (worst === 1 || worst === 2 || worst === 3) {
|
||||
return worst;
|
||||
}
|
||||
const governing = Number(alert?.riskCategory);
|
||||
if (governing === 1 || governing === 2 || governing === 3) {
|
||||
return governing;
|
||||
}
|
||||
// 0, never NaN: the Unclassified tile filters on this value, and NaN === NaN is false, so a
|
||||
// NaN band would leave that tile showing a count it could never select.
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Only an open alert whose every matched rule sits in the low or medium band can be swept up with
|
||||
* others.
|
||||
*
|
||||
* <p>
|
||||
* Two things are held back. A transaction that tripped any high-severity rule, because that is a
|
||||
* judgement about the transaction itself. And one where no matched rule carried a severity at
|
||||
* all, which is how the CTR rules arrive - they are not graded, being returns the bank owes
|
||||
* rather than suspicions. Neither belongs in a batch disposed of on a single reason.
|
||||
*/
|
||||
isSelectable(alert: any): boolean {
|
||||
const band = Number(alert?.highestSeverity);
|
||||
const graded = band === 1 || band === 2;
|
||||
return this.canReview(alert) && graded;
|
||||
}
|
||||
|
||||
/** The alert whose detail row is open. One at a time: this is a reading pane, not a comparison. */
|
||||
expandedId: number | null = null;
|
||||
|
||||
|
||||
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.selectedRuleCodes.clear();
|
||||
}
|
||||
|
||||
isExpanded(alert: any): boolean {
|
||||
return this.expandedId === alert.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every rule the transaction matched, governing one first.
|
||||
*
|
||||
* <p>
|
||||
* Served by the API. The fallback rebuilds the list from the flat fields for an alert read before the API carried
|
||||
* it, so an older row still expands to something rather than appearing to have matched nothing.
|
||||
*/
|
||||
matchedRules(alert: any): { ruleCode: string; ruleDescription: string; riskCategory: number | null; governing: boolean }[] {
|
||||
if (Array.isArray(alert?.matchedRules) && alert.matchedRules.length) {
|
||||
return alert.matchedRules;
|
||||
}
|
||||
const rules = [
|
||||
{
|
||||
ruleCode: alert?.ruleCode,
|
||||
ruleDescription: alert?.ruleDescription,
|
||||
riskCategory: alert?.riskCategory ?? null,
|
||||
governing: true
|
||||
}
|
||||
];
|
||||
for (const code of String(alert?.alsoMatchedRules || '')
|
||||
.split(',')
|
||||
.map((c: string) => c.trim())
|
||||
.filter((c: string) => c)) {
|
||||
rules.push({ ruleCode: code, ruleDescription: code, riskCategory: null, governing: false });
|
||||
}
|
||||
return rules;
|
||||
}
|
||||
|
||||
/** How many rules the transaction matched, for the count shown on the collapsed row. */
|
||||
matchedRuleCount(alert: any): number {
|
||||
return this.matchedRules(alert).length;
|
||||
}
|
||||
|
||||
severityLabel(band: number | null | undefined): string {
|
||||
switch (Number(band)) {
|
||||
case 1:
|
||||
return 'Low';
|
||||
case 2:
|
||||
return 'Medium';
|
||||
case 3:
|
||||
return 'High';
|
||||
default:
|
||||
return 'Statutory';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rule codes ticked inside the open detail pane.
|
||||
*
|
||||
* <p>
|
||||
* Cleared whenever a different alert is opened: the codes are only meaningful against the alert they were ticked on,
|
||||
* and carrying them across would apply one transaction's selection to another's rules.
|
||||
*/
|
||||
selectedRuleCodes = new Set<string>();
|
||||
|
||||
isRuleSelected(rule: any): boolean {
|
||||
return this.selectedRuleCodes.has(rule.ruleCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this rule is the officer's to decide.
|
||||
*
|
||||
* <p>
|
||||
* A rule already decided keeps its verdict. A rule with no severity band is not a graded suspicion at all - the CTR
|
||||
* rules are statutory returns the bank owes FIU-IND on any cash transaction at or above ten lakh, filed on the
|
||||
* amount whether or not the customer is above suspicion. There is no "whitelist" for one: clearing the transaction
|
||||
* does not stop the CTR, and escalating it does not describe what the rule found.
|
||||
*/
|
||||
isRuleSelectable(rule: any): boolean {
|
||||
return !rule?.disposition && this.isGradedRule(rule);
|
||||
}
|
||||
|
||||
/** A rule that carries a severity band, as against a statutory reporting rule that carries none. */
|
||||
isGradedRule(rule: any): boolean {
|
||||
const band = Number(rule?.riskCategory);
|
||||
return band === 1 || band === 2 || band === 3;
|
||||
}
|
||||
|
||||
toggleRuleSelected(rule: any): void {
|
||||
if (!this.isRuleSelectable(rule)) {
|
||||
return;
|
||||
}
|
||||
if (this.selectedRuleCodes.has(rule.ruleCode)) {
|
||||
this.selectedRuleCodes.delete(rule.ruleCode);
|
||||
} else {
|
||||
this.selectedRuleCodes.add(rule.ruleCode);
|
||||
}
|
||||
}
|
||||
|
||||
selectableRulesOf(alert: any): any[] {
|
||||
return this.matchedRules(alert).filter((r) => this.isRuleSelectable(r));
|
||||
}
|
||||
|
||||
get selectedRuleCount(): number {
|
||||
return this.selectedRuleCodes.size;
|
||||
}
|
||||
|
||||
allRulesSelected(alert: any): boolean {
|
||||
const open = this.selectableRulesOf(alert);
|
||||
return open.length > 0 && open.every((r) => this.selectedRuleCodes.has(r.ruleCode));
|
||||
}
|
||||
|
||||
toggleAllRules(alert: any): void {
|
||||
if (this.allRulesSelected(alert)) {
|
||||
this.selectedRuleCodes.clear();
|
||||
return;
|
||||
}
|
||||
for (const rule of this.selectableRulesOf(alert)) {
|
||||
this.selectedRuleCodes.add(rule.ruleCode);
|
||||
}
|
||||
}
|
||||
|
||||
ruleDispositionLabel(rule: any): string {
|
||||
return rule?.disposition === 'ESCALATE' ? 'Escalated' : rule?.disposition === 'WHITELIST' ? 'Whitelisted' : '';
|
||||
}
|
||||
|
||||
severityClass(band: number | null | undefined): string {
|
||||
switch (Number(band)) {
|
||||
case 1:
|
||||
return 'sev-low';
|
||||
case 2:
|
||||
return 'sev-medium';
|
||||
case 3:
|
||||
return 'sev-high';
|
||||
default:
|
||||
return 'sev-none';
|
||||
}
|
||||
}
|
||||
|
||||
isSelected(alert: any): boolean {
|
||||
return this.selectedIds.has(alert.id);
|
||||
}
|
||||
|
||||
toggleSelected(alert: any): void {
|
||||
if (!this.isSelectable(alert)) {
|
||||
return;
|
||||
}
|
||||
if (this.selectedIds.has(alert.id)) {
|
||||
this.selectedIds.delete(alert.id);
|
||||
} else {
|
||||
this.selectedIds.add(alert.id);
|
||||
}
|
||||
}
|
||||
|
||||
/** The rows a "select all" would act on: what is on screen, open, and below the high band. */
|
||||
get selectableRows(): any[] {
|
||||
return this.alerts.data.filter((a) => this.isSelectable(a));
|
||||
}
|
||||
|
||||
get allSelectableSelected(): boolean {
|
||||
const rows = this.selectableRows;
|
||||
return rows.length > 0 && rows.every((a) => this.selectedIds.has(a.id));
|
||||
}
|
||||
|
||||
toggleSelectAll(): void {
|
||||
if (this.allSelectableSelected) {
|
||||
this.selectedIds.clear();
|
||||
return;
|
||||
}
|
||||
for (const a of this.selectableRows) {
|
||||
this.selectedIds.add(a.id);
|
||||
}
|
||||
}
|
||||
|
||||
get selectedCount(): number {
|
||||
return this.selectedIds.size;
|
||||
}
|
||||
|
||||
private selectedAlerts(): any[] {
|
||||
return this.alerts.data.filter((a) => this.selectedIds.has(a.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops ticks that no longer stand, after every poll.
|
||||
*
|
||||
* <p>
|
||||
* The feed refreshes itself every few seconds while the officer works. Without this, an alert
|
||||
* another officer disposed of in the meantime — or one the status filter has since hidden — stays
|
||||
* in the set, and the bar goes on offering to decide it: the count reads higher than the ticks on
|
||||
* screen, and the batch carries alerts nobody chose.
|
||||
*/
|
||||
private pruneSelection(): void {
|
||||
const stillSelectable = new Set(this.selectableRows.map((a) => a.id));
|
||||
for (const id of Array.from(this.selectedIds)) {
|
||||
if (!stillSelectable.has(id)) {
|
||||
this.selectedIds.delete(id);
|
||||
}
|
||||
}
|
||||
// Same for the open detail pane: an alert filtered away or disposed of elsewhere should not
|
||||
// leave the table holding a pane for a row that is no longer on it.
|
||||
if (this.expandedId !== null && !this.alerts.data.some((a) => a.id === this.expandedId)) {
|
||||
this.expandedId = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Every alert as returned, before the status filter narrows the table. */
|
||||
private allAlerts: any[] = [];
|
||||
|
||||
@@ -84,12 +365,19 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy {
|
||||
activeStatus = '';
|
||||
private statusCounts: Record<string, number> = {};
|
||||
|
||||
/**
|
||||
* The severity tile currently narrowing the table: 1 Low, 2 Medium, 3 High, 0 unclassified, or
|
||||
* null for all. Independent of {@link activeStatus} — the two compose, so an officer can ask for
|
||||
* the high-severity alerts that are still open.
|
||||
*/
|
||||
activeSeverity: number | null = null;
|
||||
|
||||
/**
|
||||
* Severity mix of the flagged transactions currently in the table. riskCategory is the severity
|
||||
* of the rule that fired, resolved server-side from the originating catalog entry — the same
|
||||
* field the Rules Master tab shows in its Risk Category column.
|
||||
*/
|
||||
riskSummary: { label: string; count: number; cls: string }[] = [];
|
||||
riskSummary: { key: number; label: string; count: number; cls: string }[] = [];
|
||||
/** Alerts whose rule has no catalog ancestor (MANUAL_REFERRAL), so no severity to report. */
|
||||
unclassifiedCount = 0;
|
||||
|
||||
@@ -106,6 +394,18 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy {
|
||||
|
||||
@ViewChild(MatPaginator) paginator!: MatPaginator;
|
||||
|
||||
/**
|
||||
* Binds the paginator as soon as the view exists.
|
||||
*
|
||||
* <p>
|
||||
* The first poll can return before the view has initialised, and until this the data source was
|
||||
* only ever wired up as a side effect of a later filter pass — so the table could render every
|
||||
* alert unpaginated, then jump once the next poll arrived.
|
||||
*/
|
||||
ngAfterViewInit(): void {
|
||||
this.alerts.paginator = this.paginator;
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.fetch();
|
||||
timer(AmlFlaggedTransactionsComponent.POLL_INTERVAL_MS, AmlFlaggedTransactionsComponent.POLL_INTERVAL_MS)
|
||||
@@ -158,7 +458,22 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy {
|
||||
|
||||
setStatus(key: string): void {
|
||||
this.activeStatus = key;
|
||||
this.applyStatusFilter();
|
||||
this.applyStatusFilter(true);
|
||||
}
|
||||
|
||||
/** Clicking the tile already in force clears it, so the same click both narrows and undoes. */
|
||||
setSeverity(band: number): void {
|
||||
this.activeSeverity = this.activeSeverity === band ? null : band;
|
||||
this.applyStatusFilter(true);
|
||||
}
|
||||
|
||||
isSeverityActive(band: number): boolean {
|
||||
return this.activeSeverity === band;
|
||||
}
|
||||
|
||||
clearSeverity(): void {
|
||||
this.activeSeverity = null;
|
||||
this.applyStatusFilter(true);
|
||||
}
|
||||
|
||||
statusCount(key: string): number {
|
||||
@@ -203,47 +518,63 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy {
|
||||
this.allAlerts = list;
|
||||
this.recomputeStatusCounts();
|
||||
this.applyStatusFilter();
|
||||
this.pruneSelection();
|
||||
this.lastUpdated = new Date();
|
||||
}
|
||||
|
||||
private applyStatusFilter(): void {
|
||||
this.alerts.data = this.activeStatus
|
||||
/**
|
||||
* @param resetPage
|
||||
* true only when the user changed a filter. A poll must not reset the page: the feed
|
||||
* refreshes every few seconds, and an officer reading page 3 would be thrown back to
|
||||
* page 1 each time. Nor may it touch the paginator before the data source has bound to
|
||||
* it — doing that on the first pass left pageSize at 0 and emptied the table.
|
||||
*/
|
||||
private applyStatusFilter(resetPage = false): void {
|
||||
const byStatus = this.activeStatus
|
||||
? this.allAlerts.filter((a) => a.status === this.activeStatus)
|
||||
: this.allAlerts;
|
||||
this.alerts.data =
|
||||
this.activeSeverity === null ? byStatus : byStatus.filter((a) => this.severityBand(a) === this.activeSeverity);
|
||||
if (!this.alerts.paginator && this.paginator) {
|
||||
this.alerts.paginator = this.paginator;
|
||||
}
|
||||
this.recomputeRiskSummary();
|
||||
if (resetPage && this.alerts.paginator) {
|
||||
// Otherwise a filter applied while on page 2 leaves the table blank on a one-page result.
|
||||
this.alerts.paginator.firstPage();
|
||||
}
|
||||
// Counted before the severity filter, so every tile keeps its real figure and stays clickable.
|
||||
// Counting the filtered rows would zero the other tiles the moment one was picked, leaving no
|
||||
// way back to them.
|
||||
this.recomputeRiskSummary(byStatus);
|
||||
}
|
||||
|
||||
/**
|
||||
* Counted over the rows the status filter leaves visible, not the whole set — a summary that
|
||||
* disagreed with the table underneath it would be worse than no summary.
|
||||
*/
|
||||
private recomputeRiskSummary(): void {
|
||||
const pool = this.alerts.data;
|
||||
private recomputeRiskSummary(pool: any[]): void {
|
||||
const tiers = [
|
||||
{ key: 3, label: 'High Risk', cls: 'risk-high' },
|
||||
{ key: 2, label: 'Medium Risk', cls: 'risk-medium' },
|
||||
{ key: 1, label: 'Low Risk', cls: 'risk-low' }
|
||||
];
|
||||
this.riskSummary = tiers.map((t) => ({
|
||||
key: t.key,
|
||||
label: t.label,
|
||||
cls: t.cls,
|
||||
count: pool.filter((a) => Number(a.riskCategory) === t.key).length
|
||||
count: pool.filter((a) => this.severityBand(a) === t.key).length
|
||||
}));
|
||||
// Anything outside 1-3, including null, so the tiles always reconcile to the total.
|
||||
this.unclassifiedCount = pool.filter(
|
||||
(a) => ![
|
||||
1,
|
||||
2,
|
||||
3
|
||||
].includes(Number(a.riskCategory))
|
||||
).length;
|
||||
// Band 0 is everything ungraded, so the tiles always reconcile to the total.
|
||||
this.unclassifiedCount = pool.filter((a) => this.severityBand(a) === 0).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the status filter leaves, whatever severity tile is in force — so the tiles still
|
||||
* add up to the total while one of them is narrowing the table, and the total reads as the way
|
||||
* back to all of them.
|
||||
*/
|
||||
get summaryTotal(): number {
|
||||
return this.alerts.data.length;
|
||||
return this.activeStatus ? this.allAlerts.filter((a) => a.status === this.activeStatus).length : this.allAlerts.length;
|
||||
}
|
||||
|
||||
private recomputeStatusCounts(): void {
|
||||
@@ -330,4 +661,73 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy {
|
||||
this.fetch();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies one disposition to every ticked alert.
|
||||
*
|
||||
* <p>
|
||||
* Each still goes through the review dialog's own requirements - a reason, and for a whitelist
|
||||
* the evidence behind it - asked once and recorded against every alert in the batch. That is the
|
||||
* point of the batch: the officer is making one decision about one kind of activity, not the same
|
||||
* decision twenty times.
|
||||
*/
|
||||
/**
|
||||
* Applies one disposition to the rules ticked inside the open pane.
|
||||
*
|
||||
* <p>
|
||||
* The alert's own status is left to the server, which derives it from what remains: whitelisting five of six rules
|
||||
* leaves the case open on the sixth rather than closing it.
|
||||
*/
|
||||
reviewSelectedRules(alert: any, disposition: 'WHITELIST' | 'ESCALATE'): void {
|
||||
const codes = Array.from(this.selectedRuleCodes);
|
||||
if (!codes.length) {
|
||||
return;
|
||||
}
|
||||
this.dialog
|
||||
.open(AmlReviewAlertDialogComponent, {
|
||||
width: '560px',
|
||||
maxWidth: '95vw',
|
||||
autoFocus: false,
|
||||
disableClose: true,
|
||||
data: { alert, disposition, ruleCodes: codes }
|
||||
})
|
||||
.afterClosed()
|
||||
.subscribe((result: string | undefined) => {
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
this.alertService.alert({
|
||||
type: disposition === 'WHITELIST' ? 'Rules Whitelisted' : 'Rules Escalated',
|
||||
message: `${codes.length} rule${codes.length > 1 ? 's' : ''} on ${alert.transactionType} #${alert.transactionId} ${
|
||||
disposition === 'WHITELIST' ? 'cleared' : 'escalated'
|
||||
}.`
|
||||
});
|
||||
this.selectedRuleCodes.clear();
|
||||
// The alert's status may have changed with the last rule decided, so re-read rather than patching locally.
|
||||
this.fetch();
|
||||
});
|
||||
}
|
||||
|
||||
reviewSelected(disposition: 'WHITELIST' | 'ESCALATE'): void {
|
||||
const batch = this.selectedAlerts();
|
||||
if (!batch.length) {
|
||||
return;
|
||||
}
|
||||
this.dialog
|
||||
.open(AmlBulkReviewDialogComponent, {
|
||||
width: '620px',
|
||||
maxWidth: '95vw',
|
||||
autoFocus: false,
|
||||
disableClose: true,
|
||||
data: { alerts: batch, disposition }
|
||||
})
|
||||
.afterClosed()
|
||||
.subscribe((done: boolean | undefined) => {
|
||||
if (!done) {
|
||||
return;
|
||||
}
|
||||
this.selectedIds.clear();
|
||||
this.fetch();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user