diff --git a/src/app/aml/aml.service.ts b/src/app/aml/aml.service.ts index 76bbc1905..047fed18c 100644 --- a/src/app/aml/aml.service.ts +++ b/src/app/aml/aml.service.ts @@ -34,6 +34,17 @@ export class AmlService { return this.http.post(`/aml/alerts/${alertId}/review`, { disposition, notes }); } + /** + * Decides named rules within one alert, leaving its other rules open. + * + *

+ * The alert's own status follows from what is left undecided, so it is not sent: whitelisting five of six rules + * leaves the alert open on the sixth. + */ + reviewAlertRules(alertId: number, ruleCodes: string[], disposition: string, notes: string): Observable { + return this.http.post(`/aml/alerts/${alertId}/review`, { disposition, notes, ruleCodes }); + } + // ═══ Recurring-alert review queue ═══ /** @@ -141,4 +152,38 @@ export class AmlService { getRiskCategoryHistory(clientId: number): Observable { return this.http.get(`/clients/${clientId}/risk-category-history`); } + + // ═══ Risk-category changes (maker-checker) ═══ + // + // The rating decides how often a customer is re-KYC'd and what AML thresholds apply to them, so + // it moves only once a checker agrees rather than on one officer's say-so. + + /** @param requestedCategory 1 Low, 2 Medium, 3 High */ + requestRiskChange(clientId: number, requestedCategory: number, reason: string): Observable { + return this.http.post('/aml/risk-change-requests', { clientId, requestedCategory, reason }); + } + + getRiskChangeRequests(status?: string): Observable { + const params = status ? new HttpParams().set('status', status) : undefined; + return this.http.get('/aml/risk-change-requests', { params }); + } + + approveRiskChange(requestId: number): Observable { + return this.http.post(`/aml/risk-change-requests/${requestId}/approve`, {}); + } + + rejectRiskChange(requestId: number, rejectionReason: string): Observable { + return this.http.post(`/aml/risk-change-requests/${requestId}/reject`, { rejectionReason }); + } + + /** + * The latest behaviour window for each customer, as the nightly job computed it. + * + * One row per customer carrying the review period's figures and the rating they suggest. The Risk Review report + * prefers these over its own per-alert reckoning, because a rating describes a period rather than the handful of + * transactions that happened to trip a rule. + */ + getBehaviourWindows(): Observable { + return this.http.get('/risk-review/behaviour-windows'); + } } diff --git a/src/app/board-resolution/bor-report.component.html b/src/app/board-resolution/bor-report.component.html new file mode 100644 index 000000000..ec7b8bf32 --- /dev/null +++ b/src/app/board-resolution/bor-report.component.html @@ -0,0 +1,147 @@ + +

+
+
+ +
+

Board of Resolution Register — Share Capital

+

+ Every share account with the board resolution that authorised the allotment. Accounts with no resolution on + record sort to the top. +

+
+
+ +
+ + @if (loading) { +
Loading the register…
+ } @else if (loadError) { +
{{ loadError }}
+ } + + @if (!loading && !loadError) { + + @if (missingResolutionCount > 0) { +
+ + {{ missingResolutionCount }} share account{{ missingResolutionCount === 1 ? '' : 's' }} carr{{ + missingResolutionCount === 1 ? 'ies' : 'y' + }} + no resolution number. Share capital allotted without a board resolution cannot be evidenced to the Registrar. +
+ } + +
+
+
{{ filtered.length | number }}
+
Share Accounts
+
+
+
{{ distinctResolutions | number }}
+
Resolutions
+
+
+
{{ totalShares | number }}
+
Shares Allotted
+
+
+
₹{{ totalValue | number: '1.0-0' }}
+
Share Value
+
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ +
+ + + + + + + + + + + + + + + + + + + @for (r of filtered; track r.shareAccountId) { + + + + + + + + + + + + + + + } @empty { + + + + } + +
Resolution No.Resolution DateShare A/c No.CIF No.Member NameBranchProductSharesShare ValueTranchesStatusActivated On
+ @if (r.resolutionNumber) { + {{ r.resolutionNumber }} + } @else { + Not recorded + } + {{ r.resolutionDate || '—' }}{{ r.accountNo }}{{ r.cifNumber || '—' }}{{ r.clientName }}{{ r.officeName || '—' }}{{ r.productName || '—' }}{{ r.approvedShares | number }}{{ r.shareValue | number: '1.2-2' }}{{ r.trancheCount }}{{ statusText(r.statusEnum) }}{{ r.activatedDate || '—' }}
No share account matches the filters.
+
+ } +
diff --git a/src/app/board-resolution/bor-report.component.scss b/src/app/board-resolution/bor-report.component.scss new file mode 100644 index 000000000..a21ca4608 --- /dev/null +++ b/src/app/board-resolution/bor-report.component.scss @@ -0,0 +1,313 @@ +/** + * 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/. + */ + +.bor-root { + padding: 20px 24px 40px; + background: #f8fafc; + min-height: 100%; +} + +.bor-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 18px; +} + +.bor-head-left { + display: flex; + align-items: flex-start; + gap: 12px; +} + +.bor-head-ic { + font-size: 26px; + color: #0f4f3c; + margin-top: 2px; +} + +.bor-head-t { + margin: 0 0 3px; + font-size: 20px; + font-weight: 700; + color: #1e293b; +} + +.bor-head-s { + margin: 0; + max-width: 780px; + font-size: 13px; + line-height: 1.5; + color: #64748b; +} + +.bor-note { + display: flex; + align-items: flex-start; + gap: 9px; + padding: 11px 14px; + margin-bottom: 14px; + border: 1px solid #bfdbfe; + border-radius: 9px; + background: #eff6ff; + color: #1e3a8a; + font-size: 12.5px; + line-height: 1.55; + + fa-icon { + flex: 0 0 auto; + margin-top: 1px; + } + + /* An allotment with no resolution behind it is the finding, not a detail. */ + &--warn { + border-color: #fde68a; + background: #fffbeb; + color: #92400e; + + fa-icon { + color: #d97706; + } + } +} + +.bor-kpis { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; + margin-bottom: 16px; +} + +.bor-kpi { + background: #fff; + border: 1px solid #e2e8f0; + border-left: 4px solid #0f4f3c; + border-radius: 11px; + padding: 12px 16px; + + &--blue { + border-left-color: #2563eb; + } + + &--green { + border-left-color: #15803d; + } + + &--amber { + border-left-color: #d97706; + } +} + +.bor-kpi-n { + font-size: 22px; + font-weight: 700; + color: #1e293b; + line-height: 1.2; +} + +.bor-kpi-l { + font-size: 12px; + color: #64748b; + margin-top: 2px; +} + +.bor-filters { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 12px; + padding: 14px 16px; + margin-bottom: 14px; + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 11px; +} + +.bor-filter { + display: flex; + flex-direction: column; + gap: 4px; + + label { + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #64748b; + } + + &--grow { + flex: 1 1 240px; + } +} + +.bor-inp { + height: 34px; + min-width: 170px; + padding: 0 10px; + border: 1px solid #cbd5e1; + border-radius: 7px; + font: inherit; + font-size: 13px; + color: #1e293b; + background: #fff; + + &:focus { + outline: 2px solid #2563eb; + outline-offset: -1px; + } +} + +.bor-check { + display: flex; + align-items: center; + gap: 6px; + height: 34px; + font-size: 12.5px; + color: #334155; + cursor: pointer; +} + +.bor-btn { + display: inline-flex; + align-items: center; + gap: 7px; + height: 34px; + padding: 0 14px; + border: 1px solid #cbd5e1; + border-radius: 7px; + background: #fff; + font: inherit; + font-size: 12.5px; + font-weight: 600; + color: #334155; + cursor: pointer; + + &:hover:not(:disabled) { + background: #f1f5f9; + } + + &:disabled { + opacity: 0.55; + cursor: default; + } + + &--xls { + border-color: #15803d; + background: #16a34a; + color: #fff; + + &:hover:not(:disabled) { + background: #15803d; + } + } +} + +.bor-table-wrap { + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 11px; + overflow-x: auto; +} + +.bor-table { + width: 100%; + border-collapse: collapse; + font-size: 12.5px; + + th { + text-align: left; + padding: 10px; + background: #f1f5f9; + font-size: 10.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #475569; + white-space: nowrap; + border-bottom: 1px solid #e2e8f0; + } + + td { + padding: 11px 10px; + border-bottom: 1px solid #f1f5f9; + vertical-align: middle; + color: #334155; + } + + .num { + text-align: right; + font-family: 'DM Mono', monospace; + } + + .mono { + font-family: 'DM Mono', monospace; + } + + .strong { + font-weight: 700; + } + + tbody tr:hover { + background: #f8fafc; + } +} + +/* The row an auditor is looking for, marked in the table as well as counted above it. */ +.bor-row--missing { + background: #fffbeb; +} + +.bor-res { + color: #0f4f3c; + font-family: 'DM Mono', monospace; +} + +.bor-missing { + display: inline-block; + padding: 2px 8px; + border-radius: 999px; + background: #fef3c7; + color: #92400e; + font-size: 11px; + font-weight: 700; +} + +.bor-status { + display: inline-block; + padding: 2px 9px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + background: #f1f5f9; + color: #475569; + + &--active { + background: #dcfce7; + color: #15803d; + } + + &--submitted, + &--approved { + background: #fef3c7; + color: #b45309; + } + + &--rejected, + &--closed { + background: #fee2e2; + color: #b91c1c; + } +} + +.bor-empty { + padding: 30px; + text-align: center; + color: #94a3b8; +} diff --git a/src/app/board-resolution/bor-report.component.ts b/src/app/board-resolution/bor-report.component.ts new file mode 100644 index 000000000..776a4ad40 --- /dev/null +++ b/src/app/board-resolution/bor-report.component.ts @@ -0,0 +1,221 @@ +/** + * 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 { Component, OnInit, inject } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +import { exportWorkbook } from '../cashier/cashier-export'; +import { BorRegisterRow, BorReportService } from './bor-report.service'; + +/** m_share_account.status_enum, as the register labels it. */ +const STATUS_LABEL: Record = { + 100: 'Submitted', + 200: 'Approved', + 300: 'Active', + 400: 'Rejected', + 500: 'Closed', + 600: 'Inactive' +}; + +/** + * The Board of Resolution register for share capital. + * + * A co-operative allots shares only under a board resolution, and this is the list the Registrar or + * an auditor asks for: every member's holding against the resolution that authorised it. The + * account with no resolution recorded is the point of the register, so it sorts to the top and is + * counted on its own rather than being left to be noticed. + */ +@Component({ + selector: 'mifosx-bor-report', + templateUrl: './bor-report.component.html', + styleUrls: ['./bor-report.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FaIconComponent, + FormsModule + ] +}) +export class BorReportComponent implements OnInit { + private borService = inject(BorReportService); + + readonly statusLabel = STATUS_LABEL; + + rows: BorRegisterRow[] = []; + loading = false; + loadError = ''; + + /** '' = every status. */ + filterStatus: number | '' = ''; + filterBranch = ''; + filterResolution = ''; + search = ''; + /** Only the accounts with no resolution on file — the ones that need chasing. */ + missingOnly = false; + + exporting = false; + + ngOnInit(): void { + this.load(); + } + + load(): void { + this.loading = true; + this.loadError = ''; + this.borService.getRegister().subscribe({ + next: (rows) => { + this.loading = false; + this.rows = rows; + }, + error: (err: any) => { + this.loading = false; + this.rows = []; + this.loadError = err?.error?.defaultUserMessage || 'Could not load the BOR register.'; + } + }); + } + + /** Branches present in the data, so the filter offers only what can actually be selected. */ + get branches(): string[] { + return [...new Set(this.rows.map((r) => r.officeName).filter(Boolean))].sort(); + } + + get statuses(): number[] { + return [...new Set(this.rows.map((r) => r.statusEnum))].sort((a, b) => a - b); + } + + get filtered(): BorRegisterRow[] { + const needle = this.search.trim().toLowerCase(); + const resolutionNeedle = this.filterResolution.trim().toLowerCase(); + return this.rows.filter((r) => { + if (this.filterStatus !== '' && r.statusEnum !== this.filterStatus) { + return false; + } + if (this.filterBranch && r.officeName !== this.filterBranch) { + return false; + } + if (this.missingOnly && r.resolutionNumber) { + return false; + } + if (resolutionNeedle && !r.resolutionNumber.toLowerCase().includes(resolutionNeedle)) { + return false; + } + if ( + needle && + !( + r.clientName.toLowerCase().includes(needle) || + r.accountNo.toLowerCase().includes(needle) || + r.cifNumber.toLowerCase().includes(needle) + ) + ) { + return false; + } + return true; + }); + } + + get totalShares(): number { + return this.filtered.reduce((s, r) => s + r.approvedShares, 0); + } + + get totalValue(): number { + return this.filtered.reduce((s, r) => s + r.shareValue, 0); + } + + /** How many resolutions the filtered holdings were authorised under. */ + get distinctResolutions(): number { + return new Set(this.filtered.map((r) => r.resolutionNumber).filter(Boolean)).size; + } + + get missingResolutionCount(): number { + return this.rows.filter((r) => !r.resolutionNumber).length; + } + + statusText(statusEnum: number): string { + return STATUS_LABEL[statusEnum] || '—'; + } + + statusClass(statusEnum: number): string { + return 'bor-status bor-status--' + (STATUS_LABEL[statusEnum] || 'unknown').toLowerCase(); + } + + resetFilters(): void { + this.filterStatus = ''; + this.filterBranch = ''; + this.filterResolution = ''; + this.search = ''; + this.missingOnly = false; + } + + async exportExcel(): Promise { + this.exporting = true; + try { + const rows = this.filtered; + await exportWorkbook( + [ + { + sheetName: 'BOR Register', + title: 'Board of Resolution Register — Share Capital', + subtitle: `${rows.length} share account(s) · ${this.distinctResolutions} resolution(s) · ${this.totalShares} shares`, + headers: [ + 'Resolution No.', + 'Resolution Date', + 'Share A/c No.', + 'CIF No.', + 'Member Name', + 'Branch', + 'Product', + 'Shares', + 'Unit Price', + 'Share Value', + 'Tranches', + 'Status', + 'Activated On' + ], + widths: [ + 20, + 16, + 18, + 16, + 30, + 18, + 18, + 10, + 12, + 14, + 10, + 12, + 14 + ], + rows: rows.map((r) => [ + // Named rather than left blank, because an empty cell in a filing reads as an + // oversight in the export rather than a gap in the register. + r.resolutionNumber || 'NOT RECORDED', + r.resolutionDate || '—', + r.accountNo, + r.cifNumber, + r.clientName, + r.officeName, + r.productName, + r.approvedShares, + r.unitPrice, + r.shareValue, + r.trancheCount, + this.statusText(r.statusEnum), + r.activatedDate || '—' + ]) + } + ], + 'BOR-Register' + ); + } finally { + this.exporting = false; + } + } +} diff --git a/src/app/board-resolution/bor-report.service.ts b/src/app/board-resolution/bor-report.service.ts new file mode 100644 index 000000000..b75370e8c --- /dev/null +++ b/src/app/board-resolution/bor-report.service.ts @@ -0,0 +1,94 @@ +/** + * 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 { Injectable, inject } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable, map } from 'rxjs'; + +/** Custom Helpers */ +import { toIsoDate } from '../core/utils/api-date'; + +/** One share account and the board resolution that authorised it. */ +export interface BorRegisterRow { + shareAccountId: number; + accountNo: string; + clientId: number; + cifNumber: string; + clientName: string; + officeId: number | null; + officeName: string; + productName: string; + currencyCode: string; + statusEnum: number; + approvedShares: number; + pendingShares: number; + unitPrice: number; + shareValue: number; + resolutionNumber: string; + /** ISO date, or '' where no meeting date was ever recorded against the allotment. */ + resolutionDate: string; + /** How many purchase tranches make up the holding. */ + trancheCount: number; + submittedDate: string; + approvedDate: string; + activatedDate: string; + closedDate: string; +} + +/** + * The BOR register. + * + * One call returns every account with its resolution. The share allotment report next door fetches + * each account separately and stops at two hundred, which is a limit on a register rather than on a + * page of one. + */ +@Injectable({ providedIn: 'root' }) +export class BorReportService { + private http = inject(HttpClient); + + getRegister(status?: number, officeId?: number): Observable { + let params = new HttpParams(); + if (status != null) { + params = params.set('status', String(status)); + } + if (officeId != null) { + params = params.set('officeId', String(officeId)); + } + return this.http + .get('/shareaccounts/bor-register', { params }) + .pipe(map((rows) => (rows || []).map((r) => this.toRow(r)))); + } + + /** Dates arrive as [y, M, d] arrays; flattened to ISO so they sort and filter as strings. */ + private toRow(r: any): BorRegisterRow { + return { + shareAccountId: r.shareAccountId, + accountNo: r.accountNo || '', + clientId: r.clientId, + cifNumber: r.cifNumber || '', + clientName: r.clientName || '', + officeId: r.officeId ?? null, + officeName: r.officeName || '', + productName: r.productName || '', + currencyCode: r.currencyCode || 'INR', + statusEnum: Number(r.statusEnum) || 0, + approvedShares: Number(r.approvedShares) || 0, + pendingShares: Number(r.pendingShares) || 0, + unitPrice: Number(r.unitPrice) || 0, + shareValue: Number(r.shareValue) || 0, + resolutionNumber: (r.resolutionNumber || '').trim(), + resolutionDate: toIsoDate(r.resolutionDate), + trancheCount: Number(r.trancheCount) || 0, + submittedDate: toIsoDate(r.submittedDate), + approvedDate: toIsoDate(r.approvedDate), + activatedDate: toIsoDate(r.activatedDate), + closedDate: toIsoDate(r.closedDate) + }; + } +} diff --git a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.html b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.html index dd1e065c3..44b97a309 100644 --- a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.html +++ b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.html @@ -1142,6 +1142,46 @@ + +
+
+

Risk Profile

+ Used by the periodic risk review +
+ +
+ + Politically Exposed Person + Holds the rating at High; never lowered on ordinary activity + + + Sanctions / watch-list match + UNSC, MHA or internal watch list + + + Adverse media / regulatory action + FIR, court proceedings or published action + +
+ +
+ + Declared annual income (₹) + + As stated by the customer + + + + Declared annual turnover (₹) + + For a business customer + +
+
+ diff --git a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.scss b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.scss index 0f4520920..032060e25 100644 --- a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.scss +++ b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.scss @@ -1807,3 +1807,28 @@ color: #15803d; } } + +/* ── Risk profile ──────────────────────────────────────────────────────────── + Determinations about the customer, which the periodic risk review reads directly. Grouped with + the document block so the form reads as "what we know about them" rather than scattering a PEP + flag among the identifier fields. */ + +.kyc-doc-fields__hint { + font-size: 12px; + color: #64748b; +} + +.kyc-risk-flags { + display: flex; + flex-direction: column; + gap: 10px; + padding: 4px 0 14px; +} + +/* Sits under its checkbox label: the consequence of ticking the box matters more than the box. */ +.kyc-risk-hint { + display: block; + font-size: 12px; + color: #64748b; + line-height: 1.4; +} diff --git a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts index ecfdd0210..84743bb5b 100644 --- a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts +++ b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts @@ -140,6 +140,13 @@ export class KycTabComponent implements OnInit, OnDestroy { 1, [Validators.required] ], + // Risk profile. Determinations about the customer, which the periodic risk review reads directly - a PEP or a + // sanctions match holds the rating at High whatever the period's activity looks like. + isPep: [false], + sanctionsMatch: [false], + adverseMediaMatch: [false], + declaredAnnualIncome: [null], + declaredAnnualTurnover: [null], // The officially valid document, so a lapsed one can be re-collected on this form. The // backend has always accepted these four; only the fields were missing. kycDocumentName: [''], @@ -221,6 +228,11 @@ export class KycTabComponent implements OnInit, OnDestroy { this.kycForm.patchValue({ kycType: data.kycType || 1, riskCategory: data.riskCategory || 1, + isPep: !!data.isPep, + sanctionsMatch: !!data.sanctionsMatch, + adverseMediaMatch: !!data.adverseMediaMatch, + declaredAnnualIncome: data.declaredAnnualIncome ?? null, + declaredAnnualTurnover: data.declaredAnnualTurnover ?? null, panNumber: data.panNumber || '', // Aadhaar is stored masked on the server; user must re-enter // the full 12-digit number to re-verify, so don't pre-fill. @@ -446,6 +458,28 @@ export class KycTabComponent implements OnInit, OnDestroy { return new Date(); } + /** + * The customer's risk profile, as every save has to carry it. + * + *

+ * Sent from every path that writes the KYC record rather than only the main Save, because the form holds one set of + * values: a save triggered from the PAN declaration or the Aadhaar receipt would otherwise write the record without + * these and silently clear a PEP flag the officer had just ticked. + */ + private buildRiskProfileFields() { + const v = this.kycForm.value; + return { + isPep: !!v.isPep, + sanctionsMatch: !!v.sanctionsMatch, + adverseMediaMatch: !!v.adverseMediaMatch, + // Blank stays null rather than becoming zero: an income nobody asked for is unknown, and a zero would read as a + // customer who declared no income at all. + declaredAnnualIncome: v.declaredAnnualIncome === '' || v.declaredAnnualIncome == null ? null : v.declaredAnnualIncome, + declaredAnnualTurnover: + v.declaredAnnualTurnover === '' || v.declaredAnnualTurnover == null ? null : v.declaredAnnualTurnover + }; + } + private buildExceptionFields() { const panVerified = this.verificationStatus.pan === 'VERIFIED' || this.kycData?.panVerified === true; const aadhaarVerified = this.verificationStatus.aadhaar === 'VERIFIED' || this.kycData?.aadhaarVerified === true; @@ -484,7 +518,8 @@ export class KycTabComponent implements OnInit, OnDestroy { // dateFormat is required so the backend can parse exceptionResolvedOn when an exception closes. dateFormat: 'dd MMMM yyyy', ...this.buildDocumentFields(), - ...this.buildExceptionFields() + ...this.buildRiskProfileFields(), + ...this.buildExceptionFields() }; if (!this.kycId) { @@ -530,6 +565,7 @@ export class KycTabComponent implements OnInit, OnDestroy { remarks: this.kycForm.value.remarks || null, riskCategory: this.kycForm.value.riskCategory || 1, dateFormat: 'dd MMMM yyyy', + ...this.buildRiskProfileFields(), ...this.buildExceptionFields() }; @@ -729,7 +765,8 @@ export class KycTabComponent implements OnInit, OnDestroy { dateFormat, riskCategory: this.kycForm.value.riskCategory || 1, ...declarationFields, - ...this.buildExceptionFields() + ...this.buildRiskProfileFields(), + ...this.buildExceptionFields() }; this.clientsService @@ -889,7 +926,8 @@ export class KycTabComponent implements OnInit, OnDestroy { dateFormat, riskCategory: this.kycForm.value.riskCategory || 1, ...receiptFields, - ...this.buildExceptionFields() + ...this.buildRiskProfileFields(), + ...this.buildExceptionFields() }; this.clientsService diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html index cf61082bd..7d8c63ad2 100644 --- a/src/app/core/shell/shell.component.html +++ b/src/app/core/shell/shell.component.html @@ -310,41 +310,6 @@ } } - - - - CASH₹14,82,500 - │ - - TXN TODAY - 1,247 - ↑ 8.2% - - │ - CLEARING IN42 - │ - CLEARING OUT38 - │ - - NPA·GROSS - 3.47% - ↑ 0.12 - - │ - CASA58.2% - │ - CD RATIO71.8% - │ - - RBI CIRC - DBR.AML.NO.4/14.01.001 - NEW - - │ - REPO6.50% - │ - - -

- - - LIVE - -
- - - -
-
+ } @@ -713,6 +671,9 @@ + +
+
Evidence relied on
+
+ {{ t }} +
+

Tick at least one piece of evidence

+ + + Reference (optional) + + +
+ + + {{ isWhitelist ? 'Evidence detail' : 'What makes these suspicious?' }} + + A reason is required + + + +

+ {{ + isWhitelist + ? 'Cleared as false positives — no report is filed, and the evidence is retained against each alert.' + : 'Each alert opens its own STR case for the Principal Officer.' + }} +

+ +

{{ errorMessage }}

+ + + + + + `, + styles: [ + ` + .bk-intro { + font-size: 13px; + color: #64748b; + margin: 0 0 12px; + line-height: 1.5; + } + .bk-list { + max-height: 190px; + overflow-y: auto; + border: 1px solid #e2e8f0; + border-radius: 8px; + margin-bottom: 16px; + background: #f8fafc; + } + .bk-row { + display: grid; + grid-template-columns: 1.1fr 1.4fr 1.6fr 0.9fr; + gap: 10px; + padding: 8px 12px; + font-size: 12px; + border-bottom: 1px solid #eef2f7; + } + .bk-row:last-child { + border-bottom: none; + } + .bk-txn { + font-family: 'DM Mono', monospace; + color: #334155; + } + .bk-client { + color: #1e293b; + } + .bk-rule { + color: #7c3aed; + font-size: 11px; + } + .bk-amt { + text-align: right; + font-family: 'DM Mono', monospace; + color: #1e293b; + } + .bk-label { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #64748b; + margin-bottom: 6px; + } + .bk-checks { + display: flex; + flex-direction: column; + gap: 2px; + margin-bottom: 10px; + } + .bk-full { + width: 100%; + } + .bk-effect { + font-size: 12.5px; + line-height: 1.5; + color: #15803d; + background: #f0fdf4; + border: 1px solid #bbf7d0; + border-radius: 6px; + padding: 10px 12px; + margin: 4px 0 0; + } + .bk-effect--warn { + color: #b91c1c; + background: #fef2f2; + border-color: #fecaca; + } + .bk-err { + margin: 10px 0 0; + color: #b91c1c; + font-size: 12.5px; + } + ` + ] +}) +export class AmlBulkReviewDialogComponent { + private fb = inject(FormBuilder); + private amlService = inject(AmlService); + dialogRef = inject(MatDialogRef); + + readonly evidenceTypes = [ + 'Customer explanation obtained', + 'Source of funds documented', + 'KYC / identity re-verified', + 'Consistent with declared income or turnover', + 'Established business or salary pattern', + 'Duplicate or system-generated alert', + 'Other' + ]; + + form: FormGroup; + submitting = false; + errorMessage = ''; + + constructor(@Inject(MAT_DIALOG_DATA) public data: AmlBulkReviewData) { + this.form = this.fb.group({ + evidence: this.fb.array(this.evidenceTypes.map(() => false)), + evidenceRef: [ + '', + Validators.maxLength(100) + ], + notes: [ + '', + [ + Validators.required, + Validators.maxLength(800) + ] + ] + }); + } + + get isWhitelist(): boolean { + return this.data.disposition === 'WHITELIST'; + } + + get evidenceArray(): FormArray { + return this.form.get('evidence') as FormArray; + } + + get selectedEvidence(): string[] { + return this.evidenceTypes.filter((_, i) => this.evidenceArray.at(i)?.value === true); + } + + get evidenceMissing(): boolean { + return this.isWhitelist && this.evidenceArray.touched && this.selectedEvidence.length === 0; + } + + /** Exactly what is written to each alert, so the record reads the same as a single review. */ + private composedNotes(): string { + const v = this.form.value; + const detail = (v.notes || '').trim(); + if (!this.isWhitelist) { + return detail ? `Grounds: ${detail}` : ''; + } + const parts: string[] = []; + if (this.selectedEvidence.length) { + parts.push(`Evidence: ${this.selectedEvidence.join('; ')}`); + } + if (v.evidenceRef) { + parts.push(`Ref: ${(v.evidenceRef || '').trim()}`); + } + const head = parts.join(' | '); + const composed = head && detail ? `${head} — ${detail}` : head || detail; + // Batch note, so each alert says it was disposed of alongside others rather than on its own. + return `${composed} [batch of ${this.data.alerts.length}]`.slice(0, 1000); + } + + submit(): void { + this.evidenceArray.markAllAsTouched(); + if (this.form.invalid || (this.isWhitelist && this.selectedEvidence.length === 0)) { + this.form.markAllAsTouched(); + return; + } + + this.submitting = true; + this.errorMessage = ''; + const notes = this.composedNotes(); + + // Each alert is reviewed on its own call: one that fails - already disposed of by someone else, + // say - must not take the rest of the batch with it. Failures are counted and reported. + forkJoin( + this.data.alerts.map((a) => + this.amlService + .reviewAlert(a.id, this.data.disposition, notes) + .pipe(catchError(() => of({ __failed: true, id: a.id }))) + ) + ).subscribe((results: any[]) => { + this.submitting = false; + const failed = results.filter((r) => r?.__failed).length; + if (failed === results.length) { + this.errorMessage = 'None of the alerts could be updated. They may already have been reviewed.'; + return; + } + if (failed > 0) { + this.errorMessage = `${results.length - failed} updated, ${failed} could not be — those may already have been reviewed.`; + // Still closes as done: most of the batch landed, and the list behind must refresh. + setTimeout(() => this.dialogRef.close(true), 2200); + return; + } + this.dialogRef.close(true); + }); + } +} diff --git a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.html b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.html index 4470f0f0d..d566f2142 100644 --- a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.html +++ b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.html @@ -60,23 +60,113 @@ - +
-
+
-
+ +
-
+ +
+
- + +@if (selectedCount > 0) { +
+ {{ selectedCount }} selected + Low / medium only — a transaction that tripped a high-severity rule is decided on its own. + + + + +
+} + + +
+ + + + + + @@ -113,6 +203,142 @@
{{ alert.ruleCode }} {{ alert.ruleDescription }} + + @if (matchedRuleCount(alert) > 1) { + + } +
+ + + + + + + @@ -132,7 +358,7 @@ - - + + +
+ + @if (selectableRows.length) { + + } + + + @if (isSelectable(alert)) { + + } @else { + — + } + Raised On + +
+ @if (isExpanded(alert)) { +
+
+ Rules matched by {{ alert.transactionType | lowercase }} #{{ alert.transactionId }} — {{ amount(alert) }} + @if (selectableRulesOf(alert).length > 1) { + + } +
+ + @for (rule of matchedRules(alert); track rule.ruleCode) { +
+ + @if (isRuleSelectable(rule)) { + + } @else if (!isGradedRule(rule)) { + + + gavel + + } @else { + + {{ !rule.directlyDecided ? 'remove_circle_outline' : rule.disposition === 'ESCALATE' ? 'report' : 'verified' }} + + } + {{ severityLabel(rule.riskCategory) }} +
+ + {{ rule.ruleCode }} + @if (rule.governing) { + governing + } + + @if (!isGradedRule(rule)) { + + → CTR filing + + } + + @if (rule.disposition) { + + {{ rule.directlyDecided ? ruleDispositionLabel(rule) : 'Closed with case' }} + + } + + {{ rule.ruleDescription }} +
+
+ } + + + @if (selectedRuleCount > 0) { +
+ {{ selectedRuleCount }} rule{{ selectedRuleCount > 1 ? 's' : '' }} selected + + + + +
+ } +
+ @if (isHighSeverity(alert)) { + A high-severity rule fired, so this transaction is decided on its own rather than in a batch. + } @else if (!alert.highestSeverity) { + A statutory return rather than a graded suspicion — decided on its own. + } @else { + Every rule here is low or medium, so this transaction can be decided together with others. + } +
+
+ }
Action + @@ -180,15 +406,33 @@
- + +
- {{ activeStatus ? 'filter_alt_off' : 'verified_user' }} - {{ activeStatus || activeSeverity !== null ? 'filter_alt_off' : 'verified_user' }} + No transactions flagged. The feed updates automatically as the engine raises alerts. - No alerts in this status. + No alerts match the filters in force.
diff --git a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.scss b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.scss index 67a98fce5..2070f6a36 100644 --- a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.scss +++ b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.scss @@ -196,15 +196,43 @@ padding: 0 0 16px; } +/* A button, not a div: each tile narrows the table to its own severity band. */ .risk-tile { display: flex; flex-direction: column; + align-items: flex-start; gap: 2px; padding: 12px 14px; border: 1px solid #e2e8f0; border-left-width: 4px; border-radius: 8px; background: #fff; + font-family: inherit; + text-align: left; + cursor: pointer; + transition: + box-shadow 0.15s, + transform 0.1s; + + &:hover:not(:disabled) { + box-shadow: 0 2px 8px rgb(15 23 42 / 12%); + } + + &:active:not(:disabled) { + transform: translateY(1px); + } + + /* A band with nothing in it would filter to an empty table. */ + &:disabled { + opacity: 0.5; + cursor: default; + } +} + +/* The band currently narrowing the table. The ring reads on every tile colour, where a + background change would fight the severity tint each tile already carries. */ +.risk-tile.tile-active { + box-shadow: 0 0 0 2px #1a237e; } .risk-tile-n { @@ -293,8 +321,21 @@ } } -.alert-row:hover { +.alert-row { + cursor: pointer; + + &:hover { + background: #f8fafc; + } +} + +/* The open row and its pane read as one block. */ +.alert-row.row-expanded { background: #f8fafc; + + td { + border-bottom-color: transparent; + } } /* A newly arrived alert stays tinted until acknowledged. */ @@ -380,6 +421,285 @@ font-size: 12px; } +/* Quieter than the governing rule: it is context on one case, not a second case. */ +.rule-also { + margin-top: 3px; + color: #92400e; + background: #fffbeb; + border: 1px solid #fde68a; + border-radius: 4px; + padding: 1px 6px; + font-size: 11px; + align-self: flex-start; +} + +/* Says how many rules fired and opens the detail. Replaces the block of codes that used to sit in + this cell: the codes belong in the pane, where each can carry its own severity. */ +.rule-more { + display: inline-flex; + align-items: center; + gap: 3px; + margin-top: 4px; + padding: 2px 8px 2px 4px; + align-self: flex-start; + border: 1px solid #fde68a; + border-radius: 999px; + background: #fffbeb; + color: #92400e; + font-family: inherit; + font-size: 11px; + font-weight: 700; + cursor: pointer; + + mat-icon { + font-size: 15px; + width: 15px; + height: 15px; + } + + &:hover { + background: #fef3c7; + } +} + +/* ── Expanded detail ─────────────────────────────────────────────────────────────────────── */ + +.detail-row { + height: 0; +} + +.detail-cell { + padding: 0 !important; + border-bottom: none !important; +} + +/* Animating max-height rather than height: the pane's height depends on how many rules fired, and + a transition needs a figure to move between. */ +.detail-pane { + max-height: 0; + overflow: hidden; + transition: max-height 0.2s ease; + background: #f8fafc; + + /* Generous enough for a transaction that tripped every rule it could, plus the action bar. Too low + silently clips the last rule, which is the one an officer most needs to see. */ + &.detail-open { + max-height: 1200px; + } +} + +.detail-inner { + padding: 14px 16px 16px 60px; + border-bottom: 1px solid #e2e8f0; +} + +.detail-head { + display: flex; + align-items: center; + gap: 12px; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #64748b; + margin-bottom: 10px; +} + +.rule-select-all { + border: none; + background: none; + padding: 0; + font-family: inherit; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #1a237e; + cursor: pointer; + + &:hover { + text-decoration: underline; + } +} + +/* A rule that already carries a verdict is context, not a choice. */ +.detail-rule-decided { + opacity: 0.75; +} + +.rule-decided-icon { + flex: none; + font-size: 18px; + width: 18px; + height: 18px; + color: #15803d; + cursor: help; + + &.decided-escalated { + color: #b91c1c; + } + + /* Closed alongside another rule rather than judged on its own — stated, but not as a verdict. */ + &.decided-carried { + color: #94a3b8; + } + + /* A statutory return: not the officer's decision at all. */ + &.decided-statutory { + color: #475569; + } +} + +/* Where a statutory rule goes, said next to the rule rather than left to be inferred from a + missing checkbox. */ +.ctr-tag { + margin-left: 6px; + padding: 1px 6px; + border-radius: 4px; + background: #e2e8f0; + color: #334155; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.03em; + text-transform: uppercase; + white-space: nowrap; +} + +.rule-verdict { + margin-left: 6px; + padding: 1px 6px; + border-radius: 4px; + background: #dcfce7; + color: #15803d; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.03em; + text-transform: uppercase; + + &.verdict-escalated { + background: #fee2e2; + color: #b91c1c; + } + + /* Wins over the disposition colours above: this rule was not itself the finding, and colouring it + like one would claim the officer condemned it. */ + &.verdict-carried { + background: #f1f5f9; + color: #64748b; + } +} + +/* Acts on the ticked rules only, and sits with them rather than at the top of the table. */ +.rule-action-bar { + display: flex; + align-items: center; + gap: 10px; + margin-top: 12px; + padding: 10px 12px; + border: 1px solid #bfdbfe; + border-radius: 8px; + background: #eff6ff; +} + +.rule-action-count { + font-size: 12.5px; + font-weight: 700; + color: #1e3a8a; + white-space: nowrap; +} + +.detail-rule { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 7px 10px; + border-radius: 6px; + background: #fff; + border: 1px solid #e2e8f0; + + & + & { + margin-top: 6px; + } +} + +/* The rule the alert was raised under, and the one the disposition is recorded against. */ +.detail-rule-governing { + border-color: #cbd5e1; + box-shadow: inset 3px 0 0 #1a237e; +} + +.detail-rule-text { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; +} + +.detail-rule-code { + font-size: 12.5px; + font-weight: 700; + color: #1e293b; +} + +.detail-rule-desc { + font-size: 12px; + color: #64748b; +} + +.governing-tag { + margin-left: 6px; + padding: 1px 6px; + border-radius: 4px; + background: #e0e7ff; + color: #3730a3; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.03em; + text-transform: uppercase; +} + +/* Severity of one rule inside the pane. Same colours as the summary tiles, so a band reads the + same wherever it appears. */ +.sev-chip { + flex: none; + min-width: 62px; + text-align: center; + padding: 2px 8px; + border-radius: 999px; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + + &.sev-high { + background: #fee2e2; + color: #b91c1c; + } + + &.sev-medium { + background: #fef3c7; + color: #b45309; + } + + &.sev-low { + background: #dcfce7; + color: #15803d; + } + + &.sev-none { + background: #e2e8f0; + color: #475569; + } +} + +/* Why this transaction can or cannot be decided in a batch, said where the officer is deciding. */ +.detail-foot { + margin-top: 10px; + font-size: 12px; + color: #475569; + font-style: italic; +} + .rule-code { font-weight: 600; } @@ -537,3 +857,48 @@ background: #fff; border-top: 1px solid #e2e8f0; } + +/* Batch disposition bar — only rendered while rows are ticked. */ +.bulk-bar { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 16px; + margin-bottom: 10px; + border: 1px solid #bfdbfe; + border-radius: 10px; + background: #eff6ff; +} + +.bulk-count { + font-size: 13px; + font-weight: 700; + color: #1e3a8a; + white-space: nowrap; +} + +/* Says why some rows cannot be ticked, where the officer is looking rather than in a tooltip. */ +.bulk-note { + font-size: 12px; + color: #475569; +} + +.bulk-spacer { + flex: 1; +} + +.select-cell { + width: 44px; + padding-right: 0; +} + +/* Stands in for the checkbox on a row that cannot be batched, so the column still reads as a + column rather than leaving a hole the eye has to account for. */ +.select-na { + display: inline-block; + width: 18px; + text-align: center; + color: #cbd5e1; + cursor: help; + user-select: none; +} diff --git a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.ts b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.ts index 87cec7b0b..df87f805a 100644 --- a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.ts +++ b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-flagged-transactions.component.ts @@ -7,7 +7,7 @@ */ /** Angular Imports */ -import { Component, OnInit, OnDestroy, inject, ViewChild } from '@angular/core'; +import { Component, OnInit, AfterViewInit, OnDestroy, inject, ViewChild } from '@angular/core'; import { MatTableModule, MatTableDataSource } from '@angular/material/table'; import { MatPaginatorModule, MatPaginator } from '@angular/material/paginator'; import { MatIconModule } from '@angular/material/icon'; @@ -15,6 +15,7 @@ import { MatButtonModule } from '@angular/material/button'; import { MatTooltipModule } from '@angular/material/tooltip'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatDialog, MatDialogModule } from '@angular/material/dialog'; +import { MatCheckboxModule } from '@angular/material/checkbox'; import { CommonModule } from '@angular/common'; import { Subject, timer, of } from 'rxjs'; import { switchMap, takeUntil, catchError, filter } from 'rxjs/operators'; @@ -24,6 +25,7 @@ import { AmlService } from '../../../aml/aml.service'; import { AlertService } from '../../../core/alert/alert.service'; import { HasPermissionDirective } from '../../../directives/has-permission/has-permission.directive'; import { AmlReviewAlertDialogComponent } from './aml-review-alert-dialog.component'; +import { AmlBulkReviewDialogComponent } from './aml-bulk-review-dialog.component'; /** * Live feed of transactions the AML rule engine has flagged. @@ -48,10 +50,11 @@ import { AmlReviewAlertDialogComponent } from './aml-review-alert-dialog.compone MatTooltipModule, MatProgressSpinnerModule, MatDialogModule, + MatCheckboxModule, HasPermissionDirective ] }) -export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy { +export class AmlFlaggedTransactionsComponent implements OnInit, AfterViewInit, OnDestroy { private amlService = inject(AmlService); private dialog = inject(MatDialog); private alertService = inject(AlertService); @@ -62,6 +65,7 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy { alerts = new MatTableDataSource([]); displayedColumns: string[] = [ + 'select', 'raisedOn', 'transaction', 'client', @@ -71,6 +75,283 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy { 'actions' ]; + /** + * Alerts ticked for a single disposition across several transactions. + * + *

+ * Low and medium-severity alerts arrive in numbers and are mostly the same decision repeated - + * a run of ordinary cash deposits that each tripped a threshold. Dealing with them one dialog at + * a time is what stops an officer getting to the one alert that matters. High severity is + * deliberately excluded: an alert that reached the high band is a judgement about that customer's + * transaction, and it gets its own decision. + */ + selectedIds = new Set(); + + /** m_client_risk_rule.risk_category — 3 is the high band. */ + private static readonly HIGH_SEVERITY = 3; + + /** + * Whether any rule this transaction matched reached the high band. + * + *

+ * Read from highestSeverity, not from the governing rule's own riskCategory. One transaction + * raises one alert under whichever rule most warrants attention, and a statutory CTR outranks + * everything — so a high-severity rule can sit in "also matched" while the row itself reports the + * CTR, which carries no severity at all. Judging the row by the governing rule would offer such a + * transaction up for a batch decision. + */ + isHighSeverity(alert: any): boolean { + return Number(alert?.highestSeverity) === AmlFlaggedTransactionsComponent.HIGH_SEVERITY; + } + + /** + * The band this alert counts as, for the summary tiles. + * + *

+ * The same figure the checkbox is gated on, so the tiles and the table cannot disagree. Reading + * riskCategory here put a ₹12,00,000 cash deposit that had tripped a high-severity rule under + * "Unclassified", because the alert reports under the statutory CTR and a CTR carries no band of + * its own. Alerts raised before highest_severity existed fall back to the governing rule's band. + */ + private severityBand(alert: any): number { + const worst = Number(alert?.highestSeverity); + if (worst === 1 || worst === 2 || worst === 3) { + return worst; + } + const governing = Number(alert?.riskCategory); + if (governing === 1 || governing === 2 || governing === 3) { + return governing; + } + // 0, never NaN: the Unclassified tile filters on this value, and NaN === NaN is false, so a + // NaN band would leave that tile showing a count it could never select. + return 0; + } + + /** + * Only an open alert whose every matched rule sits in the low or medium band can be swept up with + * others. + * + *

+ * Two things are held back. A transaction that tripped any high-severity rule, because that is a + * judgement about the transaction itself. And one where no matched rule carried a severity at + * all, which is how the CTR rules arrive - they are not graded, being returns the bank owes + * rather than suspicions. Neither belongs in a batch disposed of on a single reason. + */ + isSelectable(alert: any): boolean { + const band = Number(alert?.highestSeverity); + const graded = band === 1 || band === 2; + return this.canReview(alert) && graded; + } + + /** The alert whose detail row is open. One at a time: this is a reading pane, not a comparison. */ + expandedId: number | null = null; + + + toggleExpanded(alert: any): void { + this.expandedId = this.expandedId === alert.id ? null : alert.id; + // Ticks belong to the alert they were made on, not to whichever pane is open next. + this.selectedRuleCodes.clear(); + } + + isExpanded(alert: any): boolean { + return this.expandedId === alert.id; + } + + /** + * Every rule the transaction matched, governing one first. + * + *

+ * Served by the API. The fallback rebuilds the list from the flat fields for an alert read before the API carried + * it, so an older row still expands to something rather than appearing to have matched nothing. + */ + matchedRules(alert: any): { ruleCode: string; ruleDescription: string; riskCategory: number | null; governing: boolean }[] { + if (Array.isArray(alert?.matchedRules) && alert.matchedRules.length) { + return alert.matchedRules; + } + const rules = [ + { + ruleCode: alert?.ruleCode, + ruleDescription: alert?.ruleDescription, + riskCategory: alert?.riskCategory ?? null, + governing: true + } + ]; + for (const code of String(alert?.alsoMatchedRules || '') + .split(',') + .map((c: string) => c.trim()) + .filter((c: string) => c)) { + rules.push({ ruleCode: code, ruleDescription: code, riskCategory: null, governing: false }); + } + return rules; + } + + /** How many rules the transaction matched, for the count shown on the collapsed row. */ + matchedRuleCount(alert: any): number { + return this.matchedRules(alert).length; + } + + severityLabel(band: number | null | undefined): string { + switch (Number(band)) { + case 1: + return 'Low'; + case 2: + return 'Medium'; + case 3: + return 'High'; + default: + return 'Statutory'; + } + } + + /** + * Rule codes ticked inside the open detail pane. + * + *

+ * Cleared whenever a different alert is opened: the codes are only meaningful against the alert they were ticked on, + * and carrying them across would apply one transaction's selection to another's rules. + */ + selectedRuleCodes = new Set(); + + isRuleSelected(rule: any): boolean { + return this.selectedRuleCodes.has(rule.ruleCode); + } + + /** + * Whether this rule is the officer's to decide. + * + *

+ * A rule already decided keeps its verdict. A rule with no severity band is not a graded suspicion at all - the CTR + * rules are statutory returns the bank owes FIU-IND on any cash transaction at or above ten lakh, filed on the + * amount whether or not the customer is above suspicion. There is no "whitelist" for one: clearing the transaction + * does not stop the CTR, and escalating it does not describe what the rule found. + */ + isRuleSelectable(rule: any): boolean { + return !rule?.disposition && this.isGradedRule(rule); + } + + /** A rule that carries a severity band, as against a statutory reporting rule that carries none. */ + isGradedRule(rule: any): boolean { + const band = Number(rule?.riskCategory); + return band === 1 || band === 2 || band === 3; + } + + toggleRuleSelected(rule: any): void { + if (!this.isRuleSelectable(rule)) { + return; + } + if (this.selectedRuleCodes.has(rule.ruleCode)) { + this.selectedRuleCodes.delete(rule.ruleCode); + } else { + this.selectedRuleCodes.add(rule.ruleCode); + } + } + + selectableRulesOf(alert: any): any[] { + return this.matchedRules(alert).filter((r) => this.isRuleSelectable(r)); + } + + get selectedRuleCount(): number { + return this.selectedRuleCodes.size; + } + + allRulesSelected(alert: any): boolean { + const open = this.selectableRulesOf(alert); + return open.length > 0 && open.every((r) => this.selectedRuleCodes.has(r.ruleCode)); + } + + toggleAllRules(alert: any): void { + if (this.allRulesSelected(alert)) { + this.selectedRuleCodes.clear(); + return; + } + for (const rule of this.selectableRulesOf(alert)) { + this.selectedRuleCodes.add(rule.ruleCode); + } + } + + ruleDispositionLabel(rule: any): string { + return rule?.disposition === 'ESCALATE' ? 'Escalated' : rule?.disposition === 'WHITELIST' ? 'Whitelisted' : ''; + } + + severityClass(band: number | null | undefined): string { + switch (Number(band)) { + case 1: + return 'sev-low'; + case 2: + return 'sev-medium'; + case 3: + return 'sev-high'; + default: + return 'sev-none'; + } + } + + isSelected(alert: any): boolean { + return this.selectedIds.has(alert.id); + } + + toggleSelected(alert: any): void { + if (!this.isSelectable(alert)) { + return; + } + if (this.selectedIds.has(alert.id)) { + this.selectedIds.delete(alert.id); + } else { + this.selectedIds.add(alert.id); + } + } + + /** The rows a "select all" would act on: what is on screen, open, and below the high band. */ + get selectableRows(): any[] { + return this.alerts.data.filter((a) => this.isSelectable(a)); + } + + get allSelectableSelected(): boolean { + const rows = this.selectableRows; + return rows.length > 0 && rows.every((a) => this.selectedIds.has(a.id)); + } + + toggleSelectAll(): void { + if (this.allSelectableSelected) { + this.selectedIds.clear(); + return; + } + for (const a of this.selectableRows) { + this.selectedIds.add(a.id); + } + } + + get selectedCount(): number { + return this.selectedIds.size; + } + + private selectedAlerts(): any[] { + return this.alerts.data.filter((a) => this.selectedIds.has(a.id)); + } + + /** + * Drops ticks that no longer stand, after every poll. + * + *

+ * The feed refreshes itself every few seconds while the officer works. Without this, an alert + * another officer disposed of in the meantime — or one the status filter has since hidden — stays + * in the set, and the bar goes on offering to decide it: the count reads higher than the ticks on + * screen, and the batch carries alerts nobody chose. + */ + private pruneSelection(): void { + const stillSelectable = new Set(this.selectableRows.map((a) => a.id)); + for (const id of Array.from(this.selectedIds)) { + if (!stillSelectable.has(id)) { + this.selectedIds.delete(id); + } + } + // Same for the open detail pane: an alert filtered away or disposed of elsewhere should not + // leave the table holding a pane for a row that is no longer on it. + if (this.expandedId !== null && !this.alerts.data.some((a) => a.id === this.expandedId)) { + this.expandedId = null; + } + } + /** Every alert as returned, before the status filter narrows the table. */ private allAlerts: any[] = []; @@ -84,12 +365,19 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy { activeStatus = ''; private statusCounts: Record = {}; + /** + * The severity tile currently narrowing the table: 1 Low, 2 Medium, 3 High, 0 unclassified, or + * null for all. Independent of {@link activeStatus} — the two compose, so an officer can ask for + * the high-severity alerts that are still open. + */ + activeSeverity: number | null = null; + /** * Severity mix of the flagged transactions currently in the table. riskCategory is the severity * of the rule that fired, resolved server-side from the originating catalog entry — the same * field the Rules Master tab shows in its Risk Category column. */ - riskSummary: { label: string; count: number; cls: string }[] = []; + riskSummary: { key: number; label: string; count: number; cls: string }[] = []; /** Alerts whose rule has no catalog ancestor (MANUAL_REFERRAL), so no severity to report. */ unclassifiedCount = 0; @@ -106,6 +394,18 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy { @ViewChild(MatPaginator) paginator!: MatPaginator; + /** + * Binds the paginator as soon as the view exists. + * + *

+ * The first poll can return before the view has initialised, and until this the data source was + * only ever wired up as a side effect of a later filter pass — so the table could render every + * alert unpaginated, then jump once the next poll arrived. + */ + ngAfterViewInit(): void { + this.alerts.paginator = this.paginator; + } + ngOnInit(): void { this.fetch(); timer(AmlFlaggedTransactionsComponent.POLL_INTERVAL_MS, AmlFlaggedTransactionsComponent.POLL_INTERVAL_MS) @@ -158,7 +458,22 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy { setStatus(key: string): void { this.activeStatus = key; - this.applyStatusFilter(); + this.applyStatusFilter(true); + } + + /** Clicking the tile already in force clears it, so the same click both narrows and undoes. */ + setSeverity(band: number): void { + this.activeSeverity = this.activeSeverity === band ? null : band; + this.applyStatusFilter(true); + } + + isSeverityActive(band: number): boolean { + return this.activeSeverity === band; + } + + clearSeverity(): void { + this.activeSeverity = null; + this.applyStatusFilter(true); } statusCount(key: string): number { @@ -203,47 +518,63 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy { this.allAlerts = list; this.recomputeStatusCounts(); this.applyStatusFilter(); + this.pruneSelection(); this.lastUpdated = new Date(); } - private applyStatusFilter(): void { - this.alerts.data = this.activeStatus + /** + * @param resetPage + * true only when the user changed a filter. A poll must not reset the page: the feed + * refreshes every few seconds, and an officer reading page 3 would be thrown back to + * page 1 each time. Nor may it touch the paginator before the data source has bound to + * it — doing that on the first pass left pageSize at 0 and emptied the table. + */ + private applyStatusFilter(resetPage = false): void { + const byStatus = this.activeStatus ? this.allAlerts.filter((a) => a.status === this.activeStatus) : this.allAlerts; + this.alerts.data = + this.activeSeverity === null ? byStatus : byStatus.filter((a) => this.severityBand(a) === this.activeSeverity); if (!this.alerts.paginator && this.paginator) { this.alerts.paginator = this.paginator; } - this.recomputeRiskSummary(); + if (resetPage && this.alerts.paginator) { + // Otherwise a filter applied while on page 2 leaves the table blank on a one-page result. + this.alerts.paginator.firstPage(); + } + // Counted before the severity filter, so every tile keeps its real figure and stays clickable. + // Counting the filtered rows would zero the other tiles the moment one was picked, leaving no + // way back to them. + this.recomputeRiskSummary(byStatus); } /** * Counted over the rows the status filter leaves visible, not the whole set — a summary that * disagreed with the table underneath it would be worse than no summary. */ - private recomputeRiskSummary(): void { - const pool = this.alerts.data; + private recomputeRiskSummary(pool: any[]): void { const tiers = [ { key: 3, label: 'High Risk', cls: 'risk-high' }, { key: 2, label: 'Medium Risk', cls: 'risk-medium' }, { key: 1, label: 'Low Risk', cls: 'risk-low' } ]; this.riskSummary = tiers.map((t) => ({ + key: t.key, label: t.label, cls: t.cls, - count: pool.filter((a) => Number(a.riskCategory) === t.key).length + count: pool.filter((a) => this.severityBand(a) === t.key).length })); - // Anything outside 1-3, including null, so the tiles always reconcile to the total. - this.unclassifiedCount = pool.filter( - (a) => ![ - 1, - 2, - 3 - ].includes(Number(a.riskCategory)) - ).length; + // Band 0 is everything ungraded, so the tiles always reconcile to the total. + this.unclassifiedCount = pool.filter((a) => this.severityBand(a) === 0).length; } + /** + * Everything the status filter leaves, whatever severity tile is in force — so the tiles still + * add up to the total while one of them is narrowing the table, and the total reads as the way + * back to all of them. + */ get summaryTotal(): number { - return this.alerts.data.length; + return this.activeStatus ? this.allAlerts.filter((a) => a.status === this.activeStatus).length : this.allAlerts.length; } private recomputeStatusCounts(): void { @@ -330,4 +661,73 @@ export class AmlFlaggedTransactionsComponent implements OnInit, OnDestroy { this.fetch(); }); } + + /** + * Applies one disposition to every ticked alert. + * + *

+ * Each still goes through the review dialog's own requirements - a reason, and for a whitelist + * the evidence behind it - asked once and recorded against every alert in the batch. That is the + * point of the batch: the officer is making one decision about one kind of activity, not the same + * decision twenty times. + */ + /** + * Applies one disposition to the rules ticked inside the open pane. + * + *

+ * The alert's own status is left to the server, which derives it from what remains: whitelisting five of six rules + * leaves the case open on the sixth rather than closing it. + */ + reviewSelectedRules(alert: any, disposition: 'WHITELIST' | 'ESCALATE'): void { + const codes = Array.from(this.selectedRuleCodes); + if (!codes.length) { + return; + } + this.dialog + .open(AmlReviewAlertDialogComponent, { + width: '560px', + maxWidth: '95vw', + autoFocus: false, + disableClose: true, + data: { alert, disposition, ruleCodes: codes } + }) + .afterClosed() + .subscribe((result: string | undefined) => { + if (!result) { + return; + } + this.alertService.alert({ + type: disposition === 'WHITELIST' ? 'Rules Whitelisted' : 'Rules Escalated', + message: `${codes.length} rule${codes.length > 1 ? 's' : ''} on ${alert.transactionType} #${alert.transactionId} ${ + disposition === 'WHITELIST' ? 'cleared' : 'escalated' + }.` + }); + this.selectedRuleCodes.clear(); + // The alert's status may have changed with the last rule decided, so re-read rather than patching locally. + this.fetch(); + }); + } + + reviewSelected(disposition: 'WHITELIST' | 'ESCALATE'): void { + const batch = this.selectedAlerts(); + if (!batch.length) { + return; + } + this.dialog + .open(AmlBulkReviewDialogComponent, { + width: '620px', + maxWidth: '95vw', + autoFocus: false, + disableClose: true, + data: { alerts: batch, disposition } + }) + .afterClosed() + .subscribe((done: boolean | undefined) => { + if (!done) { + return; + } + this.selectedIds.clear(); + this.fetch(); + }); + } } diff --git a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-review-alert-dialog.component.ts b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-review-alert-dialog.component.ts index c5bc66317..2d6825d64 100644 --- a/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-review-alert-dialog.component.ts +++ b/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-review-alert-dialog.component.ts @@ -31,6 +31,11 @@ import { AmlService } from '../../../aml/aml.service'; export interface AmlReviewDialogData { alert: any; disposition: 'WHITELIST' | 'ESCALATE'; + /** + * The rules being decided, when the officer picked some inside the expanded pane rather than acting on the whole + * transaction. Absent means the decision covers the alert and every rule under it. + */ + ruleCodes?: string[]; } /** @@ -162,6 +167,13 @@ export class AmlReviewAlertDialogComponent { } get title(): string { + // Named differently when only some of the transaction's rules are being decided, so the officer is not told they + // are clearing a transaction when five of its six rules stay open. + const n = this.data.ruleCodes?.length ?? 0; + if (n > 0) { + const subject = n === 1 ? 'Rule' : `${n} Rules`; + return this.isWhitelist ? `Whitelist ${subject}` : `Mark ${subject} as Suspicious`; + } return this.isWhitelist ? 'Whitelist Transaction' : 'Mark as Suspicious'; } @@ -181,7 +193,12 @@ export class AmlReviewAlertDialogComponent { this.submitting = true; this.errorMessage = ''; - this.amlService.reviewAlert(this.data.alert.id, this.data.disposition, this.composedNotes).subscribe({ + // Deciding named rules within the alert rather than the whole alert, when the caller passed some. The evidence + // requirements are the same either way, which is why this dialog serves both. + const request = this.data.ruleCodes?.length + ? this.amlService.reviewAlertRules(this.data.alert.id, this.data.ruleCodes, this.data.disposition, this.composedNotes) + : this.amlService.reviewAlert(this.data.alert.id, this.data.disposition, this.composedNotes); + request.subscribe({ next: () => { this.submitting = false; this.dialogRef.close(this.data.disposition); diff --git a/src/app/reports/reports-routing.module.ts b/src/app/reports/reports-routing.module.ts index 76f120463..4a6fe1a06 100644 --- a/src/app/reports/reports-routing.module.ts +++ b/src/app/reports/reports-routing.module.ts @@ -93,6 +93,11 @@ const routes: Routes = [ loadComponent: () => import('../tds-reports/tds-remittance-report.component').then((m) => m.TdsRemittanceReportComponent) }, + { + path: 'bor', + data: { title: 'BOR Register — Share Capital', breadcrumb: 'BOR Register' }, + loadComponent: () => import('../board-resolution/bor-report.component').then((m) => m.BorReportComponent) + }, { path: ':filter', data: { routeParamBreadcrumb: 'filter' }, diff --git a/src/app/risk-review/risk-change-approvals.component.ts b/src/app/risk-review/risk-change-approvals.component.ts new file mode 100644 index 000000000..51a3e1798 --- /dev/null +++ b/src/app/risk-review/risk-change-approvals.component.ts @@ -0,0 +1,390 @@ +/** + * 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 { Component, Inject, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MAT_DIALOG_DATA, MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; + +import { AmlService } from 'app/aml/aml.service'; + +const CATEGORY_LABEL: Record = { 1: 'Low', 2: 'Medium', 3: 'High' }; + +/** + * The approval queue for risk-category changes raised from the Risk Review Report. + * + *

+ * Approving here moves the customer's KYC rating and their re-KYC due date together, so the row + * shows where the rating is going from and to, and why the reviewer asked - the checker is deciding + * how closely the bank watches that customer, not clearing a queue item. + */ +@Component({ + selector: 'mifosx-risk-change-approvals', + standalone: true, + imports: [ + CommonModule, + MatButtonModule, + MatDialogModule, + MatSnackBarModule + ], + template: ` +

+
+
+

Risk Category Approvals

+

+ Changes to a customer's KYC risk category, raised from the Risk Review Report. Approving moves the rating + and the re-KYC due date together, and writes the reason into the customer's risk history. +

+
+
+ +
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Req. IDCustomerCIFBranchRating changeReasonStatusRequested ByRequested OnAction
{{ r.id }} + {{ r.clientName }} + {{ r.cifNumber || '—' }}{{ r.officeName || '—' }} + + {{ labelOf(r.currentCategory) }} → + {{ labelOf(r.requestedCategory) }} + + {{ r.reason }} + {{ r.status }} + {{ r.makerUsername }}{{ r.createdDate }} +
+ + +
+ + by {{ r.checkerUsername }}, {{ r.checkerDate }} + + {{ r.rejectionReason }} +
No risk category change matches this filter.
+
+
+ `, + styles: [ + ` + .rca-root { + padding: 20px 24px 40px; + background: #f8fafc; + min-height: 100%; + } + .rca-head { + margin-bottom: 16px; + } + .rca-title { + margin: 0 0 3px; + font-size: 20px; + font-weight: 700; + color: #1e293b; + } + .rca-sub { + margin: 0; + max-width: 800px; + font-size: 13px; + line-height: 1.5; + color: #64748b; + } + .rca-filters { + display: flex; + gap: 8px; + margin-bottom: 14px; + flex-wrap: wrap; + } + .rca-chip { + height: 32px; + padding: 0 14px; + border: 1px solid #cbd5e1; + border-radius: 999px; + background: #fff; + font: inherit; + font-size: 12.5px; + font-weight: 600; + color: #334155; + cursor: pointer; + } + .rca-chip--on { + border-color: #1e3a8a; + background: #eff6ff; + color: #1e3a8a; + } + .rca-count { + color: #94a3b8; + font-weight: 500; + } + .rca-table-wrap { + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 11px; + overflow-x: auto; + } + .rca-table { + width: 100%; + border-collapse: collapse; + font-size: 12.5px; + } + .rca-table th { + text-align: left; + padding: 10px; + background: #f1f5f9; + font-size: 10.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #475569; + white-space: nowrap; + border-bottom: 1px solid #e2e8f0; + } + .rca-table td { + padding: 11px 10px; + border-bottom: 1px solid #f1f5f9; + vertical-align: middle; + color: #334155; + } + .mono { + font-family: 'DM Mono', monospace; + } + .rca-move { + white-space: nowrap; + } + .rca-arrow { + color: #94a3b8; + margin: 0 4px; + } + .rca-reason { + max-width: 280px; + } + .rca-status { + display: inline-block; + padding: 2px 9px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + background: #f1f5f9; + color: #475569; + } + .rca-status--pending { + background: #fef3c7; + color: #b45309; + } + .rca-status--approved { + background: #dcfce7; + color: #15803d; + } + .rca-status--rejected { + background: #fee2e2; + color: #b91c1c; + } + .rca-actions { + display: flex; + gap: 8px; + } + .rca-done { + font-size: 11.5px; + color: #64748b; + } + .rca-empty { + padding: 30px; + text-align: center; + color: #94a3b8; + } + ` + ] +}) +export class RiskChangeApprovalsComponent implements OnInit { + private amlService = inject(AmlService); + private dialog = inject(MatDialog); + private snackBar = inject(MatSnackBar); + + rows: any[] = []; + readonly statuses = ['PENDING', 'APPROVED', 'REJECTED', '']; + selectedStatus = 'PENDING'; + + ngOnInit(): void { + this.load(); + } + + load(): void { + this.amlService.getRiskChangeRequests().subscribe({ + next: (rows) => (this.rows = rows || []), + error: (err: any) => + this.snackBar.open('Could not load requests: ' + (err?.error?.defaultUserMessage || err.message), 'Close', { + duration: 5000 + }) + }); + } + + get filtered(): any[] { + return this.selectedStatus ? this.rows.filter((r) => r.status === this.selectedStatus) : this.rows; + } + + countOf(status: string): number { + return status ? this.rows.filter((r) => r.status === status).length : this.rows.length; + } + + filterBy(status: string): void { + this.selectedStatus = status; + } + + labelOf(category: number | null): string { + return category == null ? 'Unrated' : CATEGORY_LABEL[category] || '—'; + } + + approve(r: any): void { + this.amlService.approveRiskChange(r.id).subscribe({ + next: () => { + this.snackBar.open( + `${r.clientName} moved to ${this.labelOf(r.requestedCategory)} — re-KYC due date updated.`, + 'Close', + { duration: 6000 } + ); + this.load(); + }, + error: (err: any) => + this.snackBar.open('Error: ' + (err?.error?.defaultUserMessage || err.message), 'Close', { duration: 7000 }) + }); + } + + reject(r: any): void { + this.dialog + .open(RejectRiskChangeDialogComponent, { width: '450px', data: { request: r }, disableClose: true }) + .afterClosed() + .subscribe((done) => { + if (done) { + this.load(); + } + }); + } +} + +// ── REJECT DIALOG ── +@Component({ + selector: 'mifosx-reject-risk-change-dialog', + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + MatDialogModule, + MatFormFieldModule, + MatInputModule, + MatButtonModule + ], + template: ` +

Reject Risk Category Change

+ +

+ {{ data.request.clientName }} stays on their current rating. The reason is shown to the reviewer who raised it. +

+
+ + Rejection reason + + A reason is required + +
+
+ + + + + `, + styles: [ + ` + .full { + width: 100%; + } + .hint { + font-size: 13px; + color: #64748b; + margin: 0 0 12px; + line-height: 1.5; + } + ` + ] +}) +export class RejectRiskChangeDialogComponent { + private fb = inject(FormBuilder); + private amlService = inject(AmlService); + private snackBar = inject(MatSnackBar); + dialogRef = inject(MatDialogRef); + + form: FormGroup; + busy = false; + + constructor(@Inject(MAT_DIALOG_DATA) public data: { request: any }) { + this.form = this.fb.group({ rejectionReason: ['', Validators.required] }); + } + + submit(): void { + if (this.form.invalid) { + return; + } + this.busy = true; + this.amlService.rejectRiskChange(this.data.request.id, this.form.value.rejectionReason).subscribe({ + next: () => { + this.busy = false; + this.snackBar.open('Risk category change rejected', 'Close', { duration: 3000 }); + this.dialogRef.close(true); + }, + error: (err: any) => { + this.busy = false; + this.snackBar.open('Error: ' + (err?.error?.defaultUserMessage || err.message), 'Close', { duration: 6000 }); + } + }); + } +} diff --git a/src/app/risk-review/risk-change-dialog.component.ts b/src/app/risk-review/risk-change-dialog.component.ts new file mode 100644 index 000000000..2cd8c7915 --- /dev/null +++ b/src/app/risk-review/risk-change-dialog.component.ts @@ -0,0 +1,222 @@ +/** + * 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 { Component, Inject, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { MatSnackBar } from '@angular/material/snack-bar'; + +import { AmlService } from 'app/aml/aml.service'; + +export interface RiskChangeDialogData { + clientId: number; + customerName: string; + /** 1 Low, 2 Medium, 3 High; null where the customer carries no rating yet. */ + currentCategory: number | null; + /** What the report's own computation says the rating should be, used as the default. */ + recommendedCategory: number | null; + /** The one-line explanation the report already shows against the row. */ + recommendationReason: string; +} + +/** + * Raises a change to a customer's KYC risk category for approval. + * + *

+ * The rating governs how often the customer is re-KYC'd and what AML thresholds they are measured + * against, so it is not something one officer moves on their own. The dialog collects what it + * should become and why; a checker applies it. + */ +@Component({ + selector: 'mifosx-risk-change-dialog', + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + MatDialogModule, + MatButtonModule, + MatFormFieldModule, + MatInputModule, + MatSelectModule + ], + template: ` +

Change Risk Category

+ +
+
+ Customer + {{ data.customerName }} +
+
+ Rating on file + {{ labelOf(data.currentCategory) }} +
+
+ + +

+ Why this came up
{{ data.recommendationReason }} +

+ +
+ + New risk category + + Low + Medium + High + + Pick the new category + + + + Reason + + Recorded against the customer's risk history + A reason is required + +
+ +

+ The rating does not move now. It goes to a checker, and on approval the customer's KYC record and re-KYC due + date are updated together. +

+ +

{{ errorMessage }}

+
+ + + + + `, + styles: [ + ` + .rc-summary { + border: 1px solid #e2e8f0; + border-radius: 8px; + padding: 12px 14px; + margin-bottom: 14px; + background: #f8fafc; + } + .rc-row { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 16px; + padding: 3px 0; + } + .rc-label { + color: #64748b; + font-size: 12.5px; + } + .rc-value { + color: #1e293b; + font-size: 13.5px; + } + .rc-reason { + font-size: 12.5px; + line-height: 1.5; + color: #334155; + margin: 0 0 14px; + } + .rc-form { + display: flex; + flex-direction: column; + } + .rc-full { + width: 100%; + } + .rc-effect { + font-size: 12.5px; + line-height: 1.55; + color: #1e3a8a; + background: #eff6ff; + border: 1px solid #bfdbfe; + border-radius: 6px; + padding: 10px 12px; + margin: 4px 0 0; + } + .rc-error { + margin: 12px 0 0; + padding: 10px 12px; + border: 1px solid #fecaca; + border-radius: 6px; + background: #fef2f2; + color: #b91c1c; + font-size: 12.5px; + } + ` + ] +}) +export class RiskChangeDialogComponent { + private fb = inject(FormBuilder); + private amlService = inject(AmlService); + private snackBar = inject(MatSnackBar); + dialogRef = inject(MatDialogRef); + + form: FormGroup; + submitting = false; + errorMessage = ''; + + constructor(@Inject(MAT_DIALOG_DATA) public data: RiskChangeDialogData) { + this.form = this.fb.group({ + // Defaulted to what the report recommends, but left editable: the reviewer has seen the + // customer and the computation has not. + requestedCategory: [ + data.recommendedCategory ?? null, + Validators.required + ], + reason: [ + '', + Validators.required + ] + }); + } + + labelOf(category: number | null): string { + return category === 1 ? 'Low' : category === 2 ? 'Medium' : category === 3 ? 'High' : 'Unrated'; + } + + submit(): void { + if (this.form.invalid) { + this.form.markAllAsTouched(); + return; + } + this.submitting = true; + this.errorMessage = ''; + const v = this.form.value; + this.amlService.requestRiskChange(this.data.clientId, v.requestedCategory, (v.reason || '').trim()).subscribe({ + next: () => { + this.submitting = false; + this.snackBar.open( + `Risk change to ${this.labelOf(v.requestedCategory)} sent for approval — ${this.data.customerName}`, + 'Close', + { duration: 5000 } + ); + this.dialogRef.close(true); + }, + error: (err: any) => { + this.submitting = false; + this.errorMessage = err?.error?.defaultUserMessage || err?.error?.message || 'Could not send the request.'; + } + }); + } +} diff --git a/src/app/risk-review/risk-review-data.ts b/src/app/risk-review/risk-review-data.ts index 633de824d..fea25ea07 100644 --- a/src/app/risk-review/risk-review-data.ts +++ b/src/app/risk-review/risk-review-data.ts @@ -372,13 +372,29 @@ export const MATERIALITY = { * Rules whose seriousness does not depend on the amount. A sanctions match or a * deliberate structuring pattern is grave at any value, so activity being small * is not a reason to bring the rating down. + * + * Matched as prefixes, not as whole codes. The rule set names these by variant — + * STRUCTURING_BELOW_50K, STRUCTURING_BELOW_10L, PEP_ACCOUNT_MONITORING — so an + * exact-match Set never matched any of them and the protection was dead: a + * customer caught structuring could be recommended for downgrade purely because + * the amounts involved were small, which is the one case structuring exists to + * catch. */ -export const NON_MITIGABLE_RULES = new Set([ - 'SANCTIONS_HIT', +export const NON_MITIGABLE_RULE_PREFIXES = [ + 'SANCTIONS', 'PEP', 'STRUCTURING', - 'HIGH_RISK_COUNTRY' -]); + 'STRUCTURED', + 'HIGH_RISK_COUNTRY', + 'ADVERSE_MEDIA', + 'WILFUL_DEFAULTER' +]; + +/** Whether a rule code names activity that stays grave however small the amounts are. */ +export function isNonMitigableRule(ruleCode: string): boolean { + const code = (ruleCode || '').toUpperCase(); + return NON_MITIGABLE_RULE_PREFIXES.some((prefix) => code.startsWith(prefix)); +} /** Shape of one element of GET /aml/alerts. */ export interface LiveAmlAlert { diff --git a/src/app/risk-review/risk-review-report.component.html b/src/app/risk-review/risk-review-report.component.html index 503cb0dad..9bef269f2 100644 --- a/src/app/risk-review/risk-review-report.component.html +++ b/src/app/risk-review/risk-review-report.component.html @@ -304,7 +304,9 @@ reason sits under it rather than under the button, because it explains this conclusion and not the act of opening the KYC screen. --> - + + {{ directionText(c) }} @@ -323,15 +325,32 @@ - + + @if (canChangeRisk(c)) { + + } @else { + + + } diff --git a/src/app/risk-review/risk-review-report.component.scss b/src/app/risk-review/risk-review-report.component.scss index c4f9b6066..db236d881 100644 --- a/src/app/risk-review/risk-review-report.component.scss +++ b/src/app/risk-review/risk-review-report.component.scss @@ -937,6 +937,13 @@ background: var(--r-g100); color: var(--r-g500); } + + /* An unrated customer reads neither as settled nor as a movement: there is no rating to move. Amber says the row + is outstanding without claiming the customer is a concern — the gap is the bank's, not theirs. */ + &--unrated { + background: var(--r-amber-l, #fef3c7); + color: var(--r-amber, #b45309); + } } .rr-move-why { diff --git a/src/app/risk-review/risk-review-report.component.ts b/src/app/risk-review/risk-review-report.component.ts index 8f0775fc3..3b4fd2fb4 100644 --- a/src/app/risk-review/risk-review-report.component.ts +++ b/src/app/risk-review/risk-review-report.component.ts @@ -10,6 +10,7 @@ import { Component, OnInit, inject } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { Router } from '@angular/router'; +import { MatDialog } from '@angular/material/dialog'; import { forkJoin, of } from 'rxjs'; import { catchError, map } from 'rxjs/operators'; @@ -38,12 +39,14 @@ import { RiskLevel, channelLabel, mapLiveAlert, - riskFromCategory + riskFromCategory, + riskRank } from './risk-review-data'; import { DateRange, RatingDirection, RiskAlertView, + BehaviourWindowByClient, RiskCustomerRow, RiskReviewFilter, RiskReviewModel, @@ -53,6 +56,7 @@ import { monthRange, weekRange } from './risk-review.compute'; +import { RiskChangeDialogComponent, RiskChangeDialogData } from './risk-change-dialog.component'; type PeriodMode = 'day' | 'week' | 'month' | 'range'; type ViewMode = 'customer' | 'alert' | 'txn'; @@ -109,6 +113,7 @@ export class RiskReviewReportComponent implements OnInit { private clients = inject(ClientsService); private scope = inject(BranchScopeService); private router = inject(Router); + private dialog = inject(MatDialog); readonly period = GL_PERIOD; readonly branches = GL_BRANCHES; @@ -181,21 +186,37 @@ export class RiskReviewReportComponent implements OnInit { // ── Loading ──────────────────────────────────────────────────────────────── + /** + * The nightly job's verdict per customer, keyed on client id. + * + * Empty until the first load, and stays empty on a tenant where the job has not run - the report then falls back to + * its own per-alert reckoning rather than showing nothing. + */ + private behaviourWindows: BehaviourWindowByClient = {}; + load(): void { this.loading = true; - 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; + // Both in one pass: the windows decide what each row recommends, so composing before they arrive would show a + // recommendation the screen then silently replaced. + forkJoin({ + alerts: this.aml.getAlerts().pipe(catchError(() => of([] as LiveAmlAlert[]))), + windows: this.aml.getBehaviourWindows().pipe(catchError(() => of([] as any[]))) + }).subscribe(({ alerts: live, windows }) => { + this.behaviourWindows = {}; + for (const w of windows || []) { + if (w?.clientId != null) { + this.behaviourWindows[w.clientId] = w; } - this.enrichCurrentRisk(rows); - }); + } + 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); + }); } /** @@ -336,9 +357,11 @@ export class RiskReviewReportComponent implements OnInit { private memo: RiskReviewModel = { alerts: [], customers: [], txns: [], summary: EMPTY_SUMMARY }; private get model(): RiskReviewModel { - const key = JSON.stringify(this.filter) + `|${this.alerts.length}`; + // The window count belongs in the key: the windows decide what each row recommends, and they arrive in the same + // load as the alerts. Keying on the alerts alone would serve the memo computed before they landed. + const key = JSON.stringify(this.filter) + `|${this.alerts.length}|${Object.keys(this.behaviourWindows).length}`; if (key !== this.memoKey) { - this.memo = buildRiskReview(this.alerts, this.filter); + this.memo = buildRiskReview(this.alerts, this.filter, this.behaviourWindows); this.memoKey = key; } return this.memo; @@ -364,10 +387,63 @@ export class RiskReviewReportComponent implements OnInit { * a regulatory clock, an under-rated customer is the real compliance gap, an over-rated one is * only a correction, and matching ratings need nothing at all. */ + /** + * A report row for a customer the behaviour review named but no alert brought onto the report. + * + *

+ * The alert-derived fields are empty because there are none in the window, and that is the point being shown: the + * case rests on the period, not on anything that fired. The figures the verdict was drawn from travel on + * behaviourWindow, so the row can still explain itself. + */ + private rowFromWindow(w: any): RiskCustomerRow { + const RANK: Record = { 1: 'LOW', 2: 'MEDIUM', 3: 'HIGH' }; + const current = RANK[w.riskCategoryAtReview] ?? null; + const recommended = RANK[w.suggestedCategory] ?? current ?? 'LOW'; + return { + clientId: w.clientId, + customerName: w.clientName || `Client ${w.clientId}`, + accountNo: w.accountNo || '', + branchCode: w.officeName || '', + currentRisk: current, + alerts: [], + alertCount: 0, + totalAmount: Number(w.creditTotal || 0) + Number(w.debitTotal || 0), + largestAmount: Number(w.largestTxn || 0), + proposedRisk: recommended, + recommendedRisk: recommended, + direction: w.suggestedDirection === 'UP' ? 'up' : w.suggestedDirection === 'DOWN' ? 'down' : 'same', + directionReason: w.suggestionReason || '', + ruleLabels: [], + firstAlertDate: '', + lastAlertDate: '', + openCount: 0, + escalated: false, + needsUpgrade: w.suggestedDirection === 'UP', + needsDowngrade: w.suggestedDirection === 'DOWN', + behaviourWindow: w + }; + } + get customerGroups(): RiskCustomerGroup[] { const rows = this.customerRows; - const escalated = rows.filter((c) => c.escalated); - const rest = rows.filter((c) => !c.escalated); + // An escalated customer whose rating still has to move belongs with the work, not with the cases that are waiting + // on someone else. An open STR holds a rating at High, and if the customer is not yet at High that is a change + // somebody has to apply - filing the STR does not make it. Only a customer whose rating already matches sits under + // the Principal Officer with nothing to do. + const escalated = rows.filter((c) => c.escalated && c.direction === 'same'); + const rest = rows.filter((c) => !(c.escalated && c.direction === 'same')); + + // Customers the nightly review says to move who raised no alert inside the filter window. + // + // The report is built from alerts, so a customer appears only once a rule has fired on them recently. That is the + // wrong test for a downgrade: the whole case for lowering a rating is that nothing has fired for a year, so the + // customer with the strongest case is precisely the one the alert feed has stopped mentioning. Without this they + // would sit in the behaviour table with a verdict nobody ever sees. + const onReport = new Set(rows.map((r) => r.clientId)); + const offReport: RiskCustomerRow[] = Object.values(this.behaviourWindows) + .filter((w: any) => w?.clientId != null && !onReport.has(w.clientId)) + .filter((w: any) => w.suggestedDirection === 'UP' || w.suggestedDirection === 'DOWN') + .map((w: any) => this.rowFromWindow(w)); const groups: RiskCustomerGroup[] = [ { @@ -378,25 +454,42 @@ export class RiskReviewReportComponent implements OnInit { rows: escalated }, { - key: 'up', - title: 'Rating needs raising', - hint: 'The rules point higher than the rating on file. These are under-rated customers — the real gap.', + // Raising and lowering are one section, not two. The heading answers "is there anything to do about this + // customer", and which way the rating moves is already on every row - as a verdict, an arrow and a reason. + // Splitting them put the same answer in two places and left an officer reading two lists to find one day's + // work. Raises sort first within the section, because an under-rated customer is the more urgent gap. + key: 'change', + title: 'Change needed', + hint: 'Every condition for moving the rating is met. Apply the change; it goes to a checker for approval.', tone: 'up', - rows: rest.filter((c) => c.direction === 'up') + rows: [ + ...rest.filter((c) => c.currentRisk && c.direction === 'up'), + ...offReport.filter((c) => c.direction === 'up'), + ...rest.filter((c) => c.currentRisk && c.direction === 'down'), + ...offReport.filter((c) => c.direction === 'down') + ] }, { - key: 'down', - title: 'Rating can be lowered', - hint: 'Activity no longer supports the rating on file.', - tone: 'down', - rows: rest.filter((c) => c.direction === 'down') + // Its own section, and above "no change needed", because it is the opposite of that. An unrated customer + // whose money is moving has no rating for the behaviour to be compared against, so nothing here can say + // whether it is in keeping - the work is to rate them, and grouping them under "no change needed" buried the + // one action the report was asking for. + key: 'unrated', + title: 'Rating needed', + hint: 'These customers are transacting with no rating on file. Nothing can be judged against a rating that does not exist — rate them first.', + tone: 'up', + rows: rest.filter((c) => !c.currentRisk) }, { + // Everything an alert put on the report that the policy will not move. "No change needed" read as though + // these customers were fine, which is not what it means: a High-rated customer with four open alerts is on + // this list, and the reason is that the conditions for lowering the rating are not met - not that there is + // nothing to look at. The reason on each row says which condition fell short. key: 'same', - title: 'No change needed', - hint: 'The rating on file already matches the behaviour. Listed for the record.', + title: 'Rating holds — no change this period', + hint: 'An alert brought these customers onto the report, but the conditions for moving the rating are not met. The reason on each row says what is missing.', tone: 'hold', - rows: rest.filter((c) => c.direction === 'same') + rows: rest.filter((c) => c.currentRisk && c.direction === 'same') } ]; @@ -575,6 +668,67 @@ export class RiskReviewReportComponent implements OnInit { ]); } + /** + * Sends a case that is with the Principal Officer to the STR queue. + * + * The rating cannot be moved while an STR is open, so the useful thing to offer is the case + * itself. Sending the reviewer to the customer's KYC record instead made them land on a screen + * where the one action they might take is the one thing they must not do yet. + */ + openStrCase(c: RiskCustomerRow, event: Event): void { + event.stopPropagation(); + this.router.navigate(['/fiu'], { queryParams: { tab: 'str' } }); + } + + /** + * Whether this row's rating can be moved from here. + * + * A case with the Principal Officer is deliberately excluded: the rating is held where it is + * until the STR concludes, which is what the section heading on that group says. Offering the + * button there would invite a change nobody is in a position to make yet. + */ + canChangeRisk(c: RiskCustomerRow): boolean { + return !c.escalated; + } + + /** + * Raises a rating change for approval, rather than sending the reviewer to the KYC screen to make + * it themselves. + * + * The button used to navigate away: the reviewer left the report, found the customer, edited the + * record, and the change took effect on their say-so alone with no second pair of eyes and no + * record of why. The rating governs re-KYC frequency and AML thresholds, so it goes through a + * checker like every other decision of that weight. + */ + changeRisk(c: RiskCustomerRow, event: Event): void { + event.stopPropagation(); + if (!this.canChangeRisk(c)) { + return; + } + this.dialog + .open(RiskChangeDialogComponent, { + width: '520px', + maxWidth: '95vw', + disableClose: true, + data: { + clientId: c.clientId, + customerName: c.customerName, + currentCategory: riskRank(c.currentRisk) || null, + recommendedCategory: riskRank(c.recommendedRisk) || null, + recommendationReason: c.directionReason + } as RiskChangeDialogData + }) + .afterClosed() + .subscribe((sent) => { + if (sent) { + // The rating has not moved yet - only a request exists - so the figures on screen are + // still correct. Reloaded anyway so a second click cannot raise a duplicate request the + // server would refuse. + this.load(); + } + }); + } + /** * What the reviewer is going to the KYC screen to do. * @@ -589,25 +743,41 @@ export class RiskReviewReportComponent implements OnInit { * change nobody is authorised to make yet. */ directionText(c: RiskCustomerRow): string { - if (c.escalated) { + // Escalation is only the whole story when the rating already matches. An STR holds a customer at High, so one + // filed against a Medium-rated customer leaves a rating that still has to be moved - saying "held" there reports + // the case and hides the work, which is how this row came to sit under "Change needed" announcing that nothing + // needed doing. + if (c.escalated && c.direction === 'same') { return 'Held — with Principal Officer'; } - if (c.direction === 'up') { - return `Raise to ${RISK_LABEL[c.recommendedRisk]}`; + // Both ends of the move, not just the destination. "Lower to Medium" reads as a verdict on its own and leaves the + // officer joining it to the rating column to see what is actually changing; "High → Medium" is the whole fact in + // one glance, and on an exported sheet it still says what happened without the column beside it. + if (c.direction === 'up' || c.direction === 'down') { + const from = c.currentRisk ? RISK_LABEL[c.currentRisk] : 'Unrated'; + return `${from} → ${RISK_LABEL[c.recommendedRisk]}`; } - if (c.direction === 'down') { - return `Lower to ${RISK_LABEL[c.recommendedRisk]}`; - } - return c.currentRisk ? `Keep at ${RISK_LABEL[c.currentRisk]}` : 'No rating on record'; + // An unrated customer is told what to do, not what was concluded. "No rating on record" states the gap and stops + // there, which on a worklist reads as though the row needs nothing - and it is in fact the row that needs the most. + return c.currentRisk ? `Keep at ${RISK_LABEL[c.currentRisk]}` : 'Rate this customer'; } directionIcon(c: RiskCustomerRow): string { - if (c.escalated) { + // Same test as directionText: a padlock says the case is out of the officer's hands, which is untrue while the + // rating still has to be moved. + if (c.escalated && c.direction === 'same') { return 'lock'; } - // 'check' rather than an equals sign: faEquals is not in the shared library, and an - // unregistered icon throws at render and takes the whole row down with it. - return c.direction === 'up' ? 'arrow-up' : c.direction === 'down' ? 'arrow-down' : 'check'; + if (c.direction === 'up') { + return 'arrow-up'; + } + if (c.direction === 'down') { + return 'arrow-down'; + } + // A tick claims the row is settled. An unrated customer is not settled, so it gets the mark that asks a question + // rather than the one that closes it. 'exclamation-circle' rather than plain 'exclamation': only the registered + // names resolve, and an unregistered icon throws at render and takes the whole row down with it. + return c.currentRisk ? 'check' : 'exclamation-circle'; } /** diff --git a/src/app/risk-review/risk-review.compute.ts b/src/app/risk-review/risk-review.compute.ts index 67499a2bc..558b80ab5 100644 --- a/src/app/risk-review/risk-review.compute.ts +++ b/src/app/risk-review/risk-review.compute.ts @@ -10,7 +10,7 @@ import { AmlRuleMeta, CTR_THRESHOLD, MATERIALITY, - NON_MITIGABLE_RULES, + isNonMitigableRule, REVIEW_STATUS_META, RISK_LABEL, RiskAlertRow, @@ -109,6 +109,8 @@ export interface RiskCustomerRow { needsUpgrade: boolean; /** True when the rating on record is higher than the activity warrants. */ needsDowngrade: boolean; + /** The review period the recommendation was drawn from, where the nightly job has computed one. */ + behaviourWindow?: BehaviourWindowByClient[number] | null; } export interface RiskReviewSummary { @@ -158,7 +160,7 @@ function recommendRating( ruleCodes: string[] ): { risk: RiskLevel; reason: string } { const implied = RANK_TO_LEVEL[impliedRank] ?? 'LOW'; - const grave = ruleCodes.find((c) => NON_MITIGABLE_RULES.has(c)); + const grave = ruleCodes.find((c) => isNonMitigableRule(c)); const material = largestAmount >= CTR_THRESHOLD || totalAmount >= MATERIALITY.highTotal; const small = largestAmount < MATERIALITY.smallTxn && totalAmount < MATERIALITY.smallTotal; @@ -271,7 +273,36 @@ export function riskAlertViews(alerts: RiskAlertRow[], filter: RiskReviewFilter) * 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[] { +/** + * The behaviour window the nightly job computed for one customer, where there is one. + * + * Keyed on client id. A customer with no window - one the job has not seen yet, or a tenant where the job has never + * run - falls back to the per-alert reckoning below, so the report renders either way. + */ +export interface BehaviourWindowByClient { + [clientId: number]: { + clientId: number; + clientName: string | null; + accountNo: string | null; + officeName: string | null; + riskCategoryAtReview: number | null; + txnCount: number | null; + distinctMonths: number | null; + windowFrom: any; + windowTo: any; + creditTotal: number | null; + debitTotal: number | null; + largestTxn: number | null; + cashTotal: number | null; + alertCount: number | null; + strCaseCount: number | null; + suggestedDirection: string | null; + suggestedCategory: number | null; + suggestionReason: string | null; + }; +} + +export function riskCustomerRows(views: RiskAlertView[], windows: BehaviourWindowByClient = {}): RiskCustomerRow[] { const byClient = new Map(); for (const v of views) { const list = byClient.get(v.clientId); @@ -297,19 +328,53 @@ export function riskCustomerRows(views: RiskAlertView[]): RiskCustomerRow[] { 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) - ); + // The server's own reckoning, where the nightly job has produced one. + // + // It is the better answer, and not because it is newer: it is drawn from a review period rather than from the + // alerts on this screen. A customer appears here only once a rule has fired, so judging them on those alerts + // alone describes the exceptions and never the ordinary months around them - which is exactly what a rating is + // supposed to reflect. The job also tests the things this screen cannot see at all: how many separate months the + // activity falls in, how long the customer has held the rating, whether an STR inside the block window forbids + // lowering it. + // + // Without a window - a tenant where the job has not run, or a customer it has not reached - the per-alert + // reckoning below still stands, so the report never renders blank while the two are in step. + // A window is only good for as long as the rating it was computed against. The job takes a snapshot each night + // and records the rating the customer held at the time; once an officer moves that rating the snapshot describes + // a customer who no longer exists, and the screen would go on asking for a change that has already been applied + // and approved. Dropping it here falls back to the per-alert reckoning until tonight's job catches up. + const raw = windows[clientId]; + const staleWindow = + raw?.riskCategoryAtReview != null && currentRisk != null && RANK_TO_LEVEL[raw.riskCategoryAtReview] !== currentRisk; + const window = staleWindow ? undefined : raw; + const recommendation = window?.suggestedDirection + ? { + risk: RANK_TO_LEVEL[window.suggestedCategory ?? 0] ?? currentRisk ?? 'LOW', + reason: window.suggestionReason ?? '' + } + : 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'; + const direction: RatingDirection = window?.suggestedDirection + ? window.suggestedDirection === 'UP' + ? 'up' + : window.suggestedDirection === 'DOWN' + ? 'down' + : 'same' + : delta > 0 + ? 'up' + : delta < 0 + ? 'down' + : 'same'; rows.push({ clientId, @@ -332,7 +397,10 @@ export function riskCustomerRows(views: RiskAlertView[]): RiskCustomerRow[] { openCount: alerts.filter((a) => REVIEW_STATUS_META[a.status].open).length, escalated, needsUpgrade: direction === 'up', - needsDowngrade: direction === 'down' + needsDowngrade: direction === 'down', + // Carried through so the screen can show the period a recommendation was drawn from. A rating an officer is + // asked to approve has to show its working, and the alerts on this row are not it. + behaviourWindow: window ?? null }); } @@ -461,9 +529,13 @@ export interface RiskReviewModel { * 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 { +export function buildRiskReview( + alerts: RiskAlertRow[], + filter: RiskReviewFilter, + windows: BehaviourWindowByClient = {} +): RiskReviewModel { const base = riskAlertViews(alerts, filter); - const narrowed = applyDirectionFilter(base, riskCustomerRows(base), filter.direction); + const narrowed = applyDirectionFilter(base, riskCustomerRows(base, windows), filter.direction); return { alerts: narrowed.views, diff --git a/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html b/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html index 9a5bb2c85..03f8fa26a 100644 --- a/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html +++ b/src/app/tasks/checker-inbox-and-tasks/checker-inbox-and-tasks.component.html @@ -232,6 +232,12 @@ DD Revoke Requests + + + + Risk Category Changes +

diff --git a/src/app/tasks/tasks-routing.module.ts b/src/app/tasks/tasks-routing.module.ts index 03713aea3..e247d0f7a 100644 --- a/src/app/tasks/tasks-routing.module.ts +++ b/src/app/tasks/tasks-routing.module.ts @@ -243,6 +243,12 @@ const routes: Routes = [ (m) => m.DdRevokeApprovalsComponent ), data: { title: 'DD Revoke Approvals' } + }, + { + path: 'risk-change-approvals', + loadComponent: () => + import('../risk-review/risk-change-approvals.component').then((m) => m.RiskChangeApprovalsComponent), + data: { title: 'Risk Category Approvals' } } ] }, diff --git a/src/environments/.env.ts b/src/environments/.env.ts index c3226b95c..4509e26ef 100644 --- a/src/environments/.env.ts +++ b/src/environments/.env.ts @@ -9,8 +9,8 @@ /* tslint:disable */ export default { 'mifos_x': { - 'version': '260914', - 'hash': 'ee5a430b' + 'version': '260916', + 'hash': 'c01615ee' }, 'allow_switching_backend_instance': true };