risk review report with on which aml rule
This commit is contained in:
@@ -0,0 +1,726 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { catchError, map } from 'rxjs/operators';
|
||||
|
||||
/** Custom Modules */
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
|
||||
/** Custom Services */
|
||||
import { AmlService } from '../aml/aml.service';
|
||||
import { ClientsService } from '../clients/clients.service';
|
||||
import { BranchScopeService } from '../core/branch-scope.service';
|
||||
|
||||
/** Shared report helpers */
|
||||
import { exportWorkbook } from '../cashier/cashier-export';
|
||||
import { GL_BRANCHES, GL_PERIOD, glBranchName } from '../gl-reports/gl-reports-data';
|
||||
|
||||
/** Report data + computation */
|
||||
import {
|
||||
AML_RULE_CATALOGUE,
|
||||
LiveAmlAlert,
|
||||
REVIEW_STATUSES,
|
||||
REVIEW_STATUS_META,
|
||||
RISK_LABEL,
|
||||
ReviewStatus,
|
||||
RiskAlertRow,
|
||||
RiskLevel,
|
||||
channelLabel,
|
||||
mapLiveAlert,
|
||||
riskFromCategory,
|
||||
sampleRiskAlerts
|
||||
} from './risk-review-data';
|
||||
import {
|
||||
DateRange,
|
||||
RatingDirection,
|
||||
RiskAlertView,
|
||||
RiskCustomerRow,
|
||||
RiskReviewFilter,
|
||||
RiskReviewModel,
|
||||
RiskReviewSummary,
|
||||
RiskTxnView,
|
||||
buildRiskReview,
|
||||
monthRange,
|
||||
weekRange
|
||||
} from './risk-review.compute';
|
||||
|
||||
type PeriodMode = 'day' | 'week' | 'month' | 'range';
|
||||
type ViewMode = 'customer' | 'alert' | 'txn';
|
||||
|
||||
/** Starting value for the memo, used before the first filter pass runs. */
|
||||
const EMPTY_SUMMARY: RiskReviewSummary = {
|
||||
customers: 0,
|
||||
alerts: 0,
|
||||
txns: 0,
|
||||
ctrReportable: 0,
|
||||
openAlerts: 0,
|
||||
escalatedCustomers: 0,
|
||||
upgradeCandidates: 0,
|
||||
downgradeCandidates: 0,
|
||||
totalAmount: 0,
|
||||
byRisk: { LOW: 0, MEDIUM: 0, HIGH: 0, UNRATED: 0 },
|
||||
byRule: [],
|
||||
byStatus: []
|
||||
};
|
||||
|
||||
/**
|
||||
* Risk Review Report — the AML/compliance worklist.
|
||||
*
|
||||
* Lists the customers who tripped an AML rule in the period, the transaction
|
||||
* that tripped it, the rating currently on their KYC record and where the
|
||||
* review has got to. The reviewer's decision on each case is one of: keep the
|
||||
* rating, move it, refresh KYC, or escalate to the Principal Officer for an
|
||||
* STR — which is the vocabulary in `REVIEW_STATUS_META`.
|
||||
*
|
||||
* Rows come from the live rule engine. If the engine has raised nothing (or is
|
||||
* unreachable) the report falls back to a deterministic sample set and says so
|
||||
* on screen and in the workbook, so sample rows are never taken for live data.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-risk-review-report',
|
||||
templateUrl: './risk-review-report.component.html',
|
||||
styleUrls: ['./risk-review-report.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
FaIconComponent,
|
||||
FormsModule
|
||||
]
|
||||
})
|
||||
export class RiskReviewReportComponent implements OnInit {
|
||||
private aml = inject(AmlService);
|
||||
private clients = inject(ClientsService);
|
||||
private scope = inject(BranchScopeService);
|
||||
|
||||
readonly period = GL_PERIOD;
|
||||
readonly branches = GL_BRANCHES;
|
||||
readonly rules = AML_RULE_CATALOGUE;
|
||||
readonly reviewStatuses = REVIEW_STATUSES;
|
||||
readonly statusMeta = REVIEW_STATUS_META;
|
||||
readonly riskLabel = RISK_LABEL;
|
||||
readonly channelLabel = channelLabel;
|
||||
|
||||
readonly periodModes: { key: PeriodMode; label: string }[] = [
|
||||
{ key: 'day', label: 'Day wise' },
|
||||
{ key: 'week', label: 'Week wise' },
|
||||
{ key: 'month', label: 'Month wise' },
|
||||
{ key: 'range', label: 'Between dates' }
|
||||
];
|
||||
|
||||
// ── Source state ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Rows returned by the monitoring engine, mapped onto the report shape. */
|
||||
private liveAlerts: RiskAlertRow[] = [];
|
||||
loading = true;
|
||||
|
||||
/**
|
||||
* Live alerts and the demonstration set, in one worklist.
|
||||
*
|
||||
* Every row keeps its `live` flag and sample rows are tagged in the table, so
|
||||
* a reviewer can still tell them apart at a glance.
|
||||
*/
|
||||
private alerts: RiskAlertRow[] = [];
|
||||
|
||||
get hasLive(): boolean {
|
||||
return this.liveAlerts.length > 0;
|
||||
}
|
||||
|
||||
get sampleCount(): number {
|
||||
return this.alerts.filter((a) => !a.live).length;
|
||||
}
|
||||
|
||||
// ── Filter state ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Alerts raised today must be in view, so the default window runs to whichever
|
||||
* is later — the seeded period end or the real current date.
|
||||
*/
|
||||
private static readonly TODAY = new Date().toISOString().slice(0, 10);
|
||||
private static readonly WINDOW_END =
|
||||
RiskReviewReportComponent.TODAY > GL_PERIOD.fyEnd ? RiskReviewReportComponent.TODAY : GL_PERIOD.fyEnd;
|
||||
|
||||
periodMode: PeriodMode = 'range';
|
||||
filterDay = RiskReviewReportComponent.WINDOW_END;
|
||||
/** Any date inside the wanted week; resolved to Mon–Sat. */
|
||||
filterWeek = '2026-07-27';
|
||||
filterMonth = '2026-07';
|
||||
filterFrom = GL_PERIOD.fyStart;
|
||||
filterTo = RiskReviewReportComponent.WINDOW_END;
|
||||
branchCode = '';
|
||||
currentRisk: RiskLevel | '' = '';
|
||||
ruleCode = '';
|
||||
status: ReviewStatus | '' = '';
|
||||
/** Which way the review says the rating should move. '' shows every customer. */
|
||||
direction: RatingDirection | '' = '';
|
||||
search = '';
|
||||
openOnly = false;
|
||||
|
||||
view: ViewMode = 'customer';
|
||||
/** Client ID of the customer row currently expanded. */
|
||||
expanded: number | null = null;
|
||||
|
||||
generating = false;
|
||||
toastMsg = '';
|
||||
toastVisible = false;
|
||||
private toastTimer: any;
|
||||
|
||||
ngOnInit(): void {
|
||||
// A branch login opens on its own book and cannot move off it.
|
||||
this.branchCode = this.scope.defaultBranchCode;
|
||||
this.load();
|
||||
}
|
||||
|
||||
// ── Loading ────────────────────────────────────────────────────────────────
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.aml
|
||||
.getAlerts()
|
||||
.pipe(catchError(() => of([] as LiveAmlAlert[])))
|
||||
.subscribe((live) => {
|
||||
const rows = (live || []).map((a) => mapLiveAlert(a as unknown as LiveAmlAlert));
|
||||
this.liveAlerts = rows;
|
||||
this.compose();
|
||||
if (!rows.length) {
|
||||
this.loading = false;
|
||||
return;
|
||||
}
|
||||
this.enrichCurrentRisk(rows);
|
||||
});
|
||||
}
|
||||
|
||||
/** Live alerts first, then the demonstration set behind them. */
|
||||
private compose(): void {
|
||||
this.alerts = [
|
||||
...this.liveAlerts,
|
||||
...sampleRiskAlerts()
|
||||
];
|
||||
this.memoKey = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* The alert payload carries no rating, so the customer's current risk is read
|
||||
* off their KYC record — one call per distinct customer, not per alert. A
|
||||
* client whose KYC cannot be read stays unrated rather than blocking the
|
||||
* report, so the table renders either way.
|
||||
*/
|
||||
private enrichCurrentRisk(rows: RiskAlertRow[]): void {
|
||||
const clientIds = [...new Set(rows.map((r) => r.clientId))].filter((id) => id != null);
|
||||
if (!clientIds.length) {
|
||||
this.loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
forkJoin(
|
||||
clientIds.map((id) =>
|
||||
this.clients.getClientKyc(String(id), true).pipe(
|
||||
map((kyc: any) => ({ id, risk: riskFromCategory(kyc?.riskCategory) })),
|
||||
catchError(() => of({ id, risk: null as RiskLevel | null }))
|
||||
)
|
||||
)
|
||||
).subscribe((results) => {
|
||||
const byClient = new Map(
|
||||
results.map((r) => [
|
||||
r.id,
|
||||
r.risk
|
||||
])
|
||||
);
|
||||
this.liveAlerts = rows.map((r) => ({ ...r, currentRisk: byClient.get(r.clientId) ?? null }));
|
||||
this.compose();
|
||||
this.loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Filter resolution ──────────────────────────────────────────────────────
|
||||
|
||||
get range(): DateRange {
|
||||
if (this.periodMode === 'day') {
|
||||
return { from: this.filterDay, to: this.filterDay };
|
||||
}
|
||||
if (this.periodMode === 'week') {
|
||||
return weekRange(this.filterWeek);
|
||||
}
|
||||
if (this.periodMode === 'month') {
|
||||
return monthRange(this.filterMonth);
|
||||
}
|
||||
return { from: this.filterFrom, to: this.filterTo };
|
||||
}
|
||||
|
||||
get rangeLabel(): string {
|
||||
const r = this.range;
|
||||
return r.from === r.to ? r.from : `${r.from} to ${r.to}`;
|
||||
}
|
||||
|
||||
get canSelectBranch(): boolean {
|
||||
return this.scope.canSelectBranch;
|
||||
}
|
||||
|
||||
get branchLabel(): string {
|
||||
return this.scope.scopeLabel(this.branchCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applied at the data boundary rather than only in the UI, so a branch login
|
||||
* cannot widen its own scope by tampering with component state.
|
||||
*/
|
||||
private get effectiveBranch(): string {
|
||||
return this.scope.enforce(this.branchCode);
|
||||
}
|
||||
|
||||
private get filter(): RiskReviewFilter {
|
||||
return {
|
||||
range: this.range,
|
||||
branchCode: this.effectiveBranch,
|
||||
currentRisk: this.currentRisk,
|
||||
ruleCode: this.ruleCode,
|
||||
status: this.status,
|
||||
direction: this.direction,
|
||||
search: this.search,
|
||||
openOnly: this.openOnly
|
||||
};
|
||||
}
|
||||
|
||||
resetFilters(): void {
|
||||
this.periodMode = 'range';
|
||||
this.filterDay = RiskReviewReportComponent.WINDOW_END;
|
||||
this.filterWeek = '2026-07-27';
|
||||
this.filterMonth = '2026-07';
|
||||
this.filterFrom = GL_PERIOD.fyStart;
|
||||
this.filterTo = RiskReviewReportComponent.WINDOW_END;
|
||||
this.currentRisk = '';
|
||||
this.ruleCode = '';
|
||||
this.status = '';
|
||||
this.direction = '';
|
||||
this.search = '';
|
||||
this.openOnly = false;
|
||||
this.expanded = null;
|
||||
// Reset returns a branch login to its own branch, never to "all".
|
||||
this.branchCode = this.scope.defaultBranchCode;
|
||||
}
|
||||
|
||||
// ── Data ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The whole screen reads from one computed model. Change detection hits these
|
||||
* getters many times per cycle, so the result is memoised against the filter
|
||||
* rather than re-filtering thousands of rows on every binding.
|
||||
*/
|
||||
private memoKey = '';
|
||||
private memo: RiskReviewModel = { alerts: [], customers: [], txns: [], summary: EMPTY_SUMMARY };
|
||||
|
||||
private get model(): RiskReviewModel {
|
||||
const key = JSON.stringify(this.filter) + `|${this.alerts.length}`;
|
||||
if (key !== this.memoKey) {
|
||||
this.memo = buildRiskReview(this.alerts, this.filter);
|
||||
this.memoKey = key;
|
||||
}
|
||||
return this.memo;
|
||||
}
|
||||
|
||||
get alertRows(): RiskAlertView[] {
|
||||
return this.model.alerts;
|
||||
}
|
||||
|
||||
get customerRows(): RiskCustomerRow[] {
|
||||
return this.model.customers;
|
||||
}
|
||||
|
||||
get txnRows(): RiskTxnView[] {
|
||||
return this.model.txns;
|
||||
}
|
||||
|
||||
get summary(): RiskReviewSummary {
|
||||
return this.model.summary;
|
||||
}
|
||||
|
||||
toggle(clientId: number): void {
|
||||
this.expanded = this.expanded === clientId ? null : clientId;
|
||||
}
|
||||
|
||||
/** One-click drill from the rule mix strip into that rule. */
|
||||
toggleRule(code: string): void {
|
||||
this.ruleCode = this.ruleCode === code ? '' : code;
|
||||
this.expanded = null;
|
||||
}
|
||||
|
||||
// ── Presentation helpers ───────────────────────────────────────────────────
|
||||
|
||||
riskClass(level: RiskLevel | null): string {
|
||||
return level ? `rr-risk rr-risk--${level.toLowerCase()}` : 'rr-risk rr-risk--none';
|
||||
}
|
||||
|
||||
riskText(level: RiskLevel | null): string {
|
||||
return level ? RISK_LABEL[level] : 'Unrated';
|
||||
}
|
||||
|
||||
statusClass(status: ReviewStatus): string {
|
||||
return `rr-status rr-status--${REVIEW_STATUS_META[status].tone}`;
|
||||
}
|
||||
|
||||
/** What the review concludes should happen to the rating, in one phrase. */
|
||||
verdictText(c: RiskCustomerRow): string {
|
||||
if (c.escalated) {
|
||||
return 'Escalated to STR';
|
||||
}
|
||||
if (c.direction === 'up') {
|
||||
return `Review upward → ${RISK_LABEL[c.recommendedRisk]}`;
|
||||
}
|
||||
if (c.direction === 'down') {
|
||||
return `Review downward → ${RISK_LABEL[c.recommendedRisk]}`;
|
||||
}
|
||||
return 'Within current rating';
|
||||
}
|
||||
|
||||
verdictClass(c: RiskCustomerRow): string {
|
||||
if (c.escalated) {
|
||||
return 'rr-verdict rr-verdict--str';
|
||||
}
|
||||
if (c.direction === 'up') {
|
||||
return 'rr-verdict rr-verdict--up';
|
||||
}
|
||||
if (c.direction === 'down') {
|
||||
return 'rr-verdict rr-verdict--down';
|
||||
}
|
||||
return 'rr-verdict';
|
||||
}
|
||||
|
||||
/** Transaction detail as the reviewer would read it in one line. */
|
||||
txnDetail(a: RiskAlertView): string {
|
||||
const amount = `₹${a.amount.toLocaleString('en-IN')}`;
|
||||
if (a.txnCount > 1) {
|
||||
return `${a.txnCount} × ${channelLabel(a.channel)} aggregating ${amount}`;
|
||||
}
|
||||
return `${channelLabel(a.channel)} of ${amount}`;
|
||||
}
|
||||
|
||||
// ── Excel ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async exportExcel(): Promise<void> {
|
||||
this.generating = true;
|
||||
try {
|
||||
const s = this.summary;
|
||||
const subtitle =
|
||||
`Period: ${this.rangeLabel} · Branch: ${this.branchLabel} · ` +
|
||||
`Source: live AML monitoring engine` +
|
||||
(this.sampleCount ? ` + ${this.sampleCount} DEMONSTRATION rows (not compliance data)` : '');
|
||||
|
||||
await exportWorkbook(
|
||||
[
|
||||
{
|
||||
sheetName: 'Summary',
|
||||
title: 'Risk Review Report — Summary',
|
||||
subtitle,
|
||||
headers: [
|
||||
'Particulars',
|
||||
'Value'
|
||||
],
|
||||
widths: [
|
||||
56,
|
||||
22
|
||||
],
|
||||
rows: [
|
||||
[
|
||||
'Customers flagged for risk review',
|
||||
String(s.customers)
|
||||
],
|
||||
[
|
||||
'AML alerts triggered',
|
||||
String(s.alerts)
|
||||
],
|
||||
[
|
||||
'Transactions behind those alerts',
|
||||
String(s.txns)
|
||||
],
|
||||
[
|
||||
' of which cash entries at or above the ₹10,00,000 CTR limit',
|
||||
String(s.ctrReportable)
|
||||
],
|
||||
[
|
||||
'Alerts still awaiting a reviewer decision',
|
||||
String(s.openAlerts)
|
||||
],
|
||||
[
|
||||
'Customers rated below what the triggered rules point at (review upward)',
|
||||
String(s.upgradeCandidates)
|
||||
],
|
||||
[
|
||||
'Customers rated above what the period’s activity warrants (review downward)',
|
||||
String(s.downgradeCandidates)
|
||||
],
|
||||
[
|
||||
'Customers escalated to the Principal Officer (STR)',
|
||||
String(s.escalatedCustomers)
|
||||
],
|
||||
[
|
||||
'Total transaction value under review',
|
||||
s.totalAmount
|
||||
],
|
||||
[
|
||||
'',
|
||||
''
|
||||
],
|
||||
[
|
||||
'Customers currently rated Low',
|
||||
String(s.byRisk.LOW)
|
||||
],
|
||||
[
|
||||
'Customers currently rated Medium',
|
||||
String(s.byRisk.MEDIUM)
|
||||
],
|
||||
[
|
||||
'Customers currently rated High',
|
||||
String(s.byRisk.HIGH)
|
||||
],
|
||||
[
|
||||
'Customers with no rating on record',
|
||||
String(s.byRisk.UNRATED)
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
sheetName: 'Rules triggered',
|
||||
title: 'Risk Review Report — AML Rules Triggered',
|
||||
subtitle,
|
||||
headers: [
|
||||
'AML Rule Triggered',
|
||||
'Category',
|
||||
'Alerts',
|
||||
'What the rule looks for',
|
||||
'Action expected of the reviewer'
|
||||
],
|
||||
widths: [
|
||||
34,
|
||||
14,
|
||||
10,
|
||||
62,
|
||||
62
|
||||
],
|
||||
rows: s.byRule.map((r) => {
|
||||
const meta = AML_RULE_CATALOGUE.find((m) => m.code === r.code);
|
||||
return [
|
||||
r.label,
|
||||
meta?.category ?? '—',
|
||||
r.count,
|
||||
meta?.description ?? '—',
|
||||
meta?.suggestedAction ?? '—'
|
||||
];
|
||||
})
|
||||
},
|
||||
{
|
||||
sheetName: 'Customer wise',
|
||||
title: 'Risk Review Report — Customer wise',
|
||||
subtitle,
|
||||
headers: [
|
||||
'Customer ID',
|
||||
'Customer Name',
|
||||
'Account Number',
|
||||
'Branch',
|
||||
'Current Risk',
|
||||
'Alerts',
|
||||
'AML Rules Triggered',
|
||||
'Total Amount',
|
||||
'Largest Alert',
|
||||
'First Alert',
|
||||
'Last Alert',
|
||||
'Rules Point At',
|
||||
'Recommended Risk',
|
||||
'Open Cases',
|
||||
'Position',
|
||||
'Basis'
|
||||
],
|
||||
widths: [
|
||||
13,
|
||||
28,
|
||||
20,
|
||||
16,
|
||||
13,
|
||||
8,
|
||||
46,
|
||||
16,
|
||||
16,
|
||||
12,
|
||||
12,
|
||||
14,
|
||||
16,
|
||||
11,
|
||||
24,
|
||||
70
|
||||
],
|
||||
rows: this.customerRows.map((c) => [
|
||||
String(c.clientId),
|
||||
c.customerName,
|
||||
c.accountNo || '—',
|
||||
c.branchCode ? glBranchName(c.branchCode) : '—',
|
||||
this.riskText(c.currentRisk),
|
||||
c.alertCount,
|
||||
c.ruleLabels.join('; '),
|
||||
c.totalAmount,
|
||||
c.largestAmount,
|
||||
c.firstAlertDate,
|
||||
c.lastAlertDate,
|
||||
RISK_LABEL[c.proposedRisk],
|
||||
RISK_LABEL[c.recommendedRisk],
|
||||
c.openCount,
|
||||
this.verdictText(c),
|
||||
c.directionReason || '—'
|
||||
])
|
||||
},
|
||||
{
|
||||
sheetName: 'Alert wise',
|
||||
title: 'Risk Review Report — Alert wise',
|
||||
subtitle,
|
||||
headers: [
|
||||
'Alert ID',
|
||||
'Alert Date',
|
||||
'Customer ID',
|
||||
'Customer Name',
|
||||
'Account Number',
|
||||
'Branch',
|
||||
'Current Risk',
|
||||
'Transaction Details',
|
||||
'AML Rule Triggered',
|
||||
'Rule Category',
|
||||
'Amount',
|
||||
'Review Status',
|
||||
'Reviewed By',
|
||||
'Reviewer Remarks',
|
||||
'Action Expected'
|
||||
],
|
||||
widths: [
|
||||
11,
|
||||
12,
|
||||
13,
|
||||
28,
|
||||
20,
|
||||
16,
|
||||
13,
|
||||
38,
|
||||
32,
|
||||
14,
|
||||
16,
|
||||
22,
|
||||
16,
|
||||
60,
|
||||
60
|
||||
],
|
||||
rows: this.alertRows.map((a) => [
|
||||
a.alertId,
|
||||
a.alertDate,
|
||||
String(a.clientId),
|
||||
a.customerName,
|
||||
a.accountNo || '—',
|
||||
a.branchCode ? glBranchName(a.branchCode) : '—',
|
||||
this.riskText(a.currentRisk),
|
||||
this.txnDetail(a),
|
||||
a.rule.label,
|
||||
a.rule.category,
|
||||
a.amount,
|
||||
REVIEW_STATUS_META[a.status].label,
|
||||
a.reviewedBy || '—',
|
||||
a.remarks || '—',
|
||||
a.rule.suggestedAction
|
||||
])
|
||||
},
|
||||
{
|
||||
sheetName: 'Transaction wise',
|
||||
title: 'Risk Review Report — Transaction wise (rule triggered by each entry)',
|
||||
subtitle,
|
||||
headers: [
|
||||
'Txn Date',
|
||||
'Txn ID',
|
||||
'Customer ID',
|
||||
'Customer Name',
|
||||
'Account Number',
|
||||
'Branch',
|
||||
'Current Risk',
|
||||
'Channel',
|
||||
'Dr/Cr',
|
||||
'Amount',
|
||||
'Balance After',
|
||||
'Narration',
|
||||
'Counterparty',
|
||||
'AML Rule Triggered',
|
||||
'Rule Category',
|
||||
'Leg',
|
||||
'Alert ID',
|
||||
'Alert Date',
|
||||
'Review Status',
|
||||
'CTR'
|
||||
],
|
||||
widths: [
|
||||
12,
|
||||
12,
|
||||
13,
|
||||
28,
|
||||
20,
|
||||
16,
|
||||
13,
|
||||
18,
|
||||
7,
|
||||
16,
|
||||
16,
|
||||
32,
|
||||
38,
|
||||
32,
|
||||
14,
|
||||
9,
|
||||
11,
|
||||
12,
|
||||
22,
|
||||
7
|
||||
],
|
||||
rows: this.txnRows.map((t) => [
|
||||
t.date,
|
||||
t.txnId,
|
||||
String(t.clientId),
|
||||
t.customerName,
|
||||
t.accountNo || '—',
|
||||
t.branchCode ? glBranchName(t.branchCode) : '—',
|
||||
this.riskText(t.currentRisk),
|
||||
channelLabel(t.channel),
|
||||
t.drCr,
|
||||
t.amount,
|
||||
t.balanceAfter || null,
|
||||
t.narration || '—',
|
||||
t.counterparty || '—',
|
||||
t.rule.label,
|
||||
t.rule.category,
|
||||
t.legCount > 1 ? `${t.legIndex} of ${t.legCount}` : '—',
|
||||
t.alertId,
|
||||
t.alertDate,
|
||||
REVIEW_STATUS_META[t.status].label,
|
||||
t.ctrReportable ? 'Yes' : ''
|
||||
])
|
||||
}
|
||||
],
|
||||
`Risk-Review-Report-${this.range.from}-to-${this.range.to}`
|
||||
);
|
||||
|
||||
this.toast(
|
||||
`Exported ${s.txns} transactions across ${s.alerts} alerts and ${s.customers} customers for ${this.rangeLabel}`
|
||||
);
|
||||
} catch {
|
||||
this.toast('Could not generate the workbook — please retry');
|
||||
} finally {
|
||||
this.generating = false;
|
||||
}
|
||||
}
|
||||
|
||||
private toast(msg: string): void {
|
||||
this.toastMsg = msg;
|
||||
this.toastVisible = true;
|
||||
if (this.toastTimer) {
|
||||
clearTimeout(this.toastTimer);
|
||||
}
|
||||
this.toastTimer = setTimeout(() => (this.toastVisible = false), 3200);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user