feat: cash-only registers, day book sections and DD amendments
This commit is contained in:
@@ -9,6 +9,7 @@
|
||||
/** Angular Imports */
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { catchError, map } from 'rxjs/operators';
|
||||
|
||||
@@ -37,8 +38,7 @@ import {
|
||||
RiskLevel,
|
||||
channelLabel,
|
||||
mapLiveAlert,
|
||||
riskFromCategory,
|
||||
sampleRiskAlerts
|
||||
riskFromCategory
|
||||
} from './risk-review-data';
|
||||
import {
|
||||
DateRange,
|
||||
@@ -57,6 +57,15 @@ import {
|
||||
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,
|
||||
@@ -82,9 +91,8 @@ const EMPTY_SUMMARY: RiskReviewSummary = {
|
||||
* 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.
|
||||
* 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',
|
||||
@@ -100,6 +108,7 @@ export class RiskReviewReportComponent implements OnInit {
|
||||
private aml = inject(AmlService);
|
||||
private clients = inject(ClientsService);
|
||||
private scope = inject(BranchScopeService);
|
||||
private router = inject(Router);
|
||||
|
||||
readonly period = GL_PERIOD;
|
||||
readonly branches = GL_BRANCHES;
|
||||
@@ -122,22 +131,13 @@ export class RiskReviewReportComponent implements OnInit {
|
||||
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.
|
||||
*/
|
||||
/** The worklist — live alerts from the rule engine. */
|
||||
private alerts: RiskAlertRow[] = [];
|
||||
|
||||
get hasLive(): boolean {
|
||||
return this.liveAlerts.length > 0;
|
||||
}
|
||||
|
||||
get sampleCount(): number {
|
||||
return this.alerts.filter((a) => !a.live).length;
|
||||
}
|
||||
|
||||
// ── Filter state ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -198,12 +198,16 @@ export class RiskReviewReportComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
/** Live alerts first, then the demonstration set behind them. */
|
||||
/**
|
||||
* 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,
|
||||
...sampleRiskAlerts()
|
||||
];
|
||||
this.alerts = [...this.liveAlerts];
|
||||
this.memoKey = '';
|
||||
}
|
||||
|
||||
@@ -334,6 +338,64 @@ export class RiskReviewReportComponent implements OnInit {
|
||||
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.
|
||||
*/
|
||||
get customerGroups(): RiskCustomerGroup[] {
|
||||
const rows = this.customerRows;
|
||||
const escalated = rows.filter((c) => c.escalated);
|
||||
const rest = rows.filter((c) => !c.escalated);
|
||||
|
||||
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
|
||||
},
|
||||
{
|
||||
key: 'up',
|
||||
title: 'Rating needs raising',
|
||||
hint: 'The rules point higher than the rating on file. These are under-rated customers — the real gap.',
|
||||
tone: 'up',
|
||||
rows: rest.filter((c) => c.direction === 'up')
|
||||
},
|
||||
{
|
||||
key: 'down',
|
||||
title: 'Rating can be lowered',
|
||||
hint: 'Activity no longer supports the rating on file.',
|
||||
tone: 'down',
|
||||
rows: rest.filter((c) => c.direction === 'down')
|
||||
},
|
||||
{
|
||||
key: 'same',
|
||||
title: 'No change needed',
|
||||
hint: 'The rating on file already matches the behaviour. Listed for the record.',
|
||||
tone: 'hold',
|
||||
rows: rest.filter((c) => c.direction === 'same')
|
||||
}
|
||||
];
|
||||
|
||||
return groups.filter((g) => g.rows.length > 0);
|
||||
}
|
||||
|
||||
/** Collapsed sections, by group key. 'No change needed' starts closed — nothing to act on. */
|
||||
collapsedGroups: Record<string, boolean> = { same: true };
|
||||
|
||||
toggleGroup(key: string): void {
|
||||
this.collapsedGroups[key] = !this.collapsedGroups[key];
|
||||
}
|
||||
|
||||
get txnRows(): RiskTxnView[] {
|
||||
return this.model.txns;
|
||||
}
|
||||
@@ -393,6 +455,74 @@ export class RiskReviewReportComponent implements OnInit {
|
||||
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'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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')}`;
|
||||
@@ -409,9 +539,7 @@ export class RiskReviewReportComponent implements OnInit {
|
||||
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)` : '');
|
||||
`Period: ${this.rangeLabel} · Branch: ${this.branchLabel} · ` + `Source: live AML monitoring engine`;
|
||||
|
||||
await exportWorkbook(
|
||||
[
|
||||
@@ -533,6 +661,7 @@ export class RiskReviewReportComponent implements OnInit {
|
||||
'Last Alert',
|
||||
'Rules Point At',
|
||||
'Recommended Risk',
|
||||
'Move To',
|
||||
'Open Cases',
|
||||
'Position',
|
||||
'Basis'
|
||||
@@ -551,6 +680,7 @@ export class RiskReviewReportComponent implements OnInit {
|
||||
12,
|
||||
14,
|
||||
16,
|
||||
38,
|
||||
11,
|
||||
24,
|
||||
70
|
||||
@@ -569,6 +699,7 @@ export class RiskReviewReportComponent implements OnInit {
|
||||
c.lastAlertDate,
|
||||
RISK_LABEL[c.proposedRisk],
|
||||
RISK_LABEL[c.recommendedRisk],
|
||||
this.moveHint(c),
|
||||
c.openCount,
|
||||
this.verdictText(c),
|
||||
c.directionReason || '—'
|
||||
|
||||
Reference in New Issue
Block a user