feat: cash-only registers, day book sections and DD amendments
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -27,18 +27,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (sampleCount) {
|
||||
<div class="rr-banner rr-banner--sample">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
<span>
|
||||
<strong>{{ sampleCount }} demonstration rows</strong> are included alongside
|
||||
{{ hasLive ? 'the live alerts' : 'the live feed, which has raised nothing' }}. Sample rows carry a
|
||||
<span class="rr-tag">Sample</span> tag in the table and must not be acted on as compliance data.
|
||||
</span>
|
||||
<button class="rr-btn rr-btn--slim" (click)="load()"><fa-icon icon="undo"></fa-icon> Reload live feed</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- FILTERS -->
|
||||
<div class="rr-filters">
|
||||
<div class="rr-filter-row">
|
||||
@@ -228,148 +216,173 @@
|
||||
|
||||
<!-- CUSTOMER WISE -->
|
||||
@if (view === 'customer') {
|
||||
<div class="rr-table-wrap">
|
||||
<table class="rr-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Customer ID</th>
|
||||
<th>Customer Name</th>
|
||||
<th>Account Number</th>
|
||||
<th>Branch</th>
|
||||
<th>Current Risk</th>
|
||||
<th class="num">Alerts</th>
|
||||
<th>AML Rules Triggered</th>
|
||||
<th class="num">Amount under review</th>
|
||||
<th>Last Alert</th>
|
||||
<th>Position</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (c of customerRows; track c.clientId) {
|
||||
<tr
|
||||
class="rr-cust"
|
||||
[class.open]="expanded === c.clientId"
|
||||
[class.rr-row--escalated]="c.escalated"
|
||||
(click)="toggle(c.clientId)"
|
||||
>
|
||||
<td>
|
||||
<fa-icon [icon]="expanded === c.clientId ? 'caret-down' : 'caret-right'"></fa-icon>
|
||||
</td>
|
||||
<td class="mono">{{ c.clientId }}</td>
|
||||
<td>
|
||||
<strong>{{ c.customerName }}</strong>
|
||||
@if (c.openCount > 0) {
|
||||
<span class="rr-tag">{{ c.openCount }} open</span>
|
||||
}
|
||||
</td>
|
||||
<td class="mono">{{ c.accountNo || '—' }}</td>
|
||||
<td>{{ c.branchCode || '—' }}</td>
|
||||
<td>
|
||||
<span [class]="riskClass(c.currentRisk)">{{ riskText(c.currentRisk) }}</span>
|
||||
</td>
|
||||
<td class="num">{{ c.alertCount }}</td>
|
||||
<td class="rr-rules-cell">
|
||||
@for (label of c.ruleLabels; track label) {
|
||||
<span class="rr-rule">{{ label }}</span>
|
||||
}
|
||||
</td>
|
||||
<td class="num strong">{{ c.totalAmount | number: '1.0-0' }}</td>
|
||||
<td class="mono">{{ c.lastAlertDate }}</td>
|
||||
<td class="rr-verdict-cell">
|
||||
<span [class]="verdictClass(c)">{{ verdictText(c) }}</span>
|
||||
@if (c.directionReason) {
|
||||
<div class="rr-verdict-why">{{ c.directionReason }}</div>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Grouped by verdict: the section heading answers "what do I do about these?" once, so the
|
||||
row does not have to repeat it in a Move To and a Position column. -->
|
||||
@for (g of customerGroups; track g.key) {
|
||||
<div class="rr-group" [class]="'rr-group--' + g.tone">
|
||||
<button class="rr-group-head" (click)="toggleGroup(g.key)">
|
||||
<fa-icon [icon]="collapsedGroups[g.key] ? 'caret-right' : 'caret-down'"></fa-icon>
|
||||
<span class="rr-group-t">{{ g.title }}</span>
|
||||
<span class="rr-group-n">{{ g.rows.length }}</span>
|
||||
<span class="rr-group-h">{{ g.hint }}</span>
|
||||
</button>
|
||||
|
||||
@if (expanded === c.clientId) {
|
||||
<tr class="rr-detail">
|
||||
<td colspan="11">
|
||||
<!-- One block per alert: what the rule is, then the entries it fired on. -->
|
||||
@for (a of c.alerts; track a.alertId) {
|
||||
<div class="rr-case">
|
||||
<div class="rr-case-head">
|
||||
<span class="rr-rule">{{ a.rule.label }}</span>
|
||||
<span class="rr-rule-cat">{{ a.rule.category }}</span>
|
||||
<span class="rr-case-id mono">{{ a.alertId }} · raised {{ a.alertDate }}</span>
|
||||
<span class="rr-spacer"></span>
|
||||
<span class="rr-case-amt mono">₹{{ a.amount | number: '1.0-0' }}</span>
|
||||
<span [class]="statusClass(a.status)">{{ statusMeta[a.status].label }}</span>
|
||||
</div>
|
||||
<div class="rr-case-why">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
<span><strong>Why it triggered:</strong> {{ a.rule.description }} — {{ txnDetail(a) }}</span>
|
||||
</div>
|
||||
@if (!collapsedGroups[g.key]) {
|
||||
<div class="rr-table-wrap">
|
||||
<table class="rr-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Customer ID</th>
|
||||
<th>Customer Name</th>
|
||||
<th>Account Number</th>
|
||||
<th>Branch</th>
|
||||
<th>Rating on file</th>
|
||||
<th class="num">Alerts</th>
|
||||
<th>AML Rules Triggered</th>
|
||||
<th class="num">Amount under review</th>
|
||||
<th>Last Alert</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (c of g.rows; track c.clientId) {
|
||||
<tr
|
||||
class="rr-cust"
|
||||
[class.open]="expanded === c.clientId"
|
||||
[class.rr-row--escalated]="c.escalated"
|
||||
(click)="toggle(c.clientId)"
|
||||
>
|
||||
<td>
|
||||
<fa-icon [icon]="expanded === c.clientId ? 'caret-down' : 'caret-right'"></fa-icon>
|
||||
</td>
|
||||
<td class="mono">{{ c.clientId }}</td>
|
||||
<td>
|
||||
<strong>{{ c.customerName }}</strong>
|
||||
@if (c.openCount > 0) {
|
||||
<span class="rr-tag">{{ c.openCount }} open</span>
|
||||
}
|
||||
</td>
|
||||
<td class="mono">{{ c.accountNo || '—' }}</td>
|
||||
<td>{{ c.branchCode || '—' }}</td>
|
||||
<td>
|
||||
<span [class]="riskClass(c.currentRisk)">{{ riskText(c.currentRisk) }}</span>
|
||||
</td>
|
||||
<td class="num">{{ c.alertCount }}</td>
|
||||
<td class="rr-rules-cell">
|
||||
@for (label of c.ruleLabels; track label) {
|
||||
<span class="rr-rule">{{ label }}</span>
|
||||
}
|
||||
</td>
|
||||
<td class="num strong">{{ c.totalAmount | number: '1.0-0' }}</td>
|
||||
<td class="mono">{{ c.lastAlertDate }}</td>
|
||||
<!-- The verdict is the section this row sits in, so the cell only carries the
|
||||
button that acts on it, and the reason behind it. -->
|
||||
<td class="rr-verdict-cell">
|
||||
<button
|
||||
class="rr-kyc-link"
|
||||
[class.rr-kyc-link--act]="c.direction !== 'same' && !c.escalated"
|
||||
(click)="openKyc(c, $event)"
|
||||
[attr.aria-label]="kycActionText(c) + ' for ' + c.customerName"
|
||||
>
|
||||
<fa-icon icon="id-card"></fa-icon> {{ kycActionText(c) }}
|
||||
</button>
|
||||
@if (c.directionReason) {
|
||||
<div class="rr-verdict-why">{{ c.directionReason }}</div>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<table class="rr-subtable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Txn Date</th>
|
||||
<th>Txn ID</th>
|
||||
<th>Channel</th>
|
||||
<th>Dr/Cr</th>
|
||||
<th class="num">Amount</th>
|
||||
<th class="num">Balance After</th>
|
||||
<th>Narration</th>
|
||||
<th>Counterparty</th>
|
||||
<th>AML Rule Triggered</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (t of a.txns; track t.txnId) {
|
||||
<tr [class.rr-leg--ctr]="t.ctrReportable">
|
||||
<td class="mono">{{ t.date }}</td>
|
||||
<td class="mono">{{ t.txnId }}</td>
|
||||
<td>
|
||||
<span class="rr-chan">{{ channelLabel(t.channel) }}</span>
|
||||
</td>
|
||||
<td [class.cr]="t.drCr === 'CR'" [class.dr]="t.drCr === 'DR'">{{ t.drCr }}</td>
|
||||
<td class="num strong">
|
||||
{{ t.amount | number: '1.0-0' }}
|
||||
@if (t.ctrReportable) {
|
||||
<span class="rr-tag rr-tag--ctr">CTR</span>
|
||||
@if (expanded === c.clientId) {
|
||||
<tr class="rr-detail">
|
||||
<td colspan="11">
|
||||
<!-- One block per alert: what the rule is, then the entries it fired on. -->
|
||||
@for (a of c.alerts; track a.alertId) {
|
||||
<div class="rr-case">
|
||||
<div class="rr-case-head">
|
||||
<span class="rr-rule">{{ a.rule.label }}</span>
|
||||
<span class="rr-rule-cat">{{ a.rule.category }}</span>
|
||||
<span class="rr-case-id mono">{{ a.alertId }} · raised {{ a.alertDate }}</span>
|
||||
<span class="rr-spacer"></span>
|
||||
<span class="rr-case-amt mono">₹{{ a.amount | number: '1.0-0' }}</span>
|
||||
<span [class]="statusClass(a.status)">{{ statusMeta[a.status].label }}</span>
|
||||
</div>
|
||||
<div class="rr-case-why">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
<span
|
||||
><strong>Why it triggered:</strong> {{ a.rule.description }} —
|
||||
{{ txnDetail(a) }}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<table class="rr-subtable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Txn Date</th>
|
||||
<th>Txn ID</th>
|
||||
<th>Channel</th>
|
||||
<th>Dr/Cr</th>
|
||||
<th class="num">Amount</th>
|
||||
<th class="num">Balance After</th>
|
||||
<th>Narration</th>
|
||||
<th>Counterparty</th>
|
||||
<th>AML Rule Triggered</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (t of a.txns; track t.txnId) {
|
||||
<tr [class.rr-leg--ctr]="t.ctrReportable">
|
||||
<td class="mono">{{ t.date }}</td>
|
||||
<td class="mono">{{ t.txnId }}</td>
|
||||
<td>
|
||||
<span class="rr-chan">{{ channelLabel(t.channel) }}</span>
|
||||
</td>
|
||||
<td [class.cr]="t.drCr === 'CR'" [class.dr]="t.drCr === 'DR'">{{ t.drCr }}</td>
|
||||
<td class="num strong">
|
||||
{{ t.amount | number: '1.0-0' }}
|
||||
@if (t.ctrReportable) {
|
||||
<span class="rr-tag rr-tag--ctr">CTR</span>
|
||||
}
|
||||
</td>
|
||||
<td class="num">
|
||||
{{ t.balanceAfter ? (t.balanceAfter | number: '1.0-0') : '—' }}
|
||||
</td>
|
||||
<td>{{ t.narration || '—' }}</td>
|
||||
<td>{{ t.counterparty || '—' }}</td>
|
||||
<td>
|
||||
<span class="rr-rule">{{ a.rule.label }}</span>
|
||||
@if (a.txns.length > 1) {
|
||||
<span class="rr-leg">leg {{ $index + 1 }} of {{ a.txns.length }}</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</td>
|
||||
<td class="num">{{ t.balanceAfter ? (t.balanceAfter | number: '1.0-0') : '—' }}</td>
|
||||
<td>{{ t.narration || '—' }}</td>
|
||||
<td>{{ t.counterparty || '—' }}</td>
|
||||
<td>
|
||||
<span class="rr-rule">{{ a.rule.label }}</span>
|
||||
@if (a.txns.length > 1) {
|
||||
<span class="rr-leg">leg {{ $index + 1 }} of {{ a.txns.length }}</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="rr-case-foot">
|
||||
<div class="rr-suggest">
|
||||
<fa-icon icon="clipboard-check"></fa-icon> {{ a.rule.suggestedAction }}
|
||||
</div>
|
||||
<div class="rr-case-remark">
|
||||
<strong>Reviewer:</strong> {{ a.reviewedBy || 'not yet assigned' }} —
|
||||
{{ a.remarks || 'no remarks recorded' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rr-case-foot">
|
||||
<div class="rr-suggest">
|
||||
<fa-icon icon="clipboard-check"></fa-icon> {{ a.rule.suggestedAction }}
|
||||
</div>
|
||||
<div class="rr-case-remark">
|
||||
<strong>Reviewer:</strong> {{ a.reviewedBy || 'not yet assigned' }} —
|
||||
{{ a.remarks || 'no remarks recorded' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="11" class="rr-empty">
|
||||
No customer tripped an AML rule for {{ rangeLabel }} at {{ branchLabel }}.
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @empty {
|
||||
<div class="rr-empty">No customer tripped an AML rule for {{ rangeLabel }} at {{ branchLabel }}.</div>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- ALERT WISE -->
|
||||
@@ -400,9 +413,6 @@
|
||||
<td class="mono">{{ a.clientId }}</td>
|
||||
<td>
|
||||
<strong>{{ a.customerName }}</strong>
|
||||
@if (!a.live) {
|
||||
<span class="rr-tag">Sample</span>
|
||||
}
|
||||
</td>
|
||||
<td class="mono">{{ a.accountNo || '—' }}</td>
|
||||
<td>{{ a.branchCode || '—' }}</td>
|
||||
|
||||
@@ -139,12 +139,6 @@
|
||||
span {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&--sample {
|
||||
background: var(--r-amber-l);
|
||||
border: 1px solid #fcd34d;
|
||||
color: #78350f;
|
||||
}
|
||||
}
|
||||
|
||||
/* FILTERS */
|
||||
@@ -403,6 +397,84 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* VERDICT GROUPS — the section heading carries the verdict, so rows need not repeat it. */
|
||||
.rr-group {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.rr-group-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--r-g200);
|
||||
border-left: 4px solid var(--r-g300);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.rr-group-t {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* The count sits next to the title so a reviewer sees the size of each pile at a glance. */
|
||||
.rr-group-n {
|
||||
min-width: 24px;
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--r-g100, #f1f5f9);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.rr-group-h {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
color: var(--r-g500);
|
||||
}
|
||||
|
||||
/* One colour per verdict, matched to the arrows already used in the rows. */
|
||||
.rr-group--str .rr-group-head {
|
||||
border-left-color: #dc2626;
|
||||
}
|
||||
|
||||
.rr-group--str .rr-group-n {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.rr-group--up .rr-group-head {
|
||||
border-left-color: #ea580c;
|
||||
}
|
||||
|
||||
.rr-group--up .rr-group-n {
|
||||
background: #ffedd5;
|
||||
color: #9a3412;
|
||||
}
|
||||
|
||||
.rr-group--down .rr-group-head {
|
||||
border-left-color: #16a34a;
|
||||
}
|
||||
|
||||
.rr-group--down .rr-group-n {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.rr-group--hold .rr-group-head {
|
||||
border-left-color: var(--r-g300);
|
||||
}
|
||||
|
||||
.rr-group .rr-table-wrap {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
/* TABLE */
|
||||
.rr-table-wrap {
|
||||
background: #fff;
|
||||
@@ -751,6 +823,75 @@
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/* Where the rating should land, read straight off the Current Risk column beside it. */
|
||||
.rr-move-cell {
|
||||
min-width: 150px;
|
||||
max-width: 190px;
|
||||
}
|
||||
|
||||
.rr-move {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
|
||||
&--up {
|
||||
background: var(--r-red-l);
|
||||
color: var(--r-red);
|
||||
}
|
||||
|
||||
/* Coming down is a relief, not an alarm — same green the verdict uses. */
|
||||
&--down {
|
||||
background: var(--r-green-l);
|
||||
color: var(--r-green);
|
||||
}
|
||||
|
||||
&--hold {
|
||||
background: var(--r-g100);
|
||||
color: var(--r-g500);
|
||||
}
|
||||
}
|
||||
|
||||
.rr-move-why {
|
||||
font-size: 10px;
|
||||
color: var(--r-g500);
|
||||
margin-top: 3px;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/* Drill from the verdict into the KYC screen, where the rating is actually moved. */
|
||||
.rr-kyc-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin-top: 6px;
|
||||
padding: 3px 9px;
|
||||
border: 1px solid var(--r-g300);
|
||||
border-radius: 5px;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
color: var(--r-g500);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--r-blue);
|
||||
color: var(--r-blue);
|
||||
}
|
||||
|
||||
/* A rating that has to move gets the emphasis; a settled row stays quiet. */
|
||||
&--act {
|
||||
border-color: var(--r-blue);
|
||||
color: var(--r-blue);
|
||||
}
|
||||
}
|
||||
|
||||
.rr-tag {
|
||||
display: inline-block;
|
||||
margin-left: 6px;
|
||||
|
||||
@@ -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