1142 lines
40 KiB
TypeScript
1142 lines
40 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 { Router } from '@angular/router';
|
||
import { MatDialog } from '@angular/material/dialog';
|
||
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,
|
||
riskRank
|
||
} from './risk-review-data';
|
||
import {
|
||
DateRange,
|
||
RatingDirection,
|
||
RiskAlertView,
|
||
BehaviourWindowByClient,
|
||
RiskCustomerRow,
|
||
RiskReviewFilter,
|
||
RiskReviewModel,
|
||
RiskReviewSummary,
|
||
RiskTxnView,
|
||
buildRiskReview,
|
||
monthRange,
|
||
weekRange
|
||
} from './risk-review.compute';
|
||
import { RiskChangeDialogComponent, RiskChangeDialogData } from './risk-change-dialog.component';
|
||
|
||
type PeriodMode = 'day' | 'week' | 'month' | 'range';
|
||
type ViewMode = 'customer' | 'alert' | 'txn';
|
||
|
||
/** One block of the customer view: the customers that share a single verdict. */
|
||
interface RiskCustomerGroup {
|
||
key: string;
|
||
title: string;
|
||
hint: string;
|
||
tone: 'str' | 'up' | 'down' | 'hold';
|
||
rows: RiskCustomerRow[];
|
||
}
|
||
|
||
/** 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 only. If the engine has raised nothing
|
||
* (or is unreachable) the report shows as empty rather than padding itself.
|
||
*/
|
||
@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);
|
||
private router = inject(Router);
|
||
private dialog = inject(MatDialog);
|
||
|
||
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;
|
||
|
||
/** The worklist — live alerts from the rule engine. */
|
||
private alerts: RiskAlertRow[] = [];
|
||
|
||
get hasLive(): boolean {
|
||
return this.liveAlerts.length > 0;
|
||
}
|
||
|
||
// ── 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 ────────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* The nightly job's verdict per customer, keyed on client id.
|
||
*
|
||
* Empty until the first load, and stays empty on a tenant where the job has not run - the report then falls back to
|
||
* its own per-alert reckoning rather than showing nothing.
|
||
*/
|
||
private behaviourWindows: BehaviourWindowByClient = {};
|
||
|
||
load(): void {
|
||
this.loading = true;
|
||
// Both in one pass: the windows decide what each row recommends, so composing before they arrive would show a
|
||
// recommendation the screen then silently replaced.
|
||
forkJoin({
|
||
alerts: this.aml.getAlerts().pipe(catchError(() => of([] as LiveAmlAlert[]))),
|
||
windows: this.aml.getBehaviourWindows().pipe(catchError(() => of([] as any[])))
|
||
}).subscribe(({ alerts: live, windows }) => {
|
||
this.behaviourWindows = {};
|
||
for (const w of windows || []) {
|
||
if (w?.clientId != null) {
|
||
this.behaviourWindows[w.clientId] = w;
|
||
}
|
||
}
|
||
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);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Only what the AML engine actually raised.
|
||
*
|
||
* The demonstration set that used to be appended here is gone: a compliance report that mixes
|
||
* invented customers into live alerts is worse than an empty one, because every count, total and
|
||
* rule tally on the screen is then a number nobody can act on. An empty period now reads as an
|
||
* empty period.
|
||
*/
|
||
private compose(): void {
|
||
this.alerts = [...this.liveAlerts];
|
||
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;
|
||
}
|
||
|
||
// The alert carries the client but not their account number or branch, so both columns read
|
||
// as an em dash on every live row. Both sit on the client record the KYC call already needs,
|
||
// so they are fetched alongside it rather than left blank on a compliance report.
|
||
forkJoin(
|
||
clientIds.map((id) =>
|
||
forkJoin({
|
||
kyc: this.clients.getClientKyc(String(id), true).pipe(catchError(() => of(null))),
|
||
client: this.clients.getClientData(String(id), true).pipe(catchError(() => of(null)))
|
||
}).pipe(
|
||
map((r: any) => ({
|
||
id,
|
||
risk: riskFromCategory(r.kyc?.riskCategory),
|
||
accountNo: r.client?.accountNo || '',
|
||
branch: r.client?.officeName || ''
|
||
})),
|
||
catchError(() => of({ id, risk: null as RiskLevel | null, accountNo: '', branch: '' }))
|
||
)
|
||
)
|
||
).subscribe((results) => {
|
||
const byClient = new Map(
|
||
results.map((r) => [
|
||
r.id,
|
||
r
|
||
])
|
||
);
|
||
this.liveAlerts = rows.map((r) => {
|
||
const info = byClient.get(r.clientId);
|
||
return {
|
||
...r,
|
||
currentRisk: info?.risk ?? null,
|
||
accountNo: r.accountNo || info?.accountNo || '',
|
||
branchCode: r.branchCode || info?.branch || ''
|
||
};
|
||
});
|
||
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 {
|
||
// The window count belongs in the key: the windows decide what each row recommends, and they arrive in the same
|
||
// load as the alerts. Keying on the alerts alone would serve the memo computed before they landed.
|
||
const key = JSON.stringify(this.filter) + `|${this.alerts.length}|${Object.keys(this.behaviourWindows).length}`;
|
||
if (key !== this.memoKey) {
|
||
this.memo = buildRiskReview(this.alerts, this.filter, this.behaviourWindows);
|
||
this.memoKey = key;
|
||
}
|
||
return this.memo;
|
||
}
|
||
|
||
get alertRows(): RiskAlertView[] {
|
||
return this.model.alerts;
|
||
}
|
||
|
||
get customerRows(): RiskCustomerRow[] {
|
||
return this.model.customers;
|
||
}
|
||
|
||
/**
|
||
* The customers split by what the reviewer actually has to do about them.
|
||
*
|
||
* The report's whole question is "does the rating on file still hold?", and there are only four
|
||
* answers. Grouping by the answer puts that question in the section heading, where it is read
|
||
* once, instead of repeating it on every row across a Move To and a Position column that said the
|
||
* same thing in different words.
|
||
*
|
||
* Ordered by what a reviewer must deal with first: an STR sits with the Principal Officer against
|
||
* a regulatory clock, an under-rated customer is the real compliance gap, an over-rated one is
|
||
* only a correction, and matching ratings need nothing at all.
|
||
*/
|
||
/**
|
||
* A report row for a customer the behaviour review named but no alert brought onto the report.
|
||
*
|
||
* <p>
|
||
* The alert-derived fields are empty because there are none in the window, and that is the point being shown: the
|
||
* case rests on the period, not on anything that fired. The figures the verdict was drawn from travel on
|
||
* behaviourWindow, so the row can still explain itself.
|
||
*/
|
||
private rowFromWindow(w: any): RiskCustomerRow {
|
||
const RANK: Record<number, RiskLevel> = { 1: 'LOW', 2: 'MEDIUM', 3: 'HIGH' };
|
||
const current = RANK[w.riskCategoryAtReview] ?? null;
|
||
const recommended = RANK[w.suggestedCategory] ?? current ?? 'LOW';
|
||
return {
|
||
clientId: w.clientId,
|
||
customerName: w.clientName || `Client ${w.clientId}`,
|
||
accountNo: w.accountNo || '',
|
||
branchCode: w.officeName || '',
|
||
currentRisk: current,
|
||
alerts: [],
|
||
alertCount: 0,
|
||
totalAmount: Number(w.creditTotal || 0) + Number(w.debitTotal || 0),
|
||
largestAmount: Number(w.largestTxn || 0),
|
||
proposedRisk: recommended,
|
||
recommendedRisk: recommended,
|
||
direction: w.suggestedDirection === 'UP' ? 'up' : w.suggestedDirection === 'DOWN' ? 'down' : 'same',
|
||
directionReason: w.suggestionReason || '',
|
||
ruleLabels: [],
|
||
firstAlertDate: '',
|
||
lastAlertDate: '',
|
||
openCount: 0,
|
||
escalated: false,
|
||
needsUpgrade: w.suggestedDirection === 'UP',
|
||
needsDowngrade: w.suggestedDirection === 'DOWN',
|
||
behaviourWindow: w
|
||
};
|
||
}
|
||
|
||
get customerGroups(): RiskCustomerGroup[] {
|
||
const rows = this.customerRows;
|
||
// An escalated customer whose rating still has to move belongs with the work, not with the cases that are waiting
|
||
// on someone else. An open STR holds a rating at High, and if the customer is not yet at High that is a change
|
||
// somebody has to apply - filing the STR does not make it. Only a customer whose rating already matches sits under
|
||
// the Principal Officer with nothing to do.
|
||
const escalated = rows.filter((c) => c.escalated && c.direction === 'same');
|
||
const rest = rows.filter((c) => !(c.escalated && c.direction === 'same'));
|
||
|
||
// Customers the nightly review says to move who raised no alert inside the filter window.
|
||
//
|
||
// The report is built from alerts, so a customer appears only once a rule has fired on them recently. That is the
|
||
// wrong test for a downgrade: the whole case for lowering a rating is that nothing has fired for a year, so the
|
||
// customer with the strongest case is precisely the one the alert feed has stopped mentioning. Without this they
|
||
// would sit in the behaviour table with a verdict nobody ever sees.
|
||
const onReport = new Set(rows.map((r) => r.clientId));
|
||
const offReport: RiskCustomerRow[] = Object.values(this.behaviourWindows)
|
||
.filter((w: any) => w?.clientId != null && !onReport.has(w.clientId))
|
||
.filter((w: any) => w.suggestedDirection === 'UP' || w.suggestedDirection === 'DOWN')
|
||
.map((w: any) => this.rowFromWindow(w));
|
||
|
||
const groups: RiskCustomerGroup[] = [
|
||
{
|
||
key: 'escalated',
|
||
title: 'With the Principal Officer',
|
||
hint: 'An STR has been filed. The rating is held where it is until the outcome — nothing to apply here.',
|
||
tone: 'str',
|
||
rows: escalated
|
||
},
|
||
{
|
||
// Raising and lowering are one section, not two. The heading answers "is there anything to do about this
|
||
// customer", and which way the rating moves is already on every row - as a verdict, an arrow and a reason.
|
||
// Splitting them put the same answer in two places and left an officer reading two lists to find one day's
|
||
// work. Raises sort first within the section, because an under-rated customer is the more urgent gap.
|
||
key: 'change',
|
||
title: 'Change needed',
|
||
hint: 'Every condition for moving the rating is met. Apply the change; it goes to a checker for approval.',
|
||
tone: 'up',
|
||
rows: [
|
||
...rest.filter((c) => c.currentRisk && c.direction === 'up'),
|
||
...offReport.filter((c) => c.direction === 'up'),
|
||
...rest.filter((c) => c.currentRisk && c.direction === 'down'),
|
||
...offReport.filter((c) => c.direction === 'down')
|
||
]
|
||
},
|
||
{
|
||
// Its own section, and above the watching list, because it is the opposite of that. An unrated customer whose
|
||
// money is moving has no rating for the behaviour to be compared against, so nothing here can say whether it
|
||
// is in keeping - the work is to give them one, and grouping them with the customers nothing need be done
|
||
// about buried the one action the report was asking for.
|
||
key: 'unrated',
|
||
title: 'Update risk',
|
||
hint: 'These customers are transacting with no rating on file. Nothing can be judged against a rating that does not exist — rate them first.',
|
||
tone: 'up',
|
||
rows: rest.filter((c) => !c.currentRisk)
|
||
},
|
||
{
|
||
// Everything an alert put on the report that the policy will not move yet. "No change needed" read as though
|
||
// these customers were fine, which is not what it means: a customer with four open alerts sits here, and the
|
||
// reason is that the period does not yet carry enough to act on - not that there is nothing to look at.
|
||
// "Watching" says what the section is for, and the reason on each row says which condition fell short.
|
||
key: 'same',
|
||
title: 'Watching — not enough to act on',
|
||
hint: 'In view, but the period does not yet support moving the rating. The reason on each row says what is missing.',
|
||
tone: 'hold',
|
||
rows: rest.filter((c) => c.currentRisk && c.direction === 'same')
|
||
}
|
||
];
|
||
|
||
return groups.filter((g) => g.rows.length > 0);
|
||
}
|
||
|
||
/**
|
||
* Collapsed sections, by group key. Every group starts open.
|
||
*
|
||
* 'No change needed' used to start closed on the reasoning that there was nothing to act on. On a
|
||
* report that often holds one group, that meant opening the page to a heading and a count and no
|
||
* rows — the customer was there, but reading the report took a click to discover it. The toggle
|
||
* stays for folding a long section away.
|
||
*/
|
||
collapsedGroups: Record<string, boolean> = {};
|
||
|
||
/**
|
||
* The summary tiles read as a question each — "which customers?", "which alerts still need a
|
||
* decision?" — so each one narrows the table to the rows it counted. They were plain text
|
||
* before, which invited a click that did nothing and left the reviewer to rebuild the same
|
||
* filter by hand from the dropdowns.
|
||
*
|
||
* Clicking an active tile clears it again, so a tile is never a one-way trip into a filter the
|
||
* reviewer then has to hunt for.
|
||
*/
|
||
focusAllCustomers(): void {
|
||
this.currentRisk = '';
|
||
this.direction = '';
|
||
this.openOnly = false;
|
||
this.expanded = null;
|
||
this.collapsedGroups = {};
|
||
}
|
||
|
||
focusOpenAlerts(): void {
|
||
this.openOnly = !this.openOnly;
|
||
this.direction = '';
|
||
this.expanded = null;
|
||
this.collapsedGroups = {};
|
||
}
|
||
|
||
/** Only the customers whose rating the review says should move, either way. */
|
||
focusRatingsToMove(): void {
|
||
// Nothing to narrow to when no rating needs moving; leaving the filter alone keeps the
|
||
// table showing what is actually there rather than emptying it.
|
||
if (this.summary.upgradeCandidates + this.summary.downgradeCandidates === 0) {
|
||
return;
|
||
}
|
||
this.direction = this.direction === 'up' ? 'down' : this.direction === 'down' ? '' : 'up';
|
||
this.openOnly = false;
|
||
this.expanded = null;
|
||
this.collapsedGroups = {};
|
||
}
|
||
|
||
focusEscalated(): void {
|
||
if (this.summary.escalatedCustomers === 0) {
|
||
return;
|
||
}
|
||
this.status = this.status === 'ESCALATED_STR' ? '' : 'ESCALATED_STR';
|
||
this.openOnly = false;
|
||
this.expanded = null;
|
||
this.collapsedGroups = {};
|
||
}
|
||
|
||
toggleGroup(key: string): void {
|
||
this.collapsedGroups[key] = !this.collapsedGroups[key];
|
||
}
|
||
|
||
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';
|
||
}
|
||
|
||
/** Colours the target rating by which way it moves. */
|
||
moveClass(c: RiskCustomerRow): string {
|
||
if (c.direction === 'up') {
|
||
return 'rr-move rr-move--up';
|
||
}
|
||
if (c.direction === 'down') {
|
||
return 'rr-move rr-move--down';
|
||
}
|
||
return 'rr-move rr-move--hold';
|
||
}
|
||
|
||
/**
|
||
* The short "from → to" line under the target rating.
|
||
*
|
||
* The reason column already carries the full argument; this only has to say what is
|
||
* happening to the rating, so a reviewer scanning the column never has to read a
|
||
* paragraph to find out whether a customer is moving.
|
||
*/
|
||
moveHint(c: RiskCustomerRow): string {
|
||
const from = this.riskText(c.currentRisk);
|
||
const to = this.riskText(c.recommendedRisk);
|
||
|
||
if (c.escalated) {
|
||
return `${from} → hold at ${to} till STR outcome`;
|
||
}
|
||
if (c.direction === 'up') {
|
||
return `${from} → ${to} as per AML rules`;
|
||
}
|
||
if (c.direction === 'down') {
|
||
return `${from} → ${to}, activity does not support ${from}`;
|
||
}
|
||
// Nothing to move: say why it is staying rather than leaving the cell mute.
|
||
return c.currentRisk ? `Behaviour matches ${from} — no change` : 'No rating on record yet';
|
||
}
|
||
|
||
/**
|
||
* Opens the customer's KYC tab so the reviewer can act on the verdict.
|
||
*
|
||
* The report only ever recommends — nothing here writes the rating. Moving a customer
|
||
* is a KYC amendment and stays on the KYC screen, where the override checkbox, the
|
||
* remarks field and the review-date recalculation all live together.
|
||
*/
|
||
openKyc(c: RiskCustomerRow, event: Event): void {
|
||
// The row itself expands on click; the button must not do both.
|
||
event.stopPropagation();
|
||
this.router.navigate([
|
||
'/clients',
|
||
c.clientId,
|
||
'kyc'
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* Sends a case that is with the Principal Officer to the STR queue.
|
||
*
|
||
* The rating cannot be moved while an STR is open, so the useful thing to offer is the case
|
||
* itself. Sending the reviewer to the customer's KYC record instead made them land on a screen
|
||
* where the one action they might take is the one thing they must not do yet.
|
||
*/
|
||
openStrCase(c: RiskCustomerRow, event: Event): void {
|
||
event.stopPropagation();
|
||
this.router.navigate(['/fiu'], { queryParams: { tab: 'str' } });
|
||
}
|
||
|
||
/**
|
||
* Whether this row's rating can be moved from here.
|
||
*
|
||
* A case with the Principal Officer is deliberately excluded: the rating is held where it is
|
||
* until the STR concludes, which is what the section heading on that group says. Offering the
|
||
* button there would invite a change nobody is in a position to make yet.
|
||
*/
|
||
canChangeRisk(c: RiskCustomerRow): boolean {
|
||
return !c.escalated;
|
||
}
|
||
|
||
/**
|
||
* Raises a rating change for approval, rather than sending the reviewer to the KYC screen to make
|
||
* it themselves.
|
||
*
|
||
* The button used to navigate away: the reviewer left the report, found the customer, edited the
|
||
* record, and the change took effect on their say-so alone with no second pair of eyes and no
|
||
* record of why. The rating governs re-KYC frequency and AML thresholds, so it goes through a
|
||
* checker like every other decision of that weight.
|
||
*/
|
||
changeRisk(c: RiskCustomerRow, event: Event): void {
|
||
event.stopPropagation();
|
||
if (!this.canChangeRisk(c)) {
|
||
return;
|
||
}
|
||
this.dialog
|
||
.open(RiskChangeDialogComponent, {
|
||
width: '520px',
|
||
maxWidth: '95vw',
|
||
disableClose: true,
|
||
data: {
|
||
clientId: c.clientId,
|
||
customerName: c.customerName,
|
||
currentCategory: riskRank(c.currentRisk) || null,
|
||
recommendedCategory: riskRank(c.recommendedRisk) || null,
|
||
recommendationReason: c.directionReason
|
||
} as RiskChangeDialogData
|
||
})
|
||
.afterClosed()
|
||
.subscribe((sent) => {
|
||
if (sent) {
|
||
// The rating has not moved yet - only a request exists - so the figures on screen are
|
||
// still correct. Reloaded anyway so a second click cannot raise a duplicate request the
|
||
// server would refuse.
|
||
this.load();
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* What the reviewer is going to the KYC screen to do.
|
||
*
|
||
* An escalated case is worded as a review rather than a move: the rating is held
|
||
* pending the Principal Officer's conclusion, so there is nothing to apply yet.
|
||
*/
|
||
/**
|
||
* The verdict in the words a reviewer needs: which way the rating moves, and to what.
|
||
*
|
||
* An escalated case is deliberately not given a direction — the rating is held where it is
|
||
* until the Principal Officer reports back, so showing "raise to High" there would invite a
|
||
* change nobody is authorised to make yet.
|
||
*/
|
||
directionText(c: RiskCustomerRow): string {
|
||
// Escalation is only the whole story when the rating already matches. An STR holds a customer at High, so one
|
||
// filed against a Medium-rated customer leaves a rating that still has to be moved - saying "held" there reports
|
||
// the case and hides the work, which is how this row came to sit under "Change needed" announcing that nothing
|
||
// needed doing.
|
||
if (c.escalated && c.direction === 'same') {
|
||
return 'Held — with Principal Officer';
|
||
}
|
||
// Both ends of the move, not just the destination. "Lower to Medium" reads as a verdict on its own and leaves the
|
||
// officer joining it to the rating column to see what is actually changing; "High → Medium" is the whole fact in
|
||
// one glance, and on an exported sheet it still says what happened without the column beside it.
|
||
if (c.direction === 'up' || c.direction === 'down') {
|
||
const from = c.currentRisk ? RISK_LABEL[c.currentRisk] : 'Unrated';
|
||
return `${from} → ${RISK_LABEL[c.recommendedRisk]}`;
|
||
}
|
||
// An unrated customer is told what to do, not what was concluded. "No rating on record" states the gap and stops
|
||
// there, which on a worklist reads as though the row needs nothing - and it is in fact the row that needs the most.
|
||
return c.currentRisk ? `Keep at ${RISK_LABEL[c.currentRisk]}` : 'Rate this customer';
|
||
}
|
||
|
||
directionIcon(c: RiskCustomerRow): string {
|
||
// Same test as directionText: a padlock says the case is out of the officer's hands, which is untrue while the
|
||
// rating still has to be moved.
|
||
if (c.escalated && c.direction === 'same') {
|
||
return 'lock';
|
||
}
|
||
if (c.direction === 'up') {
|
||
return 'arrow-up';
|
||
}
|
||
if (c.direction === 'down') {
|
||
return 'arrow-down';
|
||
}
|
||
// A tick claims the row is settled. An unrated customer is not settled, so it gets the mark that asks a question
|
||
// rather than the one that closes it. 'exclamation-circle' rather than plain 'exclamation': only the registered
|
||
// names resolve, and an unregistered icon throws at render and takes the whole row down with it.
|
||
return c.currentRisk ? 'check' : 'exclamation-circle';
|
||
}
|
||
|
||
/**
|
||
* A live row carries the office name straight off the client record, not a GL branch code, so
|
||
* it is already the label to print. Only a coded row needs the lookup.
|
||
*/
|
||
branchLabelOf(branch: string): string {
|
||
if (!branch) {
|
||
return '—';
|
||
}
|
||
const named = glBranchName(branch);
|
||
return named && named !== branch ? named : branch;
|
||
}
|
||
|
||
kycActionText(c: RiskCustomerRow): string {
|
||
if (c.escalated) {
|
||
return 'Open KYC';
|
||
}
|
||
if (c.direction === 'up' || c.direction === 'down') {
|
||
return `Set ${RISK_LABEL[c.recommendedRisk]} in KYC`;
|
||
}
|
||
return 'Open KYC';
|
||
}
|
||
|
||
/** 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`;
|
||
|
||
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',
|
||
'Move To',
|
||
'Open Cases',
|
||
'Position',
|
||
'Basis'
|
||
],
|
||
widths: [
|
||
13,
|
||
28,
|
||
20,
|
||
16,
|
||
13,
|
||
8,
|
||
46,
|
||
16,
|
||
16,
|
||
12,
|
||
12,
|
||
14,
|
||
16,
|
||
38,
|
||
11,
|
||
24,
|
||
70
|
||
],
|
||
rows: this.customerRows.map((c) => [
|
||
String(c.clientId),
|
||
c.customerName,
|
||
c.accountNo || '—',
|
||
this.branchLabelOf(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],
|
||
this.moveHint(c),
|
||
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 || '—',
|
||
this.branchLabelOf(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 || '—',
|
||
this.branchLabelOf(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);
|
||
}
|
||
}
|