Files
cbsnexora/src/app/risk-review/risk-review-report.component.ts
T
2026-09-23 15:51:10 +05:30

1142 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);
}
}