727 lines
22 KiB
TypeScript
727 lines
22 KiB
TypeScript
/**
|
||
* 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);
|
||
}
|
||
}
|