risk review report with on which aml rule
This commit is contained in:
@@ -591,6 +591,9 @@
|
||||
<button mat-menu-item routerLink="/reports/form-97-98" tabindex="0">
|
||||
<i class="fa fa-file-invoice menu-item-icon"></i> Form 97/98 Transaction Report
|
||||
</button>
|
||||
<button mat-menu-item routerLink="/reports/risk-review" tabindex="0">
|
||||
<i class="fa fa-user-shield menu-item-icon"></i> Risk Review Report
|
||||
</button>
|
||||
</mat-menu>
|
||||
|
||||
<!-- Admin Mega Menu — 3-column rectangular panel grouping Setup,
|
||||
@@ -1803,6 +1806,14 @@
|
||||
>
|
||||
<fa-icon icon="shield-alt" class="gl-icon"></fa-icon> AML
|
||||
</a>
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesRbiSearch('Risk Review Report')"
|
||||
routerLink="/reports/risk-review"
|
||||
(click)="rbiMenuOpen = false"
|
||||
>
|
||||
<fa-icon icon="user-shield" class="gl-icon"></fa-icon> Risk Review Report
|
||||
</a>
|
||||
</div>
|
||||
<!-- OSS Reports -->
|
||||
<div class="gl-group">
|
||||
|
||||
@@ -320,6 +320,316 @@ export const CTR_TXNS: CtrTransaction[] = [
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* ── TC1: ATM / card transactions ──
|
||||
*
|
||||
* TC2 carries what happened at the counter; TC1 carries what happened at the
|
||||
* card rails. Both feed the same CTR, because an ATM cash withdrawal is still
|
||||
* a cash transaction and counts towards the customer's monthly aggregate.
|
||||
*
|
||||
* Every row here belongs to an account that is already in CTR_TXNS and carries
|
||||
* the same reportRefNo, so the six FINnet files reconcile against each other.
|
||||
*/
|
||||
export interface CardTransaction {
|
||||
reportType: ReportType;
|
||||
reportRefNo: number;
|
||||
branchCode: string;
|
||||
accountNumber: string;
|
||||
/** FINnet account-type code, same codes used in Account_Detail. */
|
||||
accountType: string;
|
||||
relationshipFlag: 'Customer' | 'Non-Customer';
|
||||
/** Settled at the switch, or declined / reversed. */
|
||||
txnStatus: 'Success' | 'Failed' | 'Reversed';
|
||||
txnId: number;
|
||||
txnDate: string;
|
||||
txnTime: string;
|
||||
depositOrWithdrawal: 'Deposit' | 'Withdrawal';
|
||||
txnAmount: number;
|
||||
cardType: 'RuPay Debit' | 'VISA Debit' | 'Mastercard Debit';
|
||||
/** Masked to the last four digits — the PAN itself is never put in the file. */
|
||||
cardNumber: string;
|
||||
/** FINnet country code; 95 is India, as used across the KC files. */
|
||||
cardIssuingCountry: string;
|
||||
cardIssuingBank: string;
|
||||
/** NFS / NPCI switch reference for the leg. */
|
||||
switchTxnId: string;
|
||||
atmId: string;
|
||||
/** UI convenience, not part of the TC1 schema. */
|
||||
customerName?: string;
|
||||
}
|
||||
|
||||
export const TC1_TXNS: CardTransaction[] = [
|
||||
// Ref 1 — ANAND KRISHNAN: routine ATM usage alongside a large counter deposit.
|
||||
{
|
||||
reportType: 'CTR',
|
||||
reportRefNo: 1,
|
||||
branchCode: '1',
|
||||
accountNumber: '110001101000838',
|
||||
accountType: '211',
|
||||
relationshipFlag: 'Customer',
|
||||
txnStatus: 'Success',
|
||||
txnId: 9001,
|
||||
txnDate: '06-01-2026',
|
||||
txnTime: '19:42:11',
|
||||
depositOrWithdrawal: 'Withdrawal',
|
||||
txnAmount: 20000,
|
||||
cardType: 'RuPay Debit',
|
||||
cardNumber: 'XXXXXXXXXXXX4417',
|
||||
cardIssuingCountry: '95',
|
||||
cardIssuingBank: 'CBSNexora Bank Ltd',
|
||||
switchTxnId: 'NFS26010600418',
|
||||
atmId: 'NEXA0001',
|
||||
customerName: 'ANAND KRISHNAN'
|
||||
},
|
||||
{
|
||||
reportType: 'CTR',
|
||||
reportRefNo: 1,
|
||||
branchCode: '1',
|
||||
accountNumber: '110001101000838',
|
||||
accountType: '211',
|
||||
relationshipFlag: 'Customer',
|
||||
txnStatus: 'Success',
|
||||
txnId: 9002,
|
||||
txnDate: '18-01-2026',
|
||||
txnTime: '11:07:53',
|
||||
depositOrWithdrawal: 'Withdrawal',
|
||||
txnAmount: 25000,
|
||||
cardType: 'RuPay Debit',
|
||||
cardNumber: 'XXXXXXXXXXXX4417',
|
||||
cardIssuingCountry: '95',
|
||||
cardIssuingBank: 'CBSNexora Bank Ltd',
|
||||
switchTxnId: 'NFS26011800911',
|
||||
atmId: 'NEXA0003',
|
||||
customerName: 'ANAND KRISHNAN'
|
||||
},
|
||||
|
||||
// Ref 2 — MEERA SUNDARAM: card cash-out at another bank's ATM.
|
||||
{
|
||||
reportType: 'CTR',
|
||||
reportRefNo: 2,
|
||||
branchCode: '3',
|
||||
accountNumber: '110001204000712',
|
||||
accountType: '215',
|
||||
relationshipFlag: 'Customer',
|
||||
txnStatus: 'Success',
|
||||
txnId: 9003,
|
||||
txnDate: '09-01-2026',
|
||||
txnTime: '20:15:36',
|
||||
depositOrWithdrawal: 'Withdrawal',
|
||||
txnAmount: 10000,
|
||||
cardType: 'VISA Debit',
|
||||
cardNumber: 'XXXXXXXXXXXX8820',
|
||||
cardIssuingCountry: '95',
|
||||
cardIssuingBank: 'CBSNexora Bank Ltd',
|
||||
switchTxnId: 'NFS26010901277',
|
||||
atmId: 'SBIN004512',
|
||||
customerName: 'MEERA SUNDARAM'
|
||||
},
|
||||
{
|
||||
reportType: 'CTR',
|
||||
reportRefNo: 2,
|
||||
branchCode: '3',
|
||||
accountNumber: '110001204000712',
|
||||
accountType: '215',
|
||||
relationshipFlag: 'Customer',
|
||||
// Declined for insufficient balance. Failed legs are still reported: the
|
||||
// attempt itself is part of the customer's pattern.
|
||||
txnStatus: 'Failed',
|
||||
txnId: 9004,
|
||||
txnDate: '09-01-2026',
|
||||
txnTime: '20:17:02',
|
||||
depositOrWithdrawal: 'Withdrawal',
|
||||
txnAmount: 20000,
|
||||
cardType: 'VISA Debit',
|
||||
cardNumber: 'XXXXXXXXXXXX8820',
|
||||
cardIssuingCountry: '95',
|
||||
cardIssuingBank: 'CBSNexora Bank Ltd',
|
||||
switchTxnId: 'NFS26010901284',
|
||||
atmId: 'SBIN004512',
|
||||
customerName: 'MEERA SUNDARAM'
|
||||
},
|
||||
|
||||
// Ref 3 — HARPREET SINGH BEDI: cash recycler deposit through the card.
|
||||
{
|
||||
reportType: 'CTR',
|
||||
reportRefNo: 3,
|
||||
branchCode: '6',
|
||||
accountNumber: '110001307000455',
|
||||
accountType: '215',
|
||||
relationshipFlag: 'Customer',
|
||||
txnStatus: 'Success',
|
||||
txnId: 9005,
|
||||
txnDate: '12-01-2026',
|
||||
txnTime: '13:28:44',
|
||||
depositOrWithdrawal: 'Deposit',
|
||||
txnAmount: 49500,
|
||||
cardType: 'RuPay Debit',
|
||||
cardNumber: 'XXXXXXXXXXXX6193',
|
||||
cardIssuingCountry: '95',
|
||||
cardIssuingBank: 'CBSNexora Bank Ltd',
|
||||
switchTxnId: 'NFS26011200630',
|
||||
atmId: 'NEXA0002-CRM',
|
||||
customerName: 'HARPREET SINGH BEDI'
|
||||
},
|
||||
|
||||
// Ref 4 — FAROOQ ABDULLAH KHAN: repeated near-limit withdrawals in one day.
|
||||
{
|
||||
reportType: 'CTR',
|
||||
reportRefNo: 4,
|
||||
branchCode: '2',
|
||||
accountNumber: '110001408000933',
|
||||
accountType: '211',
|
||||
relationshipFlag: 'Customer',
|
||||
txnStatus: 'Success',
|
||||
txnId: 9006,
|
||||
txnDate: '16-01-2026',
|
||||
txnTime: '09:03:19',
|
||||
depositOrWithdrawal: 'Withdrawal',
|
||||
txnAmount: 50000,
|
||||
cardType: 'Mastercard Debit',
|
||||
cardNumber: 'XXXXXXXXXXXX2075',
|
||||
cardIssuingCountry: '95',
|
||||
cardIssuingBank: 'CBSNexora Bank Ltd',
|
||||
switchTxnId: 'NFS26011600142',
|
||||
atmId: 'NEXA0004',
|
||||
customerName: 'FAROOQ ABDULLAH KHAN'
|
||||
},
|
||||
{
|
||||
reportType: 'CTR',
|
||||
reportRefNo: 4,
|
||||
branchCode: '2',
|
||||
accountNumber: '110001408000933',
|
||||
accountType: '211',
|
||||
relationshipFlag: 'Customer',
|
||||
txnStatus: 'Success',
|
||||
txnId: 9007,
|
||||
txnDate: '16-01-2026',
|
||||
txnTime: '09:06:41',
|
||||
depositOrWithdrawal: 'Withdrawal',
|
||||
txnAmount: 50000,
|
||||
cardType: 'Mastercard Debit',
|
||||
cardNumber: 'XXXXXXXXXXXX2075',
|
||||
cardIssuingCountry: '95',
|
||||
cardIssuingBank: 'CBSNexora Bank Ltd',
|
||||
switchTxnId: 'NFS26011600149',
|
||||
atmId: 'NEXA0004',
|
||||
customerName: 'FAROOQ ABDULLAH KHAN'
|
||||
},
|
||||
{
|
||||
reportType: 'CTR',
|
||||
reportRefNo: 4,
|
||||
branchCode: '2',
|
||||
accountNumber: '110001408000933',
|
||||
accountType: '211',
|
||||
relationshipFlag: 'Customer',
|
||||
// Dispensed short and auto-reversed by the switch the same evening.
|
||||
txnStatus: 'Reversed',
|
||||
txnId: 9008,
|
||||
txnDate: '16-01-2026',
|
||||
txnTime: '09:09:05',
|
||||
depositOrWithdrawal: 'Withdrawal',
|
||||
txnAmount: 50000,
|
||||
cardType: 'Mastercard Debit',
|
||||
cardNumber: 'XXXXXXXXXXXX2075',
|
||||
cardIssuingCountry: '95',
|
||||
cardIssuingBank: 'CBSNexora Bank Ltd',
|
||||
switchTxnId: 'NFS26011600155',
|
||||
atmId: 'NEXA0004',
|
||||
customerName: 'FAROOQ ABDULLAH KHAN'
|
||||
},
|
||||
|
||||
// Ref 5 — DEEPIKA RANGANATHAN
|
||||
{
|
||||
reportType: 'CTR',
|
||||
reportRefNo: 5,
|
||||
branchCode: '9',
|
||||
accountNumber: '110001509000268',
|
||||
accountType: '215',
|
||||
relationshipFlag: 'Customer',
|
||||
txnStatus: 'Success',
|
||||
txnId: 9009,
|
||||
txnDate: '21-01-2026',
|
||||
txnTime: '17:51:28',
|
||||
depositOrWithdrawal: 'Withdrawal',
|
||||
txnAmount: 15000,
|
||||
cardType: 'RuPay Debit',
|
||||
cardNumber: 'XXXXXXXXXXXX9354',
|
||||
cardIssuingCountry: '95',
|
||||
cardIssuingBank: 'CBSNexora Bank Ltd',
|
||||
switchTxnId: 'NFS26012101508',
|
||||
atmId: 'HDFC009871',
|
||||
customerName: 'DEEPIKA RANGANATHAN'
|
||||
},
|
||||
|
||||
// Ref 6 — RAJENDRA PRASAD YADAV
|
||||
{
|
||||
reportType: 'CTR',
|
||||
reportRefNo: 6,
|
||||
branchCode: '5',
|
||||
accountNumber: '110001610000841',
|
||||
accountType: '215',
|
||||
relationshipFlag: 'Customer',
|
||||
txnStatus: 'Success',
|
||||
txnId: 9010,
|
||||
txnDate: '23-01-2026',
|
||||
txnTime: '10:34:12',
|
||||
depositOrWithdrawal: 'Withdrawal',
|
||||
txnAmount: 40000,
|
||||
cardType: 'RuPay Debit',
|
||||
cardNumber: 'XXXXXXXXXXXX1268',
|
||||
cardIssuingCountry: '95',
|
||||
cardIssuingBank: 'CBSNexora Bank Ltd',
|
||||
switchTxnId: 'NFS26012300287',
|
||||
atmId: 'NEXA0005',
|
||||
customerName: 'RAJENDRA PRASAD YADAV'
|
||||
},
|
||||
{
|
||||
reportType: 'CTR',
|
||||
reportRefNo: 6,
|
||||
branchCode: '5',
|
||||
accountNumber: '110001610000841',
|
||||
accountType: '215',
|
||||
relationshipFlag: 'Customer',
|
||||
txnStatus: 'Success',
|
||||
txnId: 9011,
|
||||
txnDate: '28-01-2026',
|
||||
txnTime: '18:22:47',
|
||||
depositOrWithdrawal: 'Withdrawal',
|
||||
txnAmount: 35000,
|
||||
cardType: 'RuPay Debit',
|
||||
cardNumber: 'XXXXXXXXXXXX1268',
|
||||
cardIssuingCountry: '95',
|
||||
cardIssuingBank: 'CBSNexora Bank Ltd',
|
||||
switchTxnId: 'NFS26012801733',
|
||||
atmId: 'NEXA0005',
|
||||
customerName: 'RAJENDRA PRASAD YADAV'
|
||||
},
|
||||
|
||||
// Ref 7 — SHABANA TARIQ MIRZA: the account's card is used by a third party,
|
||||
// which is why the relationship flag differs from the rest.
|
||||
{
|
||||
reportType: 'CTR',
|
||||
reportRefNo: 7,
|
||||
branchCode: '12',
|
||||
accountNumber: '110001711000527',
|
||||
accountType: '215',
|
||||
relationshipFlag: 'Non-Customer',
|
||||
txnStatus: 'Success',
|
||||
txnId: 9012,
|
||||
txnDate: '27-01-2026',
|
||||
txnTime: '21:08:33',
|
||||
depositOrWithdrawal: 'Withdrawal',
|
||||
txnAmount: 30000,
|
||||
cardType: 'VISA Debit',
|
||||
cardNumber: 'XXXXXXXXXXXX7741',
|
||||
cardIssuingCountry: '95',
|
||||
cardIssuingBank: 'CBSNexora Bank Ltd',
|
||||
switchTxnId: 'NFS26012702094',
|
||||
atmId: 'ICIC007233',
|
||||
customerName: 'SHABANA TARIQ MIRZA'
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* ── STR cases ──
|
||||
* Raised either by the AML rules engine or by an officer flagging a CTR row as
|
||||
|
||||
+138
-289
@@ -109,6 +109,52 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- WHEN TO REPORT — the five PMLA obligations -->
|
||||
<div class="ob-block">
|
||||
<div class="ob-head">
|
||||
<div>
|
||||
<h3 class="ob-title"><fa-icon icon="gavel"></fa-icon> When to report to FIU-IND</h3>
|
||||
<p class="ob-sub">
|
||||
The five reports a reporting entity owes under the Prevention of Money Laundering Act. All are filed
|
||||
electronically through the FINnet 2.0 portal.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ob-grid">
|
||||
@for (o of obligations; track o.code) {
|
||||
<div class="ob-card" [class.ob-card--off]="!o.handled">
|
||||
<div class="ob-card-h">
|
||||
<fa-icon [icon]="o.icon" class="ob-ic"></fa-icon>
|
||||
<span class="ob-code">{{ o.code }}</span>
|
||||
<span class="ob-name">{{ o.name }}</span>
|
||||
@if (o.handled) {
|
||||
<span class="ob-flag ob-flag--on">Filed here</span>
|
||||
} @else {
|
||||
<span class="ob-flag">Tracked</span>
|
||||
}
|
||||
</div>
|
||||
<div class="ob-row">
|
||||
<label>Report when</label><span>{{ o.trigger }}</span>
|
||||
</div>
|
||||
<div class="ob-row">
|
||||
<label>Deadline</label><span class="ob-due">{{ o.deadline }}</span>
|
||||
</div>
|
||||
<div class="ob-note">{{ o.note }}</div>
|
||||
@if (o.code === 'CTR') {
|
||||
<button class="dc-btn" (click)="showTab('ctr')">
|
||||
<fa-icon icon="external-link-alt"></fa-icon> Open CTR
|
||||
</button>
|
||||
} @else if (o.code === 'STR') {
|
||||
<button class="dc-btn" (click)="showTab('str')">
|
||||
<fa-icon icon="external-link-alt"></fa-icon> Open STR
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Compliance cards -->
|
||||
<div class="fiu-compliance">
|
||||
<div class="dc-card">
|
||||
@@ -149,303 +195,106 @@
|
||||
<!-- CTR -->
|
||||
@if (activeTab === 'ctr') {
|
||||
<div class="fiu-section">
|
||||
<!-- LIVE: cash-related CTR threshold alerts -->
|
||||
<div class="fiu-form-meta">
|
||||
<div class="fiu-form-title">
|
||||
<h3>Live CTR Threshold Alerts</h3>
|
||||
<p>
|
||||
Mechanical cash threshold breaches. CTR is filed regardless of outcome — where the source of funds cannot be
|
||||
evidenced, send the transaction to STR for the Principal Officer to investigate.
|
||||
</p>
|
||||
</div>
|
||||
<select class="fiu-inp" [(ngModel)]="amlAlertStatusFilter" (ngModelChange)="loadAmlAlerts()">
|
||||
<option value="">All statuses</option>
|
||||
<option value="RAISED">Raised</option>
|
||||
<option value="UNDER_REVIEW">Under Review</option>
|
||||
<option value="WHITELISTED">Whitelisted</option>
|
||||
<option value="ESCALATED">Escalated</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="fiu-tbl-wrap">
|
||||
<table class="fiu-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Client</th>
|
||||
<th>Type</th>
|
||||
<th>Rule</th>
|
||||
<th class="ta-r">Amount</th>
|
||||
<th>Raised On</th>
|
||||
<th>Status</th>
|
||||
<th class="ta-c">Routes To</th>
|
||||
<th>Notes</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (alert of ctrAlerts; track alert.id) {
|
||||
<tr>
|
||||
<td>{{ alert.id }}</td>
|
||||
<td>{{ alert.clientDisplayName }}</td>
|
||||
<td>{{ alert.transactionType }}</td>
|
||||
<td>{{ alert.ruleCode }} — {{ alert.ruleDescription }}</td>
|
||||
<td class="ta-r mono">₹{{ alert.amount | number: '1.0-0' }}</td>
|
||||
<td>{{ alert.raisedOn | date: 'medium' }}</td>
|
||||
<td>
|
||||
<span [class]="amlStatusClass(alert.status)">{{ alert.status }}</span>
|
||||
</td>
|
||||
<td class="ta-c">
|
||||
<span [class]="alertRouteClass(alert.status)">{{ alertRouteLabel(alert.status) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
@if (alert.status === 'RAISED' || alert.status === 'UNDER_REVIEW') {
|
||||
<input
|
||||
type="text"
|
||||
class="fiu-inp"
|
||||
placeholder="Review notes"
|
||||
[(ngModel)]="amlReviewNotes[alert.id]"
|
||||
/>
|
||||
} @else {
|
||||
<span class="muted">{{ alert.reviewNotes }}</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (alert.status === 'RAISED' || alert.status === 'UNDER_REVIEW') {
|
||||
<div class="fiu-action-group" *mifosxHasPermission="'REVIEW_AML_ALERT'">
|
||||
<button class="fiu-btn fiu-btn-xml" (click)="escalateAlert(alert)">Send to STR</button>
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="10" class="fiu-empty">No live alerts.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="fiu-tbl-legend">
|
||||
<span class="report-pill report-pending">Pending</span> cash breach awaiting review ·
|
||||
<span class="report-pill report-str">Referred to STR</span> source of funds not evidenced — sent to the
|
||||
Principal Officer, who decides there whether to whitelist it or file an STR
|
||||
</p>
|
||||
|
||||
<!-- LIVE: monthly CTR reports (mechanical, threshold-based) -->
|
||||
<div class="fiu-form-meta">
|
||||
<div class="fiu-form-title">
|
||||
<h3>Monthly CTR Reports</h3>
|
||||
<p>
|
||||
Threshold: <strong>₹{{ bank.cashThreshold | number }}</strong> per single txn / connected aggregate
|
||||
per month
|
||||
</p>
|
||||
</div>
|
||||
<div class="fiu-action-group">
|
||||
<input class="fiu-inp" type="month" [(ngModel)]="ctrGenerateMonth" />
|
||||
<button class="fiu-btn fiu-btn-g" *mifosxHasPermission="'REVIEW_AML_ALERT'" (click)="generateCtrReport()">
|
||||
Generate
|
||||
<!-- FINNET FILE SEQUENCE — the CTR submission, in prescribed order -->
|
||||
<div class="seq-block">
|
||||
<div class="fiu-form-meta">
|
||||
<div class="fiu-form-title">
|
||||
<h3>CTR Submission Files — FINnet 2.0</h3>
|
||||
<p>
|
||||
The CTR is built in this order: the account is described first, then who is behind it, then the two
|
||||
transaction files. Every grid below is the exact content of its CSV. The customer cards (KC1 / KC2) travel
|
||||
with the same bundle and are worked on the Customer Cards tab.
|
||||
</p>
|
||||
</div>
|
||||
<button class="fiu-btn fiu-btn-xls" (click)="exportAll()">
|
||||
<fa-icon icon="file-csv"></fa-icon> Download full bundle
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fiu-tbl-wrap">
|
||||
<table class="fiu-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Month</th>
|
||||
<th>Generated On</th>
|
||||
<th class="ta-c">Records</th>
|
||||
<th>Status</th>
|
||||
<th>Submitted On</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of amlCtrReports; track r.id) {
|
||||
<tr>
|
||||
<td>{{ r.reportMonth }}</td>
|
||||
<td>{{ r.generatedOn | date: 'medium' }}</td>
|
||||
<td class="ta-c">{{ r.recordCount }}</td>
|
||||
<td>
|
||||
<span [class]="amlStatusClass(r.status)">{{ r.status }}</span>
|
||||
</td>
|
||||
<td>{{ r.submittedOn ? (r.submittedOn | date: 'medium') : '—' }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="5" class="fiu-empty">No CTR reports generated yet.</td>
|
||||
</tr>
|
||||
|
||||
<!-- Scope: these controls decide which transactions the six files carry. -->
|
||||
<div class="fiu-filters">
|
||||
<div class="fiu-filter">
|
||||
<label>Type</label>
|
||||
<select class="fiu-inp" [(ngModel)]="ctrFilterMode">
|
||||
<option value="">All</option>
|
||||
<option value="Deposit">Deposit</option>
|
||||
<option value="Withdrawal">Withdrawal</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="fiu-filter">
|
||||
<label>Account contains</label>
|
||||
<input class="fiu-inp" type="text" [(ngModel)]="ctrFilterAccount" placeholder="A/c partial" />
|
||||
</div>
|
||||
<div class="fiu-filter">
|
||||
<label>Min Amount (₹)</label>
|
||||
<input class="fiu-inp" type="number" [(ngModel)]="ctrFilterMinAmount" placeholder="0" />
|
||||
</div>
|
||||
<button class="fiu-btn fiu-btn-g" (click)="resetCtrFilters()">Reset</button>
|
||||
</div>
|
||||
|
||||
<!-- Step rail -->
|
||||
<div class="seq-rail">
|
||||
@for (f of finnetSequence; track f.key) {
|
||||
<button class="seq-step" [class.active]="activeFinnetFile === f.key" (click)="showFinnetFile(f.key)">
|
||||
<span class="seq-n">{{ f.step }}</span>
|
||||
<span class="seq-txt">
|
||||
<strong>{{ f.name }}</strong>
|
||||
<em>{{ f.table.rows.length }} {{ f.table.rows.length === 1 ? 'row' : 'rows' }}</em>
|
||||
</span>
|
||||
</button>
|
||||
@if (!$last) {
|
||||
<fa-icon icon="chevron-right" class="seq-arrow"></fa-icon>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- FINnet export template (separate concern — TC1/TC2 file-format generation) -->
|
||||
<div class="fiu-form-meta">
|
||||
<div class="fiu-form-title">
|
||||
<h3>FINnet TC2 Export Template</h3>
|
||||
<p>
|
||||
All cash transactions ≥ ₹{{ bank.cashThreshold | number }} (single or aggregate per month per account)
|
||||
</p>
|
||||
</div>
|
||||
<button class="fiu-btn fiu-btn-csv" (click)="exportTc2()">
|
||||
<fa-icon icon="file-csv"></fa-icon> Download TC2.csv
|
||||
</button>
|
||||
</div>
|
||||
<!-- Open file -->
|
||||
<div class="seq-panel">
|
||||
<div class="seq-panel-h">
|
||||
<div>
|
||||
<h4>Step {{ openFinnetFile.step }} — {{ openFinnetFile.title }}</h4>
|
||||
<p>{{ openFinnetFile.purpose }}</p>
|
||||
</div>
|
||||
<button class="fiu-btn fiu-btn-csv" (click)="downloadOpenFinnetFile()">
|
||||
<fa-icon icon="file-csv"></fa-icon> {{ openFinnetFile.name }}.csv
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="fiu-filters">
|
||||
<div class="fiu-filter">
|
||||
<label>Type</label>
|
||||
<select class="fiu-inp" [(ngModel)]="ctrFilterMode">
|
||||
<option value="">All</option>
|
||||
<option value="Deposit">Deposit</option>
|
||||
<option value="Withdrawal">Withdrawal</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="fiu-filter">
|
||||
<label>Account contains</label>
|
||||
<input class="fiu-inp" type="text" [(ngModel)]="ctrFilterAccount" placeholder="A/c partial" />
|
||||
</div>
|
||||
<div class="fiu-filter">
|
||||
<label>Min Amount (₹)</label>
|
||||
<input class="fiu-inp" type="number" [(ngModel)]="ctrFilterMinAmount" placeholder="0" />
|
||||
</div>
|
||||
<button class="fiu-btn fiu-btn-g" (click)="resetCtrFilters()">Reset</button>
|
||||
</div>
|
||||
|
||||
<div class="fiu-tbl-wrap">
|
||||
<table class="fiu-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="ta-c">Ref</th>
|
||||
<th>Account</th>
|
||||
<th>Customer</th>
|
||||
<th class="ta-c">Type</th>
|
||||
<th>Date / Time</th>
|
||||
<th class="ta-r">Amount</th>
|
||||
<th>Instrument</th>
|
||||
<th>Branch</th>
|
||||
<th class="ta-c">Relationship</th>
|
||||
<th class="ta-c">Filed Under</th>
|
||||
<th>Compliance Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (t of filteredCtr; track t.txnId) {
|
||||
<tr>
|
||||
<td class="ta-c">{{ t.reportRefNo }}</td>
|
||||
<td class="mono">{{ t.accountNumber }}</td>
|
||||
<td>{{ t.customerName }}</td>
|
||||
<td class="ta-c">
|
||||
<span
|
||||
class="dir-pill"
|
||||
[class.deposit]="t.depositOrWithdrawal === 'Deposit'"
|
||||
[class.withdrawal]="t.depositOrWithdrawal === 'Withdrawal'"
|
||||
>
|
||||
{{ t.depositOrWithdrawal }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{{ t.txnDate }} <span class="muted">{{ t.txnTime }}</span>
|
||||
</td>
|
||||
<td class="ta-r mono">
|
||||
<strong>₹{{ t.txnAmount | number: '1.0-0' }}</strong>
|
||||
</td>
|
||||
<td>
|
||||
{{ t.instrumentType }} <span class="muted">{{ t.instrumentId }}</span>
|
||||
</td>
|
||||
<td class="mono">
|
||||
{{ t.branchCode }} <span class="muted">{{ branchNameFor(t.branchCode) }}</span>
|
||||
</td>
|
||||
<td class="ta-c">
|
||||
<span class="rel-pill" [class.non-cust]="t.relationshipFlag === 'Non-Customer'">{{
|
||||
t.relationshipFlag
|
||||
}}</span>
|
||||
</td>
|
||||
<td class="ta-c">
|
||||
<span class="report-pill report-ctr" title="Always included in the monthly CTR / TC2 file">CTR</span>
|
||||
@if (t.complianceStatus === 'REFERRED') {
|
||||
<span
|
||||
class="report-pill report-str"
|
||||
title="Also under investigation as an STR case — the transaction remains in the CTR file"
|
||||
>+ STR</span
|
||||
>
|
||||
<div class="seq-tbl-wrap">
|
||||
<table class="seq-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="seq-rownum">#</th>
|
||||
@for (h of openFinnetFile.table.headers; track $index) {
|
||||
<th>{{ h }}</th>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (t.complianceStatus === 'REFERRED') {
|
||||
<span class="status-pill status-escalated">REFERRED TO STR</span>
|
||||
<button class="fiu-link-btn" (click)="openStrCase(t)">View in STR tab →</button>
|
||||
} @else if (t.complianceStatus === 'EVIDENCED') {
|
||||
<span class="status-pill status-whitelisted">EVIDENCE ON FILE</span>
|
||||
<div class="muted" style="margin-top: 3px">
|
||||
{{ t.evidenceDocType }}
|
||||
@if (t.evidenceReference) {
|
||||
<span> · {{ t.evidenceReference }}</span>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="fiu-action-group" style="gap: 4px" *mifosxHasPermission="'REVIEW_AML_ALERT'">
|
||||
<button class="fiu-btn fiu-btn-evidence" (click)="startCtrEvidence(t)">Record evidence</button>
|
||||
<button class="fiu-btn fiu-btn-refer" (click)="referCtrTxnToStr(t)">Send to STR</button>
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Inline evidence capture for this transaction -->
|
||||
@if (evidencingTxn?.txnId === t.txnId) {
|
||||
<tr class="evidence-row">
|
||||
<td colspan="12">
|
||||
<div class="evidence-panel">
|
||||
<div class="evidence-t">
|
||||
<fa-icon icon="check-circle"></fa-icon>
|
||||
Record source-of-funds evidence for {{ t.customerName }} — ₹{{
|
||||
t.txnAmount | number: '1.0-0'
|
||||
}}
|
||||
</div>
|
||||
<div class="evidence-grid">
|
||||
<div class="form-row">
|
||||
<label for="ctrDoc{{ t.txnId }}">Document produced</label>
|
||||
<select id="ctrDoc{{ t.txnId }}" class="fiu-inp" [(ngModel)]="ctrEvidenceDocType">
|
||||
<option value="">Select document…</option>
|
||||
@for (d of evidenceDocTypes; track d) {
|
||||
<option [value]="d">{{ d }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="ctrRef{{ t.txnId }}">Document / reference number</label>
|
||||
<input id="ctrRef{{ t.txnId }}" class="fiu-inp" type="text" [(ngModel)]="ctrEvidenceRef" />
|
||||
</div>
|
||||
<div class="fiu-action-group">
|
||||
<button class="fiu-btn fiu-btn-evidence" (click)="confirmCtrEvidence()">
|
||||
Confirm — CTR only
|
||||
</button>
|
||||
<button class="fiu-btn" (click)="cancelCtrEvidence()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="evidence-note">
|
||||
Recording evidence is a documentation step, not a suspicion verdict. The transaction is reported
|
||||
in the CTR either way — this simply confirms no STR referral is needed.
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="11" class="fiu-empty">No transactions match the filters.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of openFinnetFile.table.rows; track $index) {
|
||||
<tr>
|
||||
<td class="seq-rownum">{{ $index + 1 }}</td>
|
||||
@for (c of r; track $index) {
|
||||
<td>{{ c }}</td>
|
||||
}
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td [attr.colspan]="openFinnetFile.table.headers.length + 1" class="seq-empty">
|
||||
@if (openFinnetFile.key === 'tc1') {
|
||||
No ATM or card transactions match the current scope. When the bank books none at all, TC1 is
|
||||
still submitted as a header-only file — that is what FIU-IND expects.
|
||||
} @else {
|
||||
No rows for the current filters.
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="fiu-tbl-legend">
|
||||
<span class="report-pill report-ctr">CTR</span> every cash txn ≥ threshold is reported here regardless of
|
||||
outcome. <strong>Record evidence</strong> when the customer produces valid documents — CTR only, no STR ·
|
||||
<span class="report-pill report-str">+ STR</span> <strong>Send to STR</strong> when the source of funds cannot
|
||||
be evidenced; the Principal Officer investigates and decides whether to file with FIU-IND. A referred
|
||||
transaction stays in the CTR file as well.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
@@ -1549,3 +1549,301 @@
|
||||
color: var(--f-g500);
|
||||
}
|
||||
}
|
||||
|
||||
/* ═══ WHEN TO REPORT — the five PMLA obligations ═══ */
|
||||
.ob-block {
|
||||
margin: 18px 0 20px;
|
||||
}
|
||||
|
||||
.ob-head {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.ob-title {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ob-sub {
|
||||
margin: 3px 0 0;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
max-width: 860px;
|
||||
}
|
||||
|
||||
.ob-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.ob-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-left: 4px solid #1a237e;
|
||||
border-radius: 11px;
|
||||
padding: 13px 15px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
|
||||
/* Obligations the module only tracks read back a shade quieter than the
|
||||
two it actually files, so the distinction is visible at a glance. */
|
||||
&--off {
|
||||
border-left-color: #cbd5e1;
|
||||
background: #fcfcfd;
|
||||
}
|
||||
}
|
||||
|
||||
.ob-card-h {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ob-ic {
|
||||
color: #1a237e;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.ob-code {
|
||||
font-weight: 800;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.ob-name {
|
||||
font-size: 12px;
|
||||
color: #334155;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ob-flag {
|
||||
margin-left: auto;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
white-space: nowrap;
|
||||
|
||||
&--on {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
}
|
||||
|
||||
.ob-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
|
||||
label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 12.5px;
|
||||
color: #0f172a;
|
||||
line-height: 1.45;
|
||||
}
|
||||
}
|
||||
|
||||
.ob-due {
|
||||
font-weight: 700;
|
||||
color: #b45309 !important;
|
||||
}
|
||||
|
||||
.ob-note {
|
||||
font-size: 11.5px;
|
||||
color: #64748b;
|
||||
font-style: italic;
|
||||
border-top: 1px dashed #e2e8f0;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
/* ═══ FINNET FILE SEQUENCE ═══ */
|
||||
.seq-block {
|
||||
margin-top: 26px;
|
||||
padding-top: 18px;
|
||||
border-top: 2px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.seq-rail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.seq-step {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 7px 13px;
|
||||
border: 1px solid #cbd5e1;
|
||||
background: #fff;
|
||||
border-radius: 9px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
border-color: #1a237e;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #1a237e;
|
||||
border-color: #1a237e;
|
||||
|
||||
.seq-n {
|
||||
background: #fff;
|
||||
color: #1a237e;
|
||||
}
|
||||
|
||||
.seq-txt strong,
|
||||
.seq-txt em {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.seq-n {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
border-radius: 50%;
|
||||
background: #1a237e;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
flex: 0 0 21px;
|
||||
}
|
||||
|
||||
.seq-txt {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.25;
|
||||
|
||||
strong {
|
||||
font-size: 12.5px;
|
||||
color: #0f172a;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
em {
|
||||
font-size: 10.5px;
|
||||
color: #64748b;
|
||||
font-style: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.seq-arrow {
|
||||
color: #cbd5e1;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.seq-panel {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 11px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.seq-panel-h {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
padding: 12px 16px;
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
flex-wrap: wrap;
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-size: 13.5px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 2px 0 0;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
max-width: 780px;
|
||||
}
|
||||
}
|
||||
|
||||
.seq-tbl-wrap {
|
||||
max-height: 460px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.seq-tbl {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 11.5px;
|
||||
white-space: nowrap;
|
||||
|
||||
th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
background: #eef2f7;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: #334155;
|
||||
border-bottom: 1px solid #cbd5e1;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
font-family: 'DM Mono', monospace;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
tbody tr:hover td {
|
||||
background: #f8fafc;
|
||||
}
|
||||
}
|
||||
|
||||
.seq-rownum {
|
||||
color: #94a3b8 !important;
|
||||
font-weight: 700;
|
||||
text-align: right;
|
||||
position: sticky;
|
||||
left: 0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.seq-tbl thead .seq-rownum {
|
||||
background: #eef2f7;
|
||||
}
|
||||
|
||||
.seq-empty {
|
||||
padding: 36px !important;
|
||||
text-align: center;
|
||||
color: #64748b;
|
||||
font-family: inherit !important;
|
||||
white-space: normal;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
+298
-36
@@ -15,8 +15,10 @@ import { AuthenticationService } from 'app/core/authentication/authentication.se
|
||||
import {
|
||||
BRANCHES,
|
||||
BranchMaster,
|
||||
CardTransaction,
|
||||
CtrTransaction,
|
||||
CTR_TXNS,
|
||||
TC1_TXNS,
|
||||
FIU_BANK,
|
||||
FIU_BRANCH_CODE_TO_CANONICAL,
|
||||
KC1_INDIVIDUALS,
|
||||
@@ -29,6 +31,90 @@ import {
|
||||
STR_TXNS
|
||||
} from './fiu-data';
|
||||
|
||||
/** A FINnet file rendered as a grid — the same rows that go into the CSV. */
|
||||
export interface FinnetTable {
|
||||
headers: string[];
|
||||
rows: string[][];
|
||||
}
|
||||
|
||||
export interface FinnetFile {
|
||||
key: string;
|
||||
/** File name without the extension, as FIU-IND names it. */
|
||||
name: string;
|
||||
/** Position in the prescribed submission order. */
|
||||
step: number;
|
||||
title: string;
|
||||
purpose: string;
|
||||
table: FinnetTable;
|
||||
}
|
||||
|
||||
/**
|
||||
* The five reports a reporting entity owes FIU-IND under the PMLA, with the
|
||||
* trigger for each. All of them are filed electronically through FINnet 2.0.
|
||||
*/
|
||||
export interface FiuObligation {
|
||||
code: string;
|
||||
name: string;
|
||||
trigger: string;
|
||||
deadline: string;
|
||||
icon: string;
|
||||
/** Whether this module currently files the report or only tracks the rule. */
|
||||
handled: boolean;
|
||||
note: string;
|
||||
}
|
||||
|
||||
export const FIU_OBLIGATIONS: FiuObligation[] = [
|
||||
{
|
||||
code: 'CTR',
|
||||
name: 'Cash Transaction Report',
|
||||
trigger:
|
||||
'Cash transactions exceeding ₹10,00,000 in a calendar month — a single transaction or a series of connected ones.',
|
||||
deadline: 'By the 15th of the following month',
|
||||
icon: 'coins',
|
||||
handled: true,
|
||||
note: 'Filed from the CTR tab as the six-file FINnet bundle.'
|
||||
},
|
||||
{
|
||||
code: 'STR',
|
||||
name: 'Suspicious Transaction Report',
|
||||
trigger:
|
||||
'Any transaction that gives rise to a reasonable ground of suspicion of money laundering or a related offence — regardless of amount, and whether or not it was completed.',
|
||||
deadline: 'Within 7 working days of forming the suspicion',
|
||||
icon: 'exclamation-triangle',
|
||||
handled: true,
|
||||
note: 'Filed from the STR tab as FINnet STR XML.'
|
||||
},
|
||||
{
|
||||
code: 'CBWTR',
|
||||
name: 'Cross-Border Wire Transfer Report',
|
||||
trigger:
|
||||
'Cross-border wire transfers exceeding ₹5,00,000, where the origin or destination of the funds is outside India.',
|
||||
deadline: 'By the 15th of the following month',
|
||||
icon: 'globe',
|
||||
handled: false,
|
||||
note: 'Not applicable — this bank holds no AD licence and books no cross-border wires.'
|
||||
},
|
||||
{
|
||||
code: 'CCR',
|
||||
name: 'Counterfeit Currency Report',
|
||||
trigger:
|
||||
'Any forged or counterfeit currency note detected, at the counter or in a remittance. No monetary threshold applies.',
|
||||
deadline: 'By the 15th of the following month',
|
||||
icon: 'money-bill-wave',
|
||||
handled: false,
|
||||
note: 'Raised from the branch counterfeit-note register; not yet wired into this module.'
|
||||
},
|
||||
{
|
||||
code: 'NTR',
|
||||
name: 'Non-Profit Organisation Transaction Report',
|
||||
trigger: 'Receipts by a non-profit organisation exceeding ₹10,00,000, or its equivalent in foreign currency.',
|
||||
deadline: 'By the 15th of the following month',
|
||||
icon: 'hand-holding-heart',
|
||||
handled: false,
|
||||
note: 'Depends on NPO flagging in the customer master; not yet wired into this module.'
|
||||
}
|
||||
];
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-fiu',
|
||||
templateUrl: './fiu.component.html',
|
||||
@@ -48,8 +134,12 @@ export class FiuComponent implements OnInit {
|
||||
/** Active tab */
|
||||
activeTab: 'dashboard' | 'ctr' | 'str' | 'customers' | 'files' = 'dashboard';
|
||||
|
||||
/** The five PMLA reporting obligations, shown on the FIU landing tab. */
|
||||
readonly obligations = FIU_OBLIGATIONS;
|
||||
|
||||
bank = FIU_BANK;
|
||||
ctrTxns: CtrTransaction[] = CTR_TXNS;
|
||||
tc1Txns: CardTransaction[] = TC1_TXNS;
|
||||
strTxns: StrTransaction[] = STR_TXNS;
|
||||
kc1: KycIndividual[] = KC1_INDIVIDUALS;
|
||||
kc2: KycEntity[] = KC2_ENTITIES;
|
||||
@@ -748,6 +838,17 @@ export class FiuComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
/** ATM / card legs under the same scope controls, so TC1 and TC2 stay in step. */
|
||||
get filteredTc1(): CardTransaction[] {
|
||||
const acc = this.ctrFilterAccount.trim();
|
||||
return this.tc1Txns.filter((c) => {
|
||||
if (this.ctrFilterMode && c.depositOrWithdrawal !== this.ctrFilterMode) return false;
|
||||
if (acc && !c.accountNumber.includes(acc)) return false;
|
||||
if (this.ctrFilterMinAmount > 0 && c.txnAmount < this.ctrFilterMinAmount) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
get filteredStr(): StrTransaction[] {
|
||||
return this.scopedStrTxns.filter((s) => {
|
||||
if (this.strFilterStatus && s.status !== this.strFilterStatus) return false;
|
||||
@@ -797,9 +898,14 @@ export class FiuComponent implements OnInit {
|
||||
return map[c];
|
||||
}
|
||||
|
||||
// -------------------- Exports --------------------
|
||||
/** TC2 — Branch / cash transaction CSV */
|
||||
exportTc2(): void {
|
||||
// -------------------- FINnet file builders --------------------
|
||||
/**
|
||||
* Each FINnet file is built once, here, and used for both the on-screen
|
||||
* preview and the CSV download. Keeping a single builder per file is what
|
||||
* guarantees the branch sees on screen exactly what is submitted — the two
|
||||
* used to be written out separately, which let them drift.
|
||||
*/
|
||||
private buildTc2(): FinnetTable {
|
||||
const headers = [
|
||||
'Report Type',
|
||||
'Report Reference Number',
|
||||
@@ -818,7 +924,7 @@ export class FiuComponent implements OnInit {
|
||||
'Third Party PAN (Non-Customer)',
|
||||
'Declaration (If Third Party PAN (Non-Customer) is not available)'
|
||||
];
|
||||
const rows = [headers];
|
||||
const rows: string[][] = [];
|
||||
for (const t of this.filteredCtr) {
|
||||
rows.push([
|
||||
t.reportType,
|
||||
@@ -839,12 +945,11 @@ export class FiuComponent implements OnInit {
|
||||
t.declaration
|
||||
]);
|
||||
}
|
||||
this.saveCsv(rows, 'TC2.csv');
|
||||
this.toast(`TC2 exported (${this.filteredCtr.length} rows)`);
|
||||
return { headers, rows };
|
||||
}
|
||||
|
||||
/** TC1 header-only template (UI doesn't track ATM/card txns) */
|
||||
exportTc1(): void {
|
||||
/** TC1 — ATM / card transactions. Header-only: this bank books no card txns. */
|
||||
private buildTc1(): FinnetTable {
|
||||
const headers = [
|
||||
'Relationship Flag',
|
||||
'Transaction Status',
|
||||
@@ -865,12 +970,34 @@ export class FiuComponent implements OnInit {
|
||||
'Branch Code of Account',
|
||||
'Report Type'
|
||||
];
|
||||
this.saveCsv([headers], 'TC1.csv');
|
||||
this.toast('TC1 template exported (header only)');
|
||||
const rows: string[][] = [];
|
||||
for (const c of this.filteredTc1) {
|
||||
rows.push([
|
||||
c.relationshipFlag,
|
||||
c.txnStatus,
|
||||
c.txnId.toString(),
|
||||
c.txnDate.replace(/-/g, '/'),
|
||||
c.txnTime,
|
||||
c.depositOrWithdrawal,
|
||||
c.txnAmount.toString(),
|
||||
c.accountNumber,
|
||||
c.accountType,
|
||||
c.cardType,
|
||||
c.cardNumber,
|
||||
c.cardIssuingCountry,
|
||||
c.cardIssuingBank,
|
||||
c.reportRefNo.toString(),
|
||||
c.switchTxnId,
|
||||
c.atmId,
|
||||
c.branchCode,
|
||||
c.reportType
|
||||
]);
|
||||
}
|
||||
return { headers, rows };
|
||||
}
|
||||
|
||||
/** KC1 — Individual customers */
|
||||
exportKc1(): void {
|
||||
private buildKc1(): FinnetTable {
|
||||
const headers = [
|
||||
'Report Reference Number',
|
||||
'UCIC',
|
||||
@@ -917,7 +1044,7 @@ export class FiuComponent implements OnInit {
|
||||
'Other Identifier Number',
|
||||
'Primary Entity'
|
||||
];
|
||||
const rows = [headers];
|
||||
const rows: string[][] = [];
|
||||
for (const k of this.kc1) {
|
||||
rows.push([
|
||||
k.reportRefNo.toString(),
|
||||
@@ -966,12 +1093,11 @@ export class FiuComponent implements OnInit {
|
||||
'Yes'
|
||||
]);
|
||||
}
|
||||
this.saveCsv(rows, 'KC1.csv');
|
||||
this.toast(`KC1 exported (${this.kc1.length} individuals)`);
|
||||
return { headers, rows };
|
||||
}
|
||||
|
||||
/** KC2 — Entity customers */
|
||||
exportKc2(): void {
|
||||
private buildKc2(): FinnetTable {
|
||||
const headers = [
|
||||
'Report Reference Number',
|
||||
'UCIC',
|
||||
@@ -1006,7 +1132,7 @@ export class FiuComponent implements OnInit {
|
||||
'UCIC of Owner of Sole Proprietor Firm',
|
||||
'Name of Owner of Sole Proprietor Firm'
|
||||
];
|
||||
const rows = [headers];
|
||||
const rows: string[][] = [];
|
||||
for (const k of this.kc2) {
|
||||
rows.push([
|
||||
k.reportRefNo.toString(),
|
||||
@@ -1043,12 +1169,11 @@ export class FiuComponent implements OnInit {
|
||||
k.ownerName
|
||||
]);
|
||||
}
|
||||
this.saveCsv(rows, 'KC2.csv');
|
||||
this.toast(`KC2 exported (${this.kc2.length} entities)`);
|
||||
return { headers, rows };
|
||||
}
|
||||
|
||||
/** Account_Detail.csv — per-report account aggregates (12-month) */
|
||||
exportAccountDetail(): void {
|
||||
/** Account_Detail — per-report account aggregates (12-month) */
|
||||
private buildAccountDetail(): FinnetTable {
|
||||
const headers = [
|
||||
'Report Type',
|
||||
'Report Reference Number',
|
||||
@@ -1067,7 +1192,7 @@ export class FiuComponent implements OnInit {
|
||||
'Total Cash Deposit (In last 12 months)',
|
||||
'Total Cash Withdrawal (In last 12 months) Amount'
|
||||
];
|
||||
const rows = [headers];
|
||||
const rows: string[][] = [];
|
||||
// Build one aggregate row per (reportRefNo, accountNumber) pair
|
||||
const seen = new Set<string>();
|
||||
for (const t of this.filteredCtr) {
|
||||
@@ -1105,12 +1230,11 @@ export class FiuComponent implements OnInit {
|
||||
.toString()
|
||||
]);
|
||||
}
|
||||
this.saveCsv(rows, 'Account_Detail.csv');
|
||||
this.toast(`Account_Detail exported (${rows.length - 1} accounts)`);
|
||||
return { headers, rows };
|
||||
}
|
||||
|
||||
/** Account_Person_Relation.csv — account ↔ customer mapping */
|
||||
exportAccountPersonRelation(): void {
|
||||
/** Account_Person_Relation — account ↔ customer mapping */
|
||||
private buildAccountPersonRelation(): FinnetTable {
|
||||
const headers = [
|
||||
'Report Type',
|
||||
'Report Reference Number',
|
||||
@@ -1121,7 +1245,7 @@ export class FiuComponent implements OnInit {
|
||||
'Individual / Non-individual',
|
||||
'Name of Non-customer'
|
||||
];
|
||||
const rows = [headers];
|
||||
const rows: string[][] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const t of this.filteredCtr) {
|
||||
const key = `${t.reportRefNo}|${t.accountNumber}`;
|
||||
@@ -1140,8 +1264,145 @@ export class FiuComponent implements OnInit {
|
||||
t.relationshipFlag === 'Non-Customer' ? t.customerName || '' : ''
|
||||
]);
|
||||
}
|
||||
this.saveCsv(rows, 'Account_Person_Relation.csv');
|
||||
this.toast(`Account_Person_Relation exported (${rows.length - 1} rows)`);
|
||||
return { headers, rows };
|
||||
}
|
||||
|
||||
// -------------------- FINnet file sequence --------------------
|
||||
|
||||
/**
|
||||
* The six CTR files in the order FIU-IND expects them: the account is
|
||||
* described first, then who is behind it, then the transactions, and finally
|
||||
* the customer cards those transactions belong to.
|
||||
*/
|
||||
private get allFinnetFiles(): FinnetFile[] {
|
||||
return [
|
||||
{
|
||||
key: 'account-detail',
|
||||
name: 'Account_Detail',
|
||||
step: 1,
|
||||
title: 'Account Detail',
|
||||
purpose:
|
||||
'One row per reported account — status, opening date and the 12-month debit, credit and cash aggregates.',
|
||||
table: this.buildAccountDetail()
|
||||
},
|
||||
{
|
||||
key: 'account-person-relation',
|
||||
name: 'Account_Person_Relation',
|
||||
step: 2,
|
||||
title: 'Account ↔ Person Relation',
|
||||
purpose:
|
||||
'Links each account to the customers behind it, with the relationship type and whether they are an individual or an entity.',
|
||||
table: this.buildAccountPersonRelation()
|
||||
},
|
||||
{
|
||||
key: 'tc1',
|
||||
name: 'TC1',
|
||||
step: 3,
|
||||
title: 'TC1 — ATM / Card Transactions',
|
||||
purpose:
|
||||
'The card-rail leg of the same CTR: ATM withdrawals, recycler deposits and declined attempts. An ATM cash withdrawal is still cash, so it counts towards the customer’s monthly aggregate alongside TC2.',
|
||||
table: this.buildTc1()
|
||||
},
|
||||
{
|
||||
key: 'tc2',
|
||||
name: 'TC2',
|
||||
step: 4,
|
||||
title: 'TC2 — Branch / Cash Transactions',
|
||||
purpose: 'The transaction detail itself — every cash transaction that put the account over the CTR threshold.',
|
||||
table: this.buildTc2()
|
||||
},
|
||||
{
|
||||
key: 'kc1',
|
||||
name: 'KC1',
|
||||
step: 5,
|
||||
title: 'KC1 — Individual Customer Cards',
|
||||
purpose: 'Full KYC card for every individual named in the report: identifiers, address, income, risk level.',
|
||||
table: this.buildKc1()
|
||||
},
|
||||
{
|
||||
key: 'kc2',
|
||||
name: 'KC2',
|
||||
step: 6,
|
||||
title: 'KC2 — Entity Customer Cards',
|
||||
purpose: 'The same for non-individuals: firms, HUFs and societies, with GSTIN and sole-proprietor ownership.',
|
||||
table: this.buildKc2()
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The files walked through in the CTR tab: account, then who is behind it,
|
||||
* then the two transaction files. The customer cards are excluded here and
|
||||
* live on the Customer Cards tab instead, though they still travel with the
|
||||
* full bundle.
|
||||
*/
|
||||
get finnetSequence(): FinnetFile[] {
|
||||
return this.allFinnetFiles.filter((f) => f.key !== 'kc1' && f.key !== 'kc2');
|
||||
}
|
||||
|
||||
/** Which file in the sequence is open in the CTR tab. */
|
||||
activeFinnetFile = 'account-detail';
|
||||
|
||||
get openFinnetFile(): FinnetFile {
|
||||
return this.finnetSequence.find((f) => f.key === this.activeFinnetFile) ?? this.finnetSequence[0];
|
||||
}
|
||||
|
||||
showFinnetFile(key: string): void {
|
||||
this.activeFinnetFile = key;
|
||||
}
|
||||
|
||||
/** Downloads whichever file is currently previewed, so the button matches the table. */
|
||||
downloadOpenFinnetFile(): void {
|
||||
const f = this.openFinnetFile;
|
||||
this.saveCsv(
|
||||
[
|
||||
f.table.headers,
|
||||
...f.table.rows
|
||||
],
|
||||
`${f.name}.csv`
|
||||
);
|
||||
this.toast(`${f.name} exported (${f.table.rows.length} rows)`);
|
||||
}
|
||||
|
||||
// -------------------- Exports --------------------
|
||||
|
||||
private saveTable(file: FinnetFile): void {
|
||||
this.saveCsv(
|
||||
[
|
||||
file.table.headers,
|
||||
...file.table.rows
|
||||
],
|
||||
`${file.name}.csv`
|
||||
);
|
||||
this.toast(`${file.name} exported (${file.table.rows.length} rows)`);
|
||||
}
|
||||
|
||||
private fileByKey(key: string): FinnetFile {
|
||||
return this.allFinnetFiles.find((f) => f.key === key)!;
|
||||
}
|
||||
|
||||
exportTc1(): void {
|
||||
this.saveTable(this.fileByKey('tc1'));
|
||||
}
|
||||
|
||||
exportTc2(): void {
|
||||
this.saveTable(this.fileByKey('tc2'));
|
||||
}
|
||||
|
||||
exportKc1(): void {
|
||||
this.saveTable(this.fileByKey('kc1'));
|
||||
}
|
||||
|
||||
exportKc2(): void {
|
||||
this.saveTable(this.fileByKey('kc2'));
|
||||
}
|
||||
|
||||
exportAccountDetail(): void {
|
||||
this.saveTable(this.fileByKey('account-detail'));
|
||||
}
|
||||
|
||||
exportAccountPersonRelation(): void {
|
||||
this.saveTable(this.fileByKey('account-person-relation'));
|
||||
}
|
||||
|
||||
/** STR XML stub (FINnet 2.0 STR XML) */
|
||||
@@ -1187,15 +1448,16 @@ export class FiuComponent implements OnInit {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/** Bundle: the 6 CSVs plus the STR XML — emitted as separate downloads sequentially */
|
||||
/**
|
||||
* Bundle: the 6 CSVs plus the STR XML, emitted as separate downloads in the
|
||||
* order FIU-IND expects them — account, then relation, then TC, then KC.
|
||||
*/
|
||||
exportAll(): void {
|
||||
setTimeout(() => this.exportTc1(), 0);
|
||||
setTimeout(() => this.exportTc2(), 200);
|
||||
setTimeout(() => this.exportKc1(), 400);
|
||||
setTimeout(() => this.exportKc2(), 600);
|
||||
setTimeout(() => this.exportAccountDetail(), 800);
|
||||
setTimeout(() => this.exportAccountPersonRelation(), 1000);
|
||||
setTimeout(() => this.exportStrXml(), 1200);
|
||||
// All six travel together — the customer cards are not walked through in the
|
||||
// CTR tab, but a bundle without them would be incomplete.
|
||||
const files = this.allFinnetFiles;
|
||||
files.forEach((f, i) => setTimeout(() => this.saveTable(f), i * 200));
|
||||
setTimeout(() => this.exportStrXml(), files.length * 200);
|
||||
}
|
||||
|
||||
private escapeXml(s: string): string {
|
||||
|
||||
@@ -53,6 +53,12 @@ const routes: Routes = [
|
||||
(m) => m.FormDeclarationReportComponent
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'risk-review',
|
||||
data: { title: 'Risk Review Report', breadcrumb: 'Risk Review Report' },
|
||||
loadComponent: () =>
|
||||
import('../risk-review/risk-review-report.component').then((m) => m.RiskReviewReportComponent)
|
||||
},
|
||||
{
|
||||
path: ':filter',
|
||||
data: { routeParamBreadcrumb: 'filter' },
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,527 @@
|
||||
<!--
|
||||
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/.
|
||||
-->
|
||||
|
||||
<div class="rr-root">
|
||||
<!-- HEADER -->
|
||||
<div class="rr-head">
|
||||
<div class="rr-head-left">
|
||||
<fa-icon icon="user-shield" class="rr-head-ic"></fa-icon>
|
||||
<div>
|
||||
<h2 class="rr-head-t">Risk Review Report</h2>
|
||||
<div class="rr-head-s">
|
||||
Customers whose transactions tripped an AML rule and whose risk profile therefore needs a look. For each case
|
||||
the reviewer decides one of four things — keep the rating, move it, call for a KYC update, or refer it to the
|
||||
Principal Officer for an STR.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rr-head-actions">
|
||||
<button class="rr-btn rr-btn--primary" [disabled]="generating || loading" (click)="exportExcel()">
|
||||
<fa-icon icon="file-excel"></fa-icon> {{ generating ? 'Generating…' : 'Download Excel' }}
|
||||
</button>
|
||||
</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">
|
||||
<div class="rr-modes">
|
||||
@for (m of periodModes; track m.key) {
|
||||
<button class="rr-chip" [class.active]="periodMode === m.key" (click)="periodMode = m.key">
|
||||
{{ m.label }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@switch (periodMode) {
|
||||
@case ('day') {
|
||||
<label class="rr-field">
|
||||
<span>Date</span>
|
||||
<input type="date" [(ngModel)]="filterDay" />
|
||||
</label>
|
||||
}
|
||||
@case ('week') {
|
||||
<label class="rr-field">
|
||||
<span>Any date in week</span>
|
||||
<input type="date" [(ngModel)]="filterWeek" />
|
||||
</label>
|
||||
<span class="rr-resolved">Week: {{ rangeLabel }}</span>
|
||||
}
|
||||
@case ('month') {
|
||||
<label class="rr-field">
|
||||
<span>Month</span>
|
||||
<input type="month" [(ngModel)]="filterMonth" />
|
||||
</label>
|
||||
}
|
||||
@default {
|
||||
<label class="rr-field">
|
||||
<span>Alerts from</span>
|
||||
<input type="date" [(ngModel)]="filterFrom" />
|
||||
</label>
|
||||
<label class="rr-field">
|
||||
<span>To</span>
|
||||
<input type="date" [(ngModel)]="filterTo" />
|
||||
</label>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="rr-filter-row">
|
||||
<label class="rr-field">
|
||||
<span>Branch</span>
|
||||
<select [(ngModel)]="branchCode" [disabled]="!canSelectBranch">
|
||||
@if (canSelectBranch) {
|
||||
<option value="">All Branches</option>
|
||||
}
|
||||
@for (b of branches; track b.code) {
|
||||
<option [value]="b.code">{{ b.code }} — {{ b.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="rr-field">
|
||||
<span>Current risk</span>
|
||||
<select [(ngModel)]="currentRisk">
|
||||
<option value="">Any rating</option>
|
||||
<option value="LOW">Low</option>
|
||||
<option value="MEDIUM">Medium</option>
|
||||
<option value="HIGH">High</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="rr-field">
|
||||
<span>AML rule triggered</span>
|
||||
<select [(ngModel)]="ruleCode">
|
||||
<option value="">All rules</option>
|
||||
@for (r of rules; track r.code) {
|
||||
<option [value]="r.code">{{ r.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="rr-field">
|
||||
<span>Review status</span>
|
||||
<select [(ngModel)]="status">
|
||||
<option value="">Any status</option>
|
||||
@for (s of reviewStatuses; track s) {
|
||||
<option [value]="s">{{ statusMeta[s].label }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="rr-field">
|
||||
<span>Rating outcome</span>
|
||||
<select [(ngModel)]="direction">
|
||||
<option value="">Any outcome</option>
|
||||
<option value="up">Review upward</option>
|
||||
<option value="down">Review downward</option>
|
||||
<option value="same">Within current rating</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="rr-field rr-field--grow">
|
||||
<span>Search</span>
|
||||
<input type="text" [(ngModel)]="search" placeholder="Customer name, customer ID, account number or alert ID…" />
|
||||
</label>
|
||||
|
||||
<label class="rr-check">
|
||||
<input type="checkbox" [(ngModel)]="openOnly" />
|
||||
Only cases awaiting review
|
||||
</label>
|
||||
|
||||
<button class="rr-btn" (click)="resetFilters()"><fa-icon icon="undo"></fa-icon> Reset</button>
|
||||
</div>
|
||||
|
||||
@if (!canSelectBranch) {
|
||||
<div class="rr-scope-note">
|
||||
<fa-icon icon="lock"></fa-icon>
|
||||
Showing {{ branchLabel }} only. Head Office logins can select any branch.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (loading) {
|
||||
<div class="rr-loading"><fa-icon icon="shield-alt"></fa-icon> Loading AML alerts…</div>
|
||||
} @else {
|
||||
<!-- SUMMARY -->
|
||||
<div class="rr-kpis">
|
||||
<div class="rr-kpi">
|
||||
<div class="rr-kpi-n">{{ summary.customers }}</div>
|
||||
<div class="rr-kpi-l">Customers for review</div>
|
||||
<div class="rr-kpi-sub">
|
||||
{{ summary.byRisk.LOW }} Low · {{ summary.byRisk.MEDIUM }} Medium · {{ summary.byRisk.HIGH }} High
|
||||
</div>
|
||||
</div>
|
||||
<div class="rr-kpi rr-kpi--blue">
|
||||
<div class="rr-kpi-n">{{ summary.alerts }}</div>
|
||||
<div class="rr-kpi-l">AML alerts triggered</div>
|
||||
<div class="rr-kpi-sub">on {{ summary.txns }} transactions · {{ summary.openAlerts }} awaiting a decision</div>
|
||||
</div>
|
||||
<div
|
||||
class="rr-kpi rr-kpi--amber"
|
||||
[class.rr-kpi--dim]="summary.upgradeCandidates + summary.downgradeCandidates === 0"
|
||||
>
|
||||
<div class="rr-kpi-n">{{ summary.upgradeCandidates + summary.downgradeCandidates }}</div>
|
||||
<div class="rr-kpi-l">Ratings to be moved</div>
|
||||
<div class="rr-kpi-sub">
|
||||
{{ summary.upgradeCandidates }} upward · {{ summary.downgradeCandidates }} downward
|
||||
</div>
|
||||
</div>
|
||||
<div class="rr-kpi" [class.rr-kpi--red]="summary.escalatedCustomers > 0">
|
||||
<div class="rr-kpi-n">{{ summary.escalatedCustomers }}</div>
|
||||
<div class="rr-kpi-l">Escalated to STR</div>
|
||||
<div class="rr-kpi-sub">₹{{ summary.totalAmount | number: '1.0-0' }} under review</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RULE MIX — doubles as a one-click filter -->
|
||||
@if (summary.byRule.length) {
|
||||
<div class="rr-mix">
|
||||
<span class="rr-mix-t">Rules triggered</span>
|
||||
@for (r of summary.byRule; track r.code) {
|
||||
<button class="rr-mix-item" [class.active]="ruleCode === r.code" (click)="toggleRule(r.code)">
|
||||
{{ r.label }} <strong>{{ r.count }}</strong>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- VIEW SWITCH -->
|
||||
<div class="rr-views">
|
||||
<button class="rr-chip" [class.active]="view === 'customer'" (click)="view = 'customer'">
|
||||
<fa-icon icon="users"></fa-icon> Customer wise
|
||||
</button>
|
||||
<button class="rr-chip" [class.active]="view === 'alert'" (click)="view = 'alert'">
|
||||
<fa-icon icon="list"></fa-icon> Alert wise
|
||||
</button>
|
||||
<button class="rr-chip" [class.active]="view === 'txn'" (click)="view = 'txn'">
|
||||
<fa-icon icon="filter"></fa-icon> Transaction wise
|
||||
</button>
|
||||
<span class="rr-spacer"></span>
|
||||
<span class="rr-count">
|
||||
{{
|
||||
view === 'customer'
|
||||
? customerRows.length + ' customers'
|
||||
: view === 'alert'
|
||||
? alertRows.length + ' alerts'
|
||||
: txnRows.length + ' transactions'
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
@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>
|
||||
}
|
||||
</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>
|
||||
}
|
||||
</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>
|
||||
}
|
||||
|
||||
<!-- ALERT WISE -->
|
||||
@if (view === 'alert') {
|
||||
<div class="rr-table-wrap">
|
||||
<table class="rr-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Alert ID</th>
|
||||
<th>Alert Date</th>
|
||||
<th>Customer ID</th>
|
||||
<th>Customer Name</th>
|
||||
<th>Account Number</th>
|
||||
<th>Branch</th>
|
||||
<th>Current Risk</th>
|
||||
<th>Transaction Details</th>
|
||||
<th>AML Rule Triggered</th>
|
||||
<th class="num">Amount</th>
|
||||
<th>Review Status</th>
|
||||
<th>Reviewer Remarks</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (a of alertRows; track a.alertId) {
|
||||
<tr [class.rr-row--escalated]="a.status === 'ESCALATED_STR' || a.status === 'STR_FILED'">
|
||||
<td class="mono">{{ a.alertId }}</td>
|
||||
<td class="mono">{{ a.alertDate }}</td>
|
||||
<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>
|
||||
<td>
|
||||
<span [class]="riskClass(a.currentRisk)">{{ riskText(a.currentRisk) }}</span>
|
||||
</td>
|
||||
<td>{{ txnDetail(a) }}</td>
|
||||
<td class="rr-rules-cell">
|
||||
<span class="rr-rule">{{ a.rule.label }}</span>
|
||||
<span class="rr-rule-cat">{{ a.rule.category }}</span>
|
||||
<div class="rr-rule-desc">{{ a.rule.description }}</div>
|
||||
</td>
|
||||
<td class="num strong">{{ a.amount | number: '1.0-0' }}</td>
|
||||
<td>
|
||||
<span [class]="statusClass(a.status)">{{ statusMeta[a.status].label }}</span>
|
||||
@if (a.reviewedBy) {
|
||||
<div class="rr-by">by {{ a.reviewedBy }}</div>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
{{ a.remarks || '—' }}
|
||||
<div class="rr-suggest"><fa-icon icon="clipboard-check"></fa-icon> {{ a.rule.suggestedAction }}</div>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="12" class="rr-empty">
|
||||
No AML rule was triggered for {{ rangeLabel }} at {{ branchLabel }}.
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- TRANSACTION WISE — every entry, and the rule it tripped -->
|
||||
@if (view === 'txn') {
|
||||
<div class="rr-table-wrap">
|
||||
<table class="rr-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Txn Date</th>
|
||||
<th>Txn ID</th>
|
||||
<th>Customer ID</th>
|
||||
<th>Customer Name</th>
|
||||
<th>Account Number</th>
|
||||
<th>Branch</th>
|
||||
<th>Current Risk</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>
|
||||
<th>Alert</th>
|
||||
<th>Review Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (t of txnRows; track t.txnId) {
|
||||
<tr [class.rr-row--escalated]="t.status === 'ESCALATED_STR' || t.status === 'STR_FILED'">
|
||||
<td class="mono">{{ t.date }}</td>
|
||||
<td class="mono">{{ t.txnId }}</td>
|
||||
<td class="mono">{{ t.clientId }}</td>
|
||||
<td>
|
||||
<strong>{{ t.customerName }}</strong>
|
||||
</td>
|
||||
<td class="mono">{{ t.accountNo || '—' }}</td>
|
||||
<td>{{ t.branchCode || '—' }}</td>
|
||||
<td>
|
||||
<span [class]="riskClass(t.currentRisk)">{{ riskText(t.currentRisk) }}</span>
|
||||
</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 class="rr-rules-cell">
|
||||
<span class="rr-rule">{{ t.rule.label }}</span>
|
||||
<span class="rr-rule-cat">{{ t.rule.category }}</span>
|
||||
@if (t.legCount > 1) {
|
||||
<span class="rr-leg">leg {{ t.legIndex }} of {{ t.legCount }}</span>
|
||||
}
|
||||
<div class="rr-rule-desc">{{ t.rule.description }}</div>
|
||||
</td>
|
||||
<td class="mono">{{ t.alertId }}</td>
|
||||
<td>
|
||||
<span [class]="statusClass(t.status)">{{ statusMeta[t.status].label }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="16" class="rr-empty">
|
||||
No transaction tripped an AML rule for {{ rangeLabel }} at {{ branchLabel }}.
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
<p class="rr-legend">
|
||||
<span class="rr-status rr-status--open">Pending review</span> awaiting a reviewer ·
|
||||
<span class="rr-status rr-status--ok">Risk retained</span> explained, rating unchanged ·
|
||||
<span class="rr-status rr-status--warn">Risk upgraded / KYC update</span> profile changed ·
|
||||
<span class="rr-status rr-status--danger">Escalated / STR filed</span> with the Principal Officer
|
||||
</p>
|
||||
}
|
||||
|
||||
<div class="rr-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
|
||||
</div>
|
||||
@@ -0,0 +1,820 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
:host {
|
||||
--r-navy: #1a237e;
|
||||
--r-blue: #1d4ed8;
|
||||
--r-blue-l: #eff6ff;
|
||||
--r-green: #15803d;
|
||||
--r-green-l: #dcfce7;
|
||||
--r-amber: #b45309;
|
||||
--r-amber-l: #fef3c7;
|
||||
--r-red: #c0392b;
|
||||
--r-red-l: #fdecea;
|
||||
--r-violet: #6d28d9;
|
||||
--r-violet-l: #f3e8ff;
|
||||
--r-g50: #f8fafc;
|
||||
--r-g100: #f1f5f9;
|
||||
--r-g200: #e2e8f0;
|
||||
--r-g300: #cbd5e1;
|
||||
--r-g500: #64748b;
|
||||
--r-g700: #334155;
|
||||
--r-g900: #0f172a;
|
||||
|
||||
display: block;
|
||||
font-family: 'DM Sans', system-ui, sans-serif;
|
||||
background: var(--r-g50);
|
||||
color: var(--r-g900);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.rr-root {
|
||||
padding: 18px 22px;
|
||||
max-width: 1800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* HEADER */
|
||||
.rr-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.rr-head-left {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.rr-head-ic {
|
||||
font-size: 26px;
|
||||
color: var(--r-navy);
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.rr-head-t {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rr-head-s {
|
||||
font-size: 12px;
|
||||
color: var(--r-g500);
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
/* BUTTONS */
|
||||
.rr-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 15px;
|
||||
border: 1px solid var(--r-g300);
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: var(--r-g700);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--r-g50);
|
||||
border-color: var(--r-g500);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&--primary {
|
||||
background: var(--r-green);
|
||||
border-color: var(--r-green);
|
||||
color: #fff;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #12692f;
|
||||
border-color: #12692f;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
&--slim {
|
||||
padding: 5px 11px;
|
||||
font-size: 11.5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.rr-head-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* BANNER */
|
||||
.rr-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
border-radius: 10px;
|
||||
padding: 10px 14px;
|
||||
font-size: 12.5px;
|
||||
margin-bottom: 14px;
|
||||
|
||||
span {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&--sample {
|
||||
background: var(--r-amber-l);
|
||||
border: 1px solid #fcd34d;
|
||||
color: #78350f;
|
||||
}
|
||||
}
|
||||
|
||||
/* FILTERS */
|
||||
.rr-filters {
|
||||
background: #fff;
|
||||
border: 1px solid var(--r-g200);
|
||||
border-radius: 11px;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.rr-filter-row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.rr-modes {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.rr-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
border: 1px solid var(--r-g300);
|
||||
background: #fff;
|
||||
border-radius: 999px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: var(--r-g700);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--r-navy);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--r-navy);
|
||||
border-color: var(--r-navy);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.rr-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
|
||||
span {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--r-g500);
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--r-g300);
|
||||
border-radius: 7px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
background: #fff;
|
||||
color: var(--r-g900);
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--r-navy);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: var(--r-g100);
|
||||
color: var(--r-g500);
|
||||
}
|
||||
}
|
||||
|
||||
&--grow {
|
||||
flex: 1;
|
||||
min-width: 260px;
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.rr-resolved {
|
||||
font-size: 12px;
|
||||
color: var(--r-g500);
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.rr-check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: var(--r-g700);
|
||||
padding-bottom: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rr-scope-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 12px;
|
||||
color: var(--r-amber);
|
||||
background: var(--r-amber-l);
|
||||
border: 1px solid #fcd34d;
|
||||
border-radius: 7px;
|
||||
padding: 6px 11px;
|
||||
}
|
||||
|
||||
/* LOADING */
|
||||
.rr-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 9px;
|
||||
padding: 60px;
|
||||
color: var(--r-g500);
|
||||
font-size: 13px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--r-g200);
|
||||
border-radius: 11px;
|
||||
}
|
||||
|
||||
/* KPIs */
|
||||
.rr-kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.rr-kpi {
|
||||
background: #fff;
|
||||
border: 1px solid var(--r-g200);
|
||||
border-left: 4px solid var(--r-navy);
|
||||
border-radius: 11px;
|
||||
padding: 12px 16px;
|
||||
|
||||
&--blue {
|
||||
border-left-color: var(--r-blue);
|
||||
}
|
||||
|
||||
&--amber {
|
||||
border-left-color: var(--r-amber);
|
||||
}
|
||||
|
||||
&--red {
|
||||
border-left-color: var(--r-red);
|
||||
}
|
||||
|
||||
&--dim {
|
||||
border-left-color: var(--r-g300);
|
||||
}
|
||||
}
|
||||
|
||||
.rr-kpi-n {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
font-family: 'DM Mono', monospace;
|
||||
}
|
||||
|
||||
.rr-kpi-l {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--r-g700);
|
||||
}
|
||||
|
||||
.rr-kpi-sub {
|
||||
font-size: 11px;
|
||||
color: var(--r-g500);
|
||||
}
|
||||
|
||||
/* RULE MIX */
|
||||
.rr-mix {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.rr-mix-t {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--r-g500);
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.rr-mix-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 11px;
|
||||
border: 1px solid var(--r-g200);
|
||||
background: #fff;
|
||||
border-radius: 7px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
color: var(--r-g700);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
|
||||
strong {
|
||||
font-family: 'DM Mono', monospace;
|
||||
color: var(--r-navy);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--r-navy);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--r-navy);
|
||||
border-color: var(--r-navy);
|
||||
color: #fff;
|
||||
|
||||
strong {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* VIEW SWITCH */
|
||||
.rr-views {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.rr-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.rr-count {
|
||||
font-size: 12px;
|
||||
color: var(--r-g500);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* TABLE */
|
||||
.rr-table-wrap {
|
||||
background: #fff;
|
||||
border: 1px solid var(--r-g200);
|
||||
border-radius: 11px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.rr-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12.5px;
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 9px 10px;
|
||||
background: var(--r-g100);
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--r-g700);
|
||||
white-space: nowrap;
|
||||
border-bottom: 1px solid var(--r-g200);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--r-g100);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.num {
|
||||
text-align: right;
|
||||
font-family: 'DM Mono', monospace;
|
||||
}
|
||||
|
||||
.strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
tbody tr:hover:not(.rr-detail) {
|
||||
background: var(--r-g50);
|
||||
}
|
||||
}
|
||||
|
||||
.rr-cust {
|
||||
cursor: pointer;
|
||||
|
||||
&.open {
|
||||
background: var(--r-blue-l);
|
||||
}
|
||||
}
|
||||
|
||||
.rr-row--escalated {
|
||||
background: var(--r-red-l);
|
||||
}
|
||||
|
||||
.rr-detail {
|
||||
background: #fcfcfe;
|
||||
|
||||
td {
|
||||
padding: 12px 16px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.rr-subtable {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--r-g200);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 7px 10px;
|
||||
background: var(--r-g50);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--r-g500);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 7px 10px;
|
||||
border-top: 1px solid var(--r-g100);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.num {
|
||||
text-align: right;
|
||||
font-family: 'DM Mono', monospace;
|
||||
}
|
||||
|
||||
.strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
/* One alert inside an expanded customer: rule, why, entries, outcome. */
|
||||
.rr-case {
|
||||
background: #fff;
|
||||
border: 1px solid var(--r-g200);
|
||||
border-radius: 9px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 10px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.rr-case-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.rr-case-id {
|
||||
font-size: 11px;
|
||||
color: var(--r-g500);
|
||||
}
|
||||
|
||||
.rr-case-amt {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rr-case-why {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 7px;
|
||||
font-size: 11.5px;
|
||||
color: #78350f;
|
||||
background: var(--r-amber-l);
|
||||
border-radius: 6px;
|
||||
padding: 5px 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.rr-case-foot {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 18px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 7px;
|
||||
padding-top: 7px;
|
||||
border-top: 1px dashed var(--r-g200);
|
||||
}
|
||||
|
||||
.rr-case-remark {
|
||||
font-size: 11.5px;
|
||||
color: var(--r-g700);
|
||||
}
|
||||
|
||||
/* Channel of a single entry */
|
||||
.rr-chan {
|
||||
display: inline-block;
|
||||
padding: 1px 7px;
|
||||
border-radius: 5px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
background: var(--r-g100);
|
||||
color: var(--r-g700);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Position of an entry within a multi-leg pattern alert */
|
||||
.rr-leg {
|
||||
display: inline-block;
|
||||
margin-left: 5px;
|
||||
padding: 1px 7px;
|
||||
border-radius: 5px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
background: var(--r-blue-l);
|
||||
color: var(--r-blue);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rr-leg--ctr {
|
||||
background: var(--r-red-l);
|
||||
}
|
||||
|
||||
.cr {
|
||||
color: var(--r-green);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.dr {
|
||||
color: var(--r-red);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Rule cell */
|
||||
.rr-rules-cell {
|
||||
min-width: 230px;
|
||||
max-width: 340px;
|
||||
}
|
||||
|
||||
.rr-rule {
|
||||
display: inline-block;
|
||||
margin: 0 5px 3px 0;
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
background: var(--r-violet-l);
|
||||
color: var(--r-violet);
|
||||
}
|
||||
|
||||
.rr-rule-cat {
|
||||
display: inline-block;
|
||||
padding: 1px 7px;
|
||||
border-radius: 5px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
background: var(--r-g100);
|
||||
color: var(--r-g700);
|
||||
}
|
||||
|
||||
.rr-rule-desc {
|
||||
font-size: 11px;
|
||||
color: var(--r-g500);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.rr-suggest {
|
||||
font-size: 11px;
|
||||
color: var(--r-blue);
|
||||
margin-top: 3px;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.rr-by {
|
||||
font-size: 10.5px;
|
||||
color: var(--r-g500);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Risk rating badge */
|
||||
.rr-risk {
|
||||
display: inline-block;
|
||||
padding: 1px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
|
||||
&--low {
|
||||
background: var(--r-green-l);
|
||||
color: var(--r-green);
|
||||
}
|
||||
|
||||
&--medium {
|
||||
background: var(--r-amber-l);
|
||||
color: var(--r-amber);
|
||||
}
|
||||
|
||||
&--high {
|
||||
background: var(--r-red-l);
|
||||
color: var(--r-red);
|
||||
}
|
||||
|
||||
&--none {
|
||||
background: var(--r-g100);
|
||||
color: var(--r-g500);
|
||||
}
|
||||
}
|
||||
|
||||
/* Review status badge */
|
||||
.rr-status {
|
||||
display: inline-block;
|
||||
padding: 1px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
|
||||
&--open {
|
||||
background: var(--r-g100);
|
||||
color: var(--r-g700);
|
||||
}
|
||||
|
||||
&--progress {
|
||||
background: var(--r-blue-l);
|
||||
color: var(--r-blue);
|
||||
}
|
||||
|
||||
&--ok {
|
||||
background: var(--r-green-l);
|
||||
color: var(--r-green);
|
||||
}
|
||||
|
||||
&--warn {
|
||||
background: var(--r-amber-l);
|
||||
color: var(--r-amber);
|
||||
}
|
||||
|
||||
&--danger {
|
||||
background: var(--r-red-l);
|
||||
color: var(--r-red);
|
||||
}
|
||||
}
|
||||
|
||||
/* What the report says should happen to the rating */
|
||||
.rr-verdict-cell {
|
||||
min-width: 210px;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.rr-verdict {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--r-g500);
|
||||
white-space: nowrap;
|
||||
|
||||
&--up {
|
||||
color: var(--r-amber);
|
||||
}
|
||||
|
||||
/* A rating coming down is good news, not a warning. */
|
||||
&--down {
|
||||
color: var(--r-green);
|
||||
}
|
||||
|
||||
&--str {
|
||||
color: var(--r-red);
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.rr-verdict-why {
|
||||
font-size: 10.5px;
|
||||
color: var(--r-g500);
|
||||
margin-top: 2px;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.rr-tag {
|
||||
display: inline-block;
|
||||
margin-left: 6px;
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
background: var(--r-g100);
|
||||
color: var(--r-g700);
|
||||
white-space: nowrap;
|
||||
|
||||
&--ctr {
|
||||
background: var(--r-red-l);
|
||||
color: var(--r-red);
|
||||
}
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: 'DM Mono', monospace;
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.rr-empty {
|
||||
padding: 44px;
|
||||
text-align: center;
|
||||
color: var(--r-g500);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.rr-legend {
|
||||
font-size: 11.5px;
|
||||
color: var(--r-g500);
|
||||
margin: 10px 2px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* TOAST */
|
||||
.rr-toast {
|
||||
position: fixed;
|
||||
bottom: 32px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(20px);
|
||||
background: var(--r-navy);
|
||||
color: #fff;
|
||||
padding: 12px 22px;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
box-shadow: 0 6px 20px rgb(0 0 0 / 25%);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: all 0.25s;
|
||||
z-index: 1500;
|
||||
|
||||
&.show {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 1200px) {
|
||||
.rr-kpis {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,726 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { catchError, map } from 'rxjs/operators';
|
||||
|
||||
/** Custom Modules */
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
|
||||
/** Custom Services */
|
||||
import { AmlService } from '../aml/aml.service';
|
||||
import { ClientsService } from '../clients/clients.service';
|
||||
import { BranchScopeService } from '../core/branch-scope.service';
|
||||
|
||||
/** Shared report helpers */
|
||||
import { exportWorkbook } from '../cashier/cashier-export';
|
||||
import { GL_BRANCHES, GL_PERIOD, glBranchName } from '../gl-reports/gl-reports-data';
|
||||
|
||||
/** Report data + computation */
|
||||
import {
|
||||
AML_RULE_CATALOGUE,
|
||||
LiveAmlAlert,
|
||||
REVIEW_STATUSES,
|
||||
REVIEW_STATUS_META,
|
||||
RISK_LABEL,
|
||||
ReviewStatus,
|
||||
RiskAlertRow,
|
||||
RiskLevel,
|
||||
channelLabel,
|
||||
mapLiveAlert,
|
||||
riskFromCategory,
|
||||
sampleRiskAlerts
|
||||
} from './risk-review-data';
|
||||
import {
|
||||
DateRange,
|
||||
RatingDirection,
|
||||
RiskAlertView,
|
||||
RiskCustomerRow,
|
||||
RiskReviewFilter,
|
||||
RiskReviewModel,
|
||||
RiskReviewSummary,
|
||||
RiskTxnView,
|
||||
buildRiskReview,
|
||||
monthRange,
|
||||
weekRange
|
||||
} from './risk-review.compute';
|
||||
|
||||
type PeriodMode = 'day' | 'week' | 'month' | 'range';
|
||||
type ViewMode = 'customer' | 'alert' | 'txn';
|
||||
|
||||
/** Starting value for the memo, used before the first filter pass runs. */
|
||||
const EMPTY_SUMMARY: RiskReviewSummary = {
|
||||
customers: 0,
|
||||
alerts: 0,
|
||||
txns: 0,
|
||||
ctrReportable: 0,
|
||||
openAlerts: 0,
|
||||
escalatedCustomers: 0,
|
||||
upgradeCandidates: 0,
|
||||
downgradeCandidates: 0,
|
||||
totalAmount: 0,
|
||||
byRisk: { LOW: 0, MEDIUM: 0, HIGH: 0, UNRATED: 0 },
|
||||
byRule: [],
|
||||
byStatus: []
|
||||
};
|
||||
|
||||
/**
|
||||
* Risk Review Report — the AML/compliance worklist.
|
||||
*
|
||||
* Lists the customers who tripped an AML rule in the period, the transaction
|
||||
* that tripped it, the rating currently on their KYC record and where the
|
||||
* review has got to. The reviewer's decision on each case is one of: keep the
|
||||
* rating, move it, refresh KYC, or escalate to the Principal Officer for an
|
||||
* STR — which is the vocabulary in `REVIEW_STATUS_META`.
|
||||
*
|
||||
* Rows come from the live rule engine. If the engine has raised nothing (or is
|
||||
* unreachable) the report falls back to a deterministic sample set and says so
|
||||
* on screen and in the workbook, so sample rows are never taken for live data.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-risk-review-report',
|
||||
templateUrl: './risk-review-report.component.html',
|
||||
styleUrls: ['./risk-review-report.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
FaIconComponent,
|
||||
FormsModule
|
||||
]
|
||||
})
|
||||
export class RiskReviewReportComponent implements OnInit {
|
||||
private aml = inject(AmlService);
|
||||
private clients = inject(ClientsService);
|
||||
private scope = inject(BranchScopeService);
|
||||
|
||||
readonly period = GL_PERIOD;
|
||||
readonly branches = GL_BRANCHES;
|
||||
readonly rules = AML_RULE_CATALOGUE;
|
||||
readonly reviewStatuses = REVIEW_STATUSES;
|
||||
readonly statusMeta = REVIEW_STATUS_META;
|
||||
readonly riskLabel = RISK_LABEL;
|
||||
readonly channelLabel = channelLabel;
|
||||
|
||||
readonly periodModes: { key: PeriodMode; label: string }[] = [
|
||||
{ key: 'day', label: 'Day wise' },
|
||||
{ key: 'week', label: 'Week wise' },
|
||||
{ key: 'month', label: 'Month wise' },
|
||||
{ key: 'range', label: 'Between dates' }
|
||||
];
|
||||
|
||||
// ── Source state ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Rows returned by the monitoring engine, mapped onto the report shape. */
|
||||
private liveAlerts: RiskAlertRow[] = [];
|
||||
loading = true;
|
||||
|
||||
/**
|
||||
* Live alerts and the demonstration set, in one worklist.
|
||||
*
|
||||
* Every row keeps its `live` flag and sample rows are tagged in the table, so
|
||||
* a reviewer can still tell them apart at a glance.
|
||||
*/
|
||||
private alerts: RiskAlertRow[] = [];
|
||||
|
||||
get hasLive(): boolean {
|
||||
return this.liveAlerts.length > 0;
|
||||
}
|
||||
|
||||
get sampleCount(): number {
|
||||
return this.alerts.filter((a) => !a.live).length;
|
||||
}
|
||||
|
||||
// ── Filter state ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Alerts raised today must be in view, so the default window runs to whichever
|
||||
* is later — the seeded period end or the real current date.
|
||||
*/
|
||||
private static readonly TODAY = new Date().toISOString().slice(0, 10);
|
||||
private static readonly WINDOW_END =
|
||||
RiskReviewReportComponent.TODAY > GL_PERIOD.fyEnd ? RiskReviewReportComponent.TODAY : GL_PERIOD.fyEnd;
|
||||
|
||||
periodMode: PeriodMode = 'range';
|
||||
filterDay = RiskReviewReportComponent.WINDOW_END;
|
||||
/** Any date inside the wanted week; resolved to Mon–Sat. */
|
||||
filterWeek = '2026-07-27';
|
||||
filterMonth = '2026-07';
|
||||
filterFrom = GL_PERIOD.fyStart;
|
||||
filterTo = RiskReviewReportComponent.WINDOW_END;
|
||||
branchCode = '';
|
||||
currentRisk: RiskLevel | '' = '';
|
||||
ruleCode = '';
|
||||
status: ReviewStatus | '' = '';
|
||||
/** Which way the review says the rating should move. '' shows every customer. */
|
||||
direction: RatingDirection | '' = '';
|
||||
search = '';
|
||||
openOnly = false;
|
||||
|
||||
view: ViewMode = 'customer';
|
||||
/** Client ID of the customer row currently expanded. */
|
||||
expanded: number | null = null;
|
||||
|
||||
generating = false;
|
||||
toastMsg = '';
|
||||
toastVisible = false;
|
||||
private toastTimer: any;
|
||||
|
||||
ngOnInit(): void {
|
||||
// A branch login opens on its own book and cannot move off it.
|
||||
this.branchCode = this.scope.defaultBranchCode;
|
||||
this.load();
|
||||
}
|
||||
|
||||
// ── Loading ────────────────────────────────────────────────────────────────
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.aml
|
||||
.getAlerts()
|
||||
.pipe(catchError(() => of([] as LiveAmlAlert[])))
|
||||
.subscribe((live) => {
|
||||
const rows = (live || []).map((a) => mapLiveAlert(a as unknown as LiveAmlAlert));
|
||||
this.liveAlerts = rows;
|
||||
this.compose();
|
||||
if (!rows.length) {
|
||||
this.loading = false;
|
||||
return;
|
||||
}
|
||||
this.enrichCurrentRisk(rows);
|
||||
});
|
||||
}
|
||||
|
||||
/** Live alerts first, then the demonstration set behind them. */
|
||||
private compose(): void {
|
||||
this.alerts = [
|
||||
...this.liveAlerts,
|
||||
...sampleRiskAlerts()
|
||||
];
|
||||
this.memoKey = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* The alert payload carries no rating, so the customer's current risk is read
|
||||
* off their KYC record — one call per distinct customer, not per alert. A
|
||||
* client whose KYC cannot be read stays unrated rather than blocking the
|
||||
* report, so the table renders either way.
|
||||
*/
|
||||
private enrichCurrentRisk(rows: RiskAlertRow[]): void {
|
||||
const clientIds = [...new Set(rows.map((r) => r.clientId))].filter((id) => id != null);
|
||||
if (!clientIds.length) {
|
||||
this.loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
forkJoin(
|
||||
clientIds.map((id) =>
|
||||
this.clients.getClientKyc(String(id), true).pipe(
|
||||
map((kyc: any) => ({ id, risk: riskFromCategory(kyc?.riskCategory) })),
|
||||
catchError(() => of({ id, risk: null as RiskLevel | null }))
|
||||
)
|
||||
)
|
||||
).subscribe((results) => {
|
||||
const byClient = new Map(
|
||||
results.map((r) => [
|
||||
r.id,
|
||||
r.risk
|
||||
])
|
||||
);
|
||||
this.liveAlerts = rows.map((r) => ({ ...r, currentRisk: byClient.get(r.clientId) ?? null }));
|
||||
this.compose();
|
||||
this.loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Filter resolution ──────────────────────────────────────────────────────
|
||||
|
||||
get range(): DateRange {
|
||||
if (this.periodMode === 'day') {
|
||||
return { from: this.filterDay, to: this.filterDay };
|
||||
}
|
||||
if (this.periodMode === 'week') {
|
||||
return weekRange(this.filterWeek);
|
||||
}
|
||||
if (this.periodMode === 'month') {
|
||||
return monthRange(this.filterMonth);
|
||||
}
|
||||
return { from: this.filterFrom, to: this.filterTo };
|
||||
}
|
||||
|
||||
get rangeLabel(): string {
|
||||
const r = this.range;
|
||||
return r.from === r.to ? r.from : `${r.from} to ${r.to}`;
|
||||
}
|
||||
|
||||
get canSelectBranch(): boolean {
|
||||
return this.scope.canSelectBranch;
|
||||
}
|
||||
|
||||
get branchLabel(): string {
|
||||
return this.scope.scopeLabel(this.branchCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applied at the data boundary rather than only in the UI, so a branch login
|
||||
* cannot widen its own scope by tampering with component state.
|
||||
*/
|
||||
private get effectiveBranch(): string {
|
||||
return this.scope.enforce(this.branchCode);
|
||||
}
|
||||
|
||||
private get filter(): RiskReviewFilter {
|
||||
return {
|
||||
range: this.range,
|
||||
branchCode: this.effectiveBranch,
|
||||
currentRisk: this.currentRisk,
|
||||
ruleCode: this.ruleCode,
|
||||
status: this.status,
|
||||
direction: this.direction,
|
||||
search: this.search,
|
||||
openOnly: this.openOnly
|
||||
};
|
||||
}
|
||||
|
||||
resetFilters(): void {
|
||||
this.periodMode = 'range';
|
||||
this.filterDay = RiskReviewReportComponent.WINDOW_END;
|
||||
this.filterWeek = '2026-07-27';
|
||||
this.filterMonth = '2026-07';
|
||||
this.filterFrom = GL_PERIOD.fyStart;
|
||||
this.filterTo = RiskReviewReportComponent.WINDOW_END;
|
||||
this.currentRisk = '';
|
||||
this.ruleCode = '';
|
||||
this.status = '';
|
||||
this.direction = '';
|
||||
this.search = '';
|
||||
this.openOnly = false;
|
||||
this.expanded = null;
|
||||
// Reset returns a branch login to its own branch, never to "all".
|
||||
this.branchCode = this.scope.defaultBranchCode;
|
||||
}
|
||||
|
||||
// ── Data ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The whole screen reads from one computed model. Change detection hits these
|
||||
* getters many times per cycle, so the result is memoised against the filter
|
||||
* rather than re-filtering thousands of rows on every binding.
|
||||
*/
|
||||
private memoKey = '';
|
||||
private memo: RiskReviewModel = { alerts: [], customers: [], txns: [], summary: EMPTY_SUMMARY };
|
||||
|
||||
private get model(): RiskReviewModel {
|
||||
const key = JSON.stringify(this.filter) + `|${this.alerts.length}`;
|
||||
if (key !== this.memoKey) {
|
||||
this.memo = buildRiskReview(this.alerts, this.filter);
|
||||
this.memoKey = key;
|
||||
}
|
||||
return this.memo;
|
||||
}
|
||||
|
||||
get alertRows(): RiskAlertView[] {
|
||||
return this.model.alerts;
|
||||
}
|
||||
|
||||
get customerRows(): RiskCustomerRow[] {
|
||||
return this.model.customers;
|
||||
}
|
||||
|
||||
get txnRows(): RiskTxnView[] {
|
||||
return this.model.txns;
|
||||
}
|
||||
|
||||
get summary(): RiskReviewSummary {
|
||||
return this.model.summary;
|
||||
}
|
||||
|
||||
toggle(clientId: number): void {
|
||||
this.expanded = this.expanded === clientId ? null : clientId;
|
||||
}
|
||||
|
||||
/** One-click drill from the rule mix strip into that rule. */
|
||||
toggleRule(code: string): void {
|
||||
this.ruleCode = this.ruleCode === code ? '' : code;
|
||||
this.expanded = null;
|
||||
}
|
||||
|
||||
// ── Presentation helpers ───────────────────────────────────────────────────
|
||||
|
||||
riskClass(level: RiskLevel | null): string {
|
||||
return level ? `rr-risk rr-risk--${level.toLowerCase()}` : 'rr-risk rr-risk--none';
|
||||
}
|
||||
|
||||
riskText(level: RiskLevel | null): string {
|
||||
return level ? RISK_LABEL[level] : 'Unrated';
|
||||
}
|
||||
|
||||
statusClass(status: ReviewStatus): string {
|
||||
return `rr-status rr-status--${REVIEW_STATUS_META[status].tone}`;
|
||||
}
|
||||
|
||||
/** What the review concludes should happen to the rating, in one phrase. */
|
||||
verdictText(c: RiskCustomerRow): string {
|
||||
if (c.escalated) {
|
||||
return 'Escalated to STR';
|
||||
}
|
||||
if (c.direction === 'up') {
|
||||
return `Review upward → ${RISK_LABEL[c.recommendedRisk]}`;
|
||||
}
|
||||
if (c.direction === 'down') {
|
||||
return `Review downward → ${RISK_LABEL[c.recommendedRisk]}`;
|
||||
}
|
||||
return 'Within current rating';
|
||||
}
|
||||
|
||||
verdictClass(c: RiskCustomerRow): string {
|
||||
if (c.escalated) {
|
||||
return 'rr-verdict rr-verdict--str';
|
||||
}
|
||||
if (c.direction === 'up') {
|
||||
return 'rr-verdict rr-verdict--up';
|
||||
}
|
||||
if (c.direction === 'down') {
|
||||
return 'rr-verdict rr-verdict--down';
|
||||
}
|
||||
return 'rr-verdict';
|
||||
}
|
||||
|
||||
/** Transaction detail as the reviewer would read it in one line. */
|
||||
txnDetail(a: RiskAlertView): string {
|
||||
const amount = `₹${a.amount.toLocaleString('en-IN')}`;
|
||||
if (a.txnCount > 1) {
|
||||
return `${a.txnCount} × ${channelLabel(a.channel)} aggregating ${amount}`;
|
||||
}
|
||||
return `${channelLabel(a.channel)} of ${amount}`;
|
||||
}
|
||||
|
||||
// ── Excel ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async exportExcel(): Promise<void> {
|
||||
this.generating = true;
|
||||
try {
|
||||
const s = this.summary;
|
||||
const subtitle =
|
||||
`Period: ${this.rangeLabel} · Branch: ${this.branchLabel} · ` +
|
||||
`Source: live AML monitoring engine` +
|
||||
(this.sampleCount ? ` + ${this.sampleCount} DEMONSTRATION rows (not compliance data)` : '');
|
||||
|
||||
await exportWorkbook(
|
||||
[
|
||||
{
|
||||
sheetName: 'Summary',
|
||||
title: 'Risk Review Report — Summary',
|
||||
subtitle,
|
||||
headers: [
|
||||
'Particulars',
|
||||
'Value'
|
||||
],
|
||||
widths: [
|
||||
56,
|
||||
22
|
||||
],
|
||||
rows: [
|
||||
[
|
||||
'Customers flagged for risk review',
|
||||
String(s.customers)
|
||||
],
|
||||
[
|
||||
'AML alerts triggered',
|
||||
String(s.alerts)
|
||||
],
|
||||
[
|
||||
'Transactions behind those alerts',
|
||||
String(s.txns)
|
||||
],
|
||||
[
|
||||
' of which cash entries at or above the ₹10,00,000 CTR limit',
|
||||
String(s.ctrReportable)
|
||||
],
|
||||
[
|
||||
'Alerts still awaiting a reviewer decision',
|
||||
String(s.openAlerts)
|
||||
],
|
||||
[
|
||||
'Customers rated below what the triggered rules point at (review upward)',
|
||||
String(s.upgradeCandidates)
|
||||
],
|
||||
[
|
||||
'Customers rated above what the period’s activity warrants (review downward)',
|
||||
String(s.downgradeCandidates)
|
||||
],
|
||||
[
|
||||
'Customers escalated to the Principal Officer (STR)',
|
||||
String(s.escalatedCustomers)
|
||||
],
|
||||
[
|
||||
'Total transaction value under review',
|
||||
s.totalAmount
|
||||
],
|
||||
[
|
||||
'',
|
||||
''
|
||||
],
|
||||
[
|
||||
'Customers currently rated Low',
|
||||
String(s.byRisk.LOW)
|
||||
],
|
||||
[
|
||||
'Customers currently rated Medium',
|
||||
String(s.byRisk.MEDIUM)
|
||||
],
|
||||
[
|
||||
'Customers currently rated High',
|
||||
String(s.byRisk.HIGH)
|
||||
],
|
||||
[
|
||||
'Customers with no rating on record',
|
||||
String(s.byRisk.UNRATED)
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
sheetName: 'Rules triggered',
|
||||
title: 'Risk Review Report — AML Rules Triggered',
|
||||
subtitle,
|
||||
headers: [
|
||||
'AML Rule Triggered',
|
||||
'Category',
|
||||
'Alerts',
|
||||
'What the rule looks for',
|
||||
'Action expected of the reviewer'
|
||||
],
|
||||
widths: [
|
||||
34,
|
||||
14,
|
||||
10,
|
||||
62,
|
||||
62
|
||||
],
|
||||
rows: s.byRule.map((r) => {
|
||||
const meta = AML_RULE_CATALOGUE.find((m) => m.code === r.code);
|
||||
return [
|
||||
r.label,
|
||||
meta?.category ?? '—',
|
||||
r.count,
|
||||
meta?.description ?? '—',
|
||||
meta?.suggestedAction ?? '—'
|
||||
];
|
||||
})
|
||||
},
|
||||
{
|
||||
sheetName: 'Customer wise',
|
||||
title: 'Risk Review Report — Customer wise',
|
||||
subtitle,
|
||||
headers: [
|
||||
'Customer ID',
|
||||
'Customer Name',
|
||||
'Account Number',
|
||||
'Branch',
|
||||
'Current Risk',
|
||||
'Alerts',
|
||||
'AML Rules Triggered',
|
||||
'Total Amount',
|
||||
'Largest Alert',
|
||||
'First Alert',
|
||||
'Last Alert',
|
||||
'Rules Point At',
|
||||
'Recommended Risk',
|
||||
'Open Cases',
|
||||
'Position',
|
||||
'Basis'
|
||||
],
|
||||
widths: [
|
||||
13,
|
||||
28,
|
||||
20,
|
||||
16,
|
||||
13,
|
||||
8,
|
||||
46,
|
||||
16,
|
||||
16,
|
||||
12,
|
||||
12,
|
||||
14,
|
||||
16,
|
||||
11,
|
||||
24,
|
||||
70
|
||||
],
|
||||
rows: this.customerRows.map((c) => [
|
||||
String(c.clientId),
|
||||
c.customerName,
|
||||
c.accountNo || '—',
|
||||
c.branchCode ? glBranchName(c.branchCode) : '—',
|
||||
this.riskText(c.currentRisk),
|
||||
c.alertCount,
|
||||
c.ruleLabels.join('; '),
|
||||
c.totalAmount,
|
||||
c.largestAmount,
|
||||
c.firstAlertDate,
|
||||
c.lastAlertDate,
|
||||
RISK_LABEL[c.proposedRisk],
|
||||
RISK_LABEL[c.recommendedRisk],
|
||||
c.openCount,
|
||||
this.verdictText(c),
|
||||
c.directionReason || '—'
|
||||
])
|
||||
},
|
||||
{
|
||||
sheetName: 'Alert wise',
|
||||
title: 'Risk Review Report — Alert wise',
|
||||
subtitle,
|
||||
headers: [
|
||||
'Alert ID',
|
||||
'Alert Date',
|
||||
'Customer ID',
|
||||
'Customer Name',
|
||||
'Account Number',
|
||||
'Branch',
|
||||
'Current Risk',
|
||||
'Transaction Details',
|
||||
'AML Rule Triggered',
|
||||
'Rule Category',
|
||||
'Amount',
|
||||
'Review Status',
|
||||
'Reviewed By',
|
||||
'Reviewer Remarks',
|
||||
'Action Expected'
|
||||
],
|
||||
widths: [
|
||||
11,
|
||||
12,
|
||||
13,
|
||||
28,
|
||||
20,
|
||||
16,
|
||||
13,
|
||||
38,
|
||||
32,
|
||||
14,
|
||||
16,
|
||||
22,
|
||||
16,
|
||||
60,
|
||||
60
|
||||
],
|
||||
rows: this.alertRows.map((a) => [
|
||||
a.alertId,
|
||||
a.alertDate,
|
||||
String(a.clientId),
|
||||
a.customerName,
|
||||
a.accountNo || '—',
|
||||
a.branchCode ? glBranchName(a.branchCode) : '—',
|
||||
this.riskText(a.currentRisk),
|
||||
this.txnDetail(a),
|
||||
a.rule.label,
|
||||
a.rule.category,
|
||||
a.amount,
|
||||
REVIEW_STATUS_META[a.status].label,
|
||||
a.reviewedBy || '—',
|
||||
a.remarks || '—',
|
||||
a.rule.suggestedAction
|
||||
])
|
||||
},
|
||||
{
|
||||
sheetName: 'Transaction wise',
|
||||
title: 'Risk Review Report — Transaction wise (rule triggered by each entry)',
|
||||
subtitle,
|
||||
headers: [
|
||||
'Txn Date',
|
||||
'Txn ID',
|
||||
'Customer ID',
|
||||
'Customer Name',
|
||||
'Account Number',
|
||||
'Branch',
|
||||
'Current Risk',
|
||||
'Channel',
|
||||
'Dr/Cr',
|
||||
'Amount',
|
||||
'Balance After',
|
||||
'Narration',
|
||||
'Counterparty',
|
||||
'AML Rule Triggered',
|
||||
'Rule Category',
|
||||
'Leg',
|
||||
'Alert ID',
|
||||
'Alert Date',
|
||||
'Review Status',
|
||||
'CTR'
|
||||
],
|
||||
widths: [
|
||||
12,
|
||||
12,
|
||||
13,
|
||||
28,
|
||||
20,
|
||||
16,
|
||||
13,
|
||||
18,
|
||||
7,
|
||||
16,
|
||||
16,
|
||||
32,
|
||||
38,
|
||||
32,
|
||||
14,
|
||||
9,
|
||||
11,
|
||||
12,
|
||||
22,
|
||||
7
|
||||
],
|
||||
rows: this.txnRows.map((t) => [
|
||||
t.date,
|
||||
t.txnId,
|
||||
String(t.clientId),
|
||||
t.customerName,
|
||||
t.accountNo || '—',
|
||||
t.branchCode ? glBranchName(t.branchCode) : '—',
|
||||
this.riskText(t.currentRisk),
|
||||
channelLabel(t.channel),
|
||||
t.drCr,
|
||||
t.amount,
|
||||
t.balanceAfter || null,
|
||||
t.narration || '—',
|
||||
t.counterparty || '—',
|
||||
t.rule.label,
|
||||
t.rule.category,
|
||||
t.legCount > 1 ? `${t.legIndex} of ${t.legCount}` : '—',
|
||||
t.alertId,
|
||||
t.alertDate,
|
||||
REVIEW_STATUS_META[t.status].label,
|
||||
t.ctrReportable ? 'Yes' : ''
|
||||
])
|
||||
}
|
||||
],
|
||||
`Risk-Review-Report-${this.range.from}-to-${this.range.to}`
|
||||
);
|
||||
|
||||
this.toast(
|
||||
`Exported ${s.txns} transactions across ${s.alerts} alerts and ${s.customers} customers for ${this.rangeLabel}`
|
||||
);
|
||||
} catch {
|
||||
this.toast('Could not generate the workbook — please retry');
|
||||
} finally {
|
||||
this.generating = false;
|
||||
}
|
||||
}
|
||||
|
||||
private toast(msg: string): void {
|
||||
this.toastMsg = msg;
|
||||
this.toastVisible = true;
|
||||
if (this.toastTimer) {
|
||||
clearTimeout(this.toastTimer);
|
||||
}
|
||||
this.toastTimer = setTimeout(() => (this.toastVisible = false), 3200);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
import {
|
||||
AmlRuleMeta,
|
||||
CTR_THRESHOLD,
|
||||
MATERIALITY,
|
||||
NON_MITIGABLE_RULES,
|
||||
REVIEW_STATUS_META,
|
||||
RISK_LABEL,
|
||||
RiskAlertRow,
|
||||
RiskLevel,
|
||||
RiskTxn,
|
||||
ReviewStatus,
|
||||
amlRuleMeta,
|
||||
riskRank
|
||||
} from './risk-review-data';
|
||||
|
||||
/** Which way the review says the rating should move. */
|
||||
export type RatingDirection = 'up' | 'down' | 'same';
|
||||
|
||||
const RANK_TO_LEVEL: Record<number, RiskLevel> = { 1: 'LOW', 2: 'MEDIUM', 3: 'HIGH' };
|
||||
|
||||
export interface DateRange {
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
export interface RiskReviewFilter {
|
||||
range: DateRange;
|
||||
/** '' means every branch. */
|
||||
branchCode: string;
|
||||
/** '' means every rating. */
|
||||
currentRisk: RiskLevel | '';
|
||||
/** '' means every rule. */
|
||||
ruleCode: string;
|
||||
/** '' means every review status. */
|
||||
status: ReviewStatus | '';
|
||||
/** '' means every customer, whichever way their rating should move. */
|
||||
direction: RatingDirection | '';
|
||||
/** Free text over customer name, customer ID, account number and alert ID. */
|
||||
search: string;
|
||||
/** Only cases a reviewer still has to act on. */
|
||||
openOnly: boolean;
|
||||
}
|
||||
|
||||
/** An alert joined to its rule catalogue entry, ready to render. */
|
||||
export interface RiskAlertView extends RiskAlertRow {
|
||||
rule: AmlRuleMeta;
|
||||
}
|
||||
|
||||
/**
|
||||
* One transaction carrying the alert and rule it belongs to.
|
||||
*
|
||||
* This is the row that answers "which AML rule did this transaction trip" —
|
||||
* the alert is flattened onto each of its entries so the same transaction can
|
||||
* be read on its own, without expanding a customer first.
|
||||
*/
|
||||
export interface RiskTxnView extends RiskTxn {
|
||||
alertId: string;
|
||||
alertDate: string;
|
||||
clientId: number;
|
||||
customerName: string;
|
||||
accountNo: string;
|
||||
branchCode: string;
|
||||
currentRisk: RiskLevel | null;
|
||||
status: ReviewStatus;
|
||||
remarks: string;
|
||||
rule: AmlRuleMeta;
|
||||
/** Position of this entry within its alert, e.g. "2 of 7" for a series. */
|
||||
legIndex: number;
|
||||
legCount: number;
|
||||
}
|
||||
|
||||
/** All of one customer's alerts in the period, rolled up. */
|
||||
export interface RiskCustomerRow {
|
||||
clientId: number;
|
||||
customerName: string;
|
||||
accountNo: string;
|
||||
branchCode: string;
|
||||
currentRisk: RiskLevel | null;
|
||||
alerts: RiskAlertView[];
|
||||
alertCount: number;
|
||||
/** Sum of the amounts behind the alerts — the exposure under review. */
|
||||
totalAmount: number;
|
||||
largestAmount: number;
|
||||
/** Highest risk any of the triggered rules points at, before materiality. */
|
||||
proposedRisk: RiskLevel;
|
||||
/** What the review concludes the rating should be, after materiality. */
|
||||
recommendedRisk: RiskLevel;
|
||||
/** Whether that is above, below or the same as the rating on record. */
|
||||
direction: RatingDirection;
|
||||
/** One line explaining the recommendation, shown under the verdict. */
|
||||
directionReason: string;
|
||||
/** Distinct rule labels, for the "AML Rule Triggered" cell. */
|
||||
ruleLabels: string[];
|
||||
/** Earliest and latest alert in the period. */
|
||||
firstAlertDate: string;
|
||||
lastAlertDate: string;
|
||||
openCount: number;
|
||||
/** True when any alert on the customer has gone to the Principal Officer. */
|
||||
escalated: boolean;
|
||||
/** True when the rules point above the rating currently on the KYC record. */
|
||||
needsUpgrade: boolean;
|
||||
/** True when the rating on record is higher than the activity warrants. */
|
||||
needsDowngrade: boolean;
|
||||
}
|
||||
|
||||
export interface RiskReviewSummary {
|
||||
customers: number;
|
||||
alerts: number;
|
||||
/** Individual entries behind those alerts. */
|
||||
txns: number;
|
||||
/** Cash entries at or above the CTR limit on their own. */
|
||||
ctrReportable: number;
|
||||
openAlerts: number;
|
||||
escalatedCustomers: number;
|
||||
upgradeCandidates: number;
|
||||
downgradeCandidates: number;
|
||||
totalAmount: number;
|
||||
byRisk: Record<'LOW' | 'MEDIUM' | 'HIGH' | 'UNRATED', number>;
|
||||
/** Rule mix across the filtered set, most-triggered first. */
|
||||
byRule: { code: string; label: string; count: number }[];
|
||||
byStatus: { status: ReviewStatus; label: string; count: number }[];
|
||||
}
|
||||
|
||||
function inRange(date: string, range: DateRange): boolean {
|
||||
return date >= range.from && date <= range.to;
|
||||
}
|
||||
|
||||
function inr(n: number): string {
|
||||
return `₹${Math.round(n).toLocaleString('en-IN')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Works out what the rating ought to be once the size of the activity is taken
|
||||
* into account, and why.
|
||||
*
|
||||
* The rules give an upper bound — what the behaviour could mean at worst. The
|
||||
* amounts then pull that bound down: a customer sitting at High who has only put
|
||||
* a handful of small transfers through has not behaved like a High-risk customer
|
||||
* this period, and the review should say so. Two things stop the pull-down: a
|
||||
* case already referred for an STR, and rules whose seriousness has nothing to
|
||||
* do with value — a sanctions match or a structuring pattern stays grave at any
|
||||
* amount.
|
||||
*/
|
||||
function recommendRating(
|
||||
impliedRank: number,
|
||||
currentRisk: RiskLevel | null,
|
||||
largestAmount: number,
|
||||
totalAmount: number,
|
||||
escalated: boolean,
|
||||
ruleCodes: string[]
|
||||
): { risk: RiskLevel; reason: string } {
|
||||
const implied = RANK_TO_LEVEL[impliedRank] ?? 'LOW';
|
||||
const grave = ruleCodes.find((c) => NON_MITIGABLE_RULES.has(c));
|
||||
const material = largestAmount >= CTR_THRESHOLD || totalAmount >= MATERIALITY.highTotal;
|
||||
const small = largestAmount < MATERIALITY.smallTxn && totalAmount < MATERIALITY.smallTotal;
|
||||
|
||||
// What the rating should be. Amounts only ever pull the rules' bound down, and
|
||||
// only where nothing weightier holds it up.
|
||||
let risk: RiskLevel;
|
||||
let basis: 'escalated' | 'grave' | 'material' | 'small' | 'moderate';
|
||||
if (escalated) {
|
||||
risk = 'HIGH';
|
||||
basis = 'escalated';
|
||||
} else if (grave) {
|
||||
risk = implied;
|
||||
basis = 'grave';
|
||||
} else if (material) {
|
||||
risk = implied;
|
||||
basis = 'material';
|
||||
} else {
|
||||
risk = RANK_TO_LEVEL[Math.min(impliedRank, small ? 1 : 2)] ?? 'LOW';
|
||||
basis = small ? 'small' : 'moderate';
|
||||
}
|
||||
|
||||
// The wording is worked out from the conclusion, not the branch, so a customer
|
||||
// being brought down is never told the activity supports where they are.
|
||||
const sizes = `largest entry ${inr(largestAmount)}, ${inr(totalAmount)} in total`;
|
||||
const rated = currentRisk ? `Rated ${RISK_LABEL[currentRisk]} on record` : 'No rating on record';
|
||||
const delta = currentRisk ? riskRank(risk) - riskRank(currentRisk) : 0;
|
||||
|
||||
if (basis === 'escalated') {
|
||||
return { risk, reason: 'Case referred to the Principal Officer — rating held at High pending the outcome.' };
|
||||
}
|
||||
|
||||
if (delta < 0) {
|
||||
if (basis === 'small') {
|
||||
return { risk, reason: `Only small-value activity — ${sizes}. ${rated}; not supported by this period.` };
|
||||
}
|
||||
if (basis === 'moderate') {
|
||||
return { risk, reason: `Moderate activity — ${sizes}. ${rated}; one step down is proportionate.` };
|
||||
}
|
||||
// Material or grave, yet still below where the customer sits: the size of
|
||||
// the activity is not the reason, the rules that fired are.
|
||||
return {
|
||||
risk,
|
||||
reason:
|
||||
`Activity of ${inr(totalAmount)} is material, but the rules triggered point only at ${RISK_LABEL[risk]}. ` +
|
||||
`${rated}.`
|
||||
};
|
||||
}
|
||||
|
||||
if (delta > 0) {
|
||||
return { risk, reason: `Triggered rules point at ${RISK_LABEL[risk]}, above the rating on record.` };
|
||||
}
|
||||
|
||||
if (basis === 'grave') {
|
||||
return {
|
||||
risk,
|
||||
reason: `${amlRuleMeta(grave as string).label} carries its own weight — the rating does not come down on amount alone.`
|
||||
};
|
||||
}
|
||||
|
||||
return { risk, reason: '' };
|
||||
}
|
||||
|
||||
/** Applies every filter and joins each alert to its rule. */
|
||||
export function riskAlertViews(alerts: RiskAlertRow[], filter: RiskReviewFilter): RiskAlertView[] {
|
||||
const needle = filter.search.trim().toLowerCase();
|
||||
|
||||
return alerts
|
||||
.filter((a) => {
|
||||
if (!inRange(a.alertDate, filter.range)) {
|
||||
return false;
|
||||
}
|
||||
// A live alert carries no branch; hiding it under a branch filter would
|
||||
// silently drop real compliance data, so it stays visible.
|
||||
if (filter.branchCode && a.branchCode && a.branchCode !== filter.branchCode) {
|
||||
return false;
|
||||
}
|
||||
if (filter.currentRisk && a.currentRisk !== filter.currentRisk) {
|
||||
return false;
|
||||
}
|
||||
if (filter.ruleCode && a.ruleCode !== filter.ruleCode) {
|
||||
return false;
|
||||
}
|
||||
if (filter.status && a.status !== filter.status) {
|
||||
return false;
|
||||
}
|
||||
if (filter.openOnly && !REVIEW_STATUS_META[a.status].open) {
|
||||
return false;
|
||||
}
|
||||
if (needle) {
|
||||
const hay = `${a.customerName} ${a.clientId} ${a.accountNo} ${a.alertId} ${a.ruleCode}`.toLowerCase();
|
||||
if (!hay.includes(needle)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map((a) => ({ ...a, rule: amlRuleMeta(a.ruleCode, a.ruleDescription) }))
|
||||
.sort((a, b) => b.alertDate.localeCompare(a.alertDate) || a.customerName.localeCompare(b.customerName));
|
||||
}
|
||||
|
||||
/**
|
||||
* Rolls the alerts up per customer. Sorted so the cases needing attention —
|
||||
* escalated, then rated below what the rules point at, then most alerts —
|
||||
* come to the top of the worklist.
|
||||
*/
|
||||
export function riskCustomerRows(views: RiskAlertView[]): RiskCustomerRow[] {
|
||||
const byClient = new Map<number, RiskAlertView[]>();
|
||||
for (const v of views) {
|
||||
const list = byClient.get(v.clientId);
|
||||
if (list) {
|
||||
list.push(v);
|
||||
} else {
|
||||
byClient.set(v.clientId, [v]);
|
||||
}
|
||||
}
|
||||
|
||||
const rows: RiskCustomerRow[] = [];
|
||||
for (const [
|
||||
clientId,
|
||||
alerts
|
||||
] of byClient) {
|
||||
const dates = alerts.map((a) => a.alertDate).sort();
|
||||
const proposedRank = Math.max(...alerts.map((a) => riskRank(a.rule.impliedRisk)));
|
||||
const proposedRisk: RiskLevel = RANK_TO_LEVEL[proposedRank] ?? 'LOW';
|
||||
const head = alerts[0];
|
||||
|
||||
const currentRisk = alerts.find((a) => a.currentRisk)?.currentRisk ?? null;
|
||||
const totalAmount = alerts.reduce((s, a) => s + a.amount, 0);
|
||||
const largestAmount = Math.max(...alerts.map((a) => a.amount));
|
||||
const escalated = alerts.some((a) => a.status === 'ESCALATED_STR' || a.status === 'STR_FILED');
|
||||
|
||||
const recommendation = recommendRating(
|
||||
proposedRank,
|
||||
currentRisk,
|
||||
largestAmount,
|
||||
totalAmount,
|
||||
escalated,
|
||||
alerts.map((a) => a.ruleCode)
|
||||
);
|
||||
|
||||
// Where the customer carries no rating there is nothing to move, so an
|
||||
// unrated customer is never counted as an upgrade or a downgrade.
|
||||
const delta = currentRisk ? riskRank(recommendation.risk) - riskRank(currentRisk) : 0;
|
||||
const direction: RatingDirection = delta > 0 ? 'up' : delta < 0 ? 'down' : 'same';
|
||||
|
||||
rows.push({
|
||||
clientId,
|
||||
customerName: head.customerName,
|
||||
// Take the first non-blank — live alerts leave these empty.
|
||||
accountNo: alerts.find((a) => a.accountNo)?.accountNo || '',
|
||||
branchCode: alerts.find((a) => a.branchCode)?.branchCode || '',
|
||||
currentRisk,
|
||||
alerts,
|
||||
alertCount: alerts.length,
|
||||
totalAmount,
|
||||
largestAmount,
|
||||
proposedRisk,
|
||||
recommendedRisk: recommendation.risk,
|
||||
direction,
|
||||
directionReason: recommendation.reason,
|
||||
ruleLabels: [...new Set(alerts.map((a) => a.rule.label))],
|
||||
firstAlertDate: dates[0],
|
||||
lastAlertDate: dates[dates.length - 1],
|
||||
openCount: alerts.filter((a) => REVIEW_STATUS_META[a.status].open).length,
|
||||
escalated,
|
||||
needsUpgrade: direction === 'up',
|
||||
needsDowngrade: direction === 'down'
|
||||
});
|
||||
}
|
||||
|
||||
// Escalations first, then ratings that need moving up, then down, then volume.
|
||||
return rows.sort(
|
||||
(a, b) =>
|
||||
Number(b.escalated) - Number(a.escalated) ||
|
||||
Number(b.needsUpgrade) - Number(a.needsUpgrade) ||
|
||||
Number(b.needsDowngrade) - Number(a.needsDowngrade) ||
|
||||
b.alertCount - a.alertCount ||
|
||||
b.totalAmount - a.totalAmount
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restricts the alerts to customers whose rating should move a given way.
|
||||
*
|
||||
* Direction is a property of the customer, not of a single alert, so it can only
|
||||
* be applied once the rollup has run — the alert and transaction views are then
|
||||
* narrowed to the same customers so all three views agree.
|
||||
*/
|
||||
export function applyDirectionFilter(
|
||||
views: RiskAlertView[],
|
||||
customers: RiskCustomerRow[],
|
||||
direction: RatingDirection | ''
|
||||
): { views: RiskAlertView[]; customers: RiskCustomerRow[] } {
|
||||
if (!direction) {
|
||||
return { views, customers };
|
||||
}
|
||||
const keep = customers.filter((c) => c.direction === direction);
|
||||
const ids = new Set(keep.map((c) => c.clientId));
|
||||
return { views: views.filter((v) => ids.has(v.clientId)), customers: keep };
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattens every alert onto its transactions, newest entry first, so each row
|
||||
* names the rule that transaction tripped.
|
||||
*/
|
||||
export function riskTxnRows(views: RiskAlertView[]): RiskTxnView[] {
|
||||
const rows: RiskTxnView[] = [];
|
||||
|
||||
for (const v of views) {
|
||||
v.txns.forEach((t, i) => {
|
||||
rows.push({
|
||||
...t,
|
||||
alertId: v.alertId,
|
||||
alertDate: v.alertDate,
|
||||
clientId: v.clientId,
|
||||
customerName: v.customerName,
|
||||
accountNo: v.accountNo,
|
||||
branchCode: v.branchCode,
|
||||
currentRisk: v.currentRisk,
|
||||
status: v.status,
|
||||
remarks: v.remarks,
|
||||
rule: v.rule,
|
||||
legIndex: i + 1,
|
||||
legCount: v.txns.length
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return rows.sort((a, b) => b.date.localeCompare(a.date) || a.txnId.localeCompare(b.txnId));
|
||||
}
|
||||
|
||||
export function riskReviewSummary(views: RiskAlertView[], customers: RiskCustomerRow[]): RiskReviewSummary {
|
||||
const byRisk: RiskReviewSummary['byRisk'] = { LOW: 0, MEDIUM: 0, HIGH: 0, UNRATED: 0 };
|
||||
for (const c of customers) {
|
||||
byRisk[c.currentRisk ?? 'UNRATED']++;
|
||||
}
|
||||
|
||||
const ruleCounts = new Map<string, { label: string; count: number }>();
|
||||
for (const v of views) {
|
||||
const entry = ruleCounts.get(v.ruleCode);
|
||||
if (entry) {
|
||||
entry.count++;
|
||||
} else {
|
||||
ruleCounts.set(v.ruleCode, { label: v.rule.label, count: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
const statusCounts = new Map<ReviewStatus, number>();
|
||||
for (const v of views) {
|
||||
statusCounts.set(v.status, (statusCounts.get(v.status) || 0) + 1);
|
||||
}
|
||||
|
||||
return {
|
||||
customers: customers.length,
|
||||
alerts: views.length,
|
||||
txns: views.reduce((s, v) => s + v.txns.length, 0),
|
||||
ctrReportable: views.reduce((s, v) => s + v.txns.filter((t) => t.ctrReportable).length, 0),
|
||||
openAlerts: views.filter((v) => REVIEW_STATUS_META[v.status].open).length,
|
||||
escalatedCustomers: customers.filter((c) => c.escalated).length,
|
||||
upgradeCandidates: customers.filter((c) => c.needsUpgrade).length,
|
||||
downgradeCandidates: customers.filter((c) => c.needsDowngrade).length,
|
||||
totalAmount: views.reduce((s, v) => s + v.amount, 0),
|
||||
byRisk,
|
||||
byRule: [...ruleCounts.entries()]
|
||||
.map(
|
||||
([
|
||||
code,
|
||||
v
|
||||
]) => ({ code, label: v.label, count: v.count })
|
||||
)
|
||||
.sort((a, b) => b.count - a.count),
|
||||
byStatus: [...statusCounts.entries()]
|
||||
.map(
|
||||
([
|
||||
status,
|
||||
count
|
||||
]) => ({ status, label: REVIEW_STATUS_META[status].label, count })
|
||||
)
|
||||
.sort((a, b) => b.count - a.count)
|
||||
};
|
||||
}
|
||||
|
||||
/** Everything the screen renders, computed in one pass. */
|
||||
export interface RiskReviewModel {
|
||||
alerts: RiskAlertView[];
|
||||
customers: RiskCustomerRow[];
|
||||
txns: RiskTxnView[];
|
||||
summary: RiskReviewSummary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single entry point for the screen. Filtering, rollup, direction narrowing and
|
||||
* the summary all run once per filter change rather than once per template
|
||||
* binding, which matters because every view reads from the same result.
|
||||
*/
|
||||
export function buildRiskReview(alerts: RiskAlertRow[], filter: RiskReviewFilter): RiskReviewModel {
|
||||
const base = riskAlertViews(alerts, filter);
|
||||
const narrowed = applyDirectionFilter(base, riskCustomerRows(base), filter.direction);
|
||||
|
||||
return {
|
||||
alerts: narrowed.views,
|
||||
customers: narrowed.customers,
|
||||
txns: riskTxnRows(narrowed.views),
|
||||
summary: riskReviewSummary(narrowed.views, narrowed.customers)
|
||||
};
|
||||
}
|
||||
|
||||
// ── Period helpers (shared with the other period-filtered reports) ───────────
|
||||
|
||||
/** Monday to Saturday containing the given date. */
|
||||
export function weekRange(anyDateInWeek: string): DateRange {
|
||||
const d = new Date(`${anyDateInWeek}T00:00:00Z`);
|
||||
const dow = d.getUTCDay();
|
||||
const backToMonday = dow === 0 ? 6 : dow - 1;
|
||||
const monday = new Date(d.getTime() - backToMonday * 86400000);
|
||||
const saturday = new Date(monday.getTime() + 5 * 86400000);
|
||||
return { from: monday.toISOString().slice(0, 10), to: saturday.toISOString().slice(0, 10) };
|
||||
}
|
||||
|
||||
/** First to last day of the given yyyy-MM. */
|
||||
export function monthRange(month: string): DateRange {
|
||||
const [
|
||||
y,
|
||||
m
|
||||
] = month.split('-').map(Number);
|
||||
const last = new Date(Date.UTC(y, m, 0)).getUTCDate();
|
||||
return { from: `${month}-01`, to: `${month}-${String(last).padStart(2, '0')}` };
|
||||
}
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
faFileUpload,
|
||||
faFileWord,
|
||||
faFillDrip,
|
||||
faGavel,
|
||||
faGlobe,
|
||||
faHandHoldingUsd,
|
||||
faHome,
|
||||
@@ -297,6 +298,7 @@ export class IconsModule {
|
||||
faFileExcel,
|
||||
faFileExport,
|
||||
faFillDrip,
|
||||
faGavel,
|
||||
faGlobe,
|
||||
faHandHoldingHand,
|
||||
faHandHoldingHeart,
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
/* tslint:disable */
|
||||
export default {
|
||||
'mifos_x': {
|
||||
'version': '260801',
|
||||
'hash': '2a9df828'
|
||||
'version': '260802',
|
||||
'hash': '0ca6842b'
|
||||
},
|
||||
'allow_switching_backend_instance': true
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user