Compare commits

..
24 Commits
Author SHA1 Message Date
nitesh 14c5f5070d Revert demo to pre-2026-08-06 state (rollback today's push)
Reverts all commits pushed to demo today (a5124ca3, 858c277a, a1a50ab8,
f72abb09, fcf66657) back to f8c532f7 as a single forward commit, without
rewriting history. All reverted content (Share Withdrawal/Closure feature,
TDS reports, KYC exception tracking, client-edit-email-dialog, form121 tab,
search-select) remains intact on dev and was verified identical before
this revert.
2026-08-06 13:39:40 +05:30
niteshandClaude Sonnet 5 fcf6665772 fix(nav): restore Loan Origination and Investment menus dropped by merge
The "Merge dev into demo" commit silently dropped both top-level nav
entries (desktop and mobile variants) along with their entire menu
panels — Loan Origination's mat-menu and the whole Investment mega-menu
(~150 lines) — because dev never had these menus, so the merge picked
dev's shell.component.html wholesale for this file instead of combining
both sides' additions. The underlying feature modules and routes were
untouched, so this restores only the shell wiring, copied verbatim from
the pre-merge demo revision.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 13:19:24 +05:30
nitesh f72abb0921 Merge remote-tracking branch 'origin/demo' into demo 2026-08-06 13:14:26 +05:30
niteshandClaude Sonnet 5 a1a50ab8a3 chore: update version stamp
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 13:10:52 +05:30
niteshandClaude Sonnet 5 858c277ae3 chore: update version stamp
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 12:17:49 +05:30
Anshul Rathore a5124ca3e8 Merge dev into demo 2026-08-06 10:46:08 +05:30
Anshul Rathore 1a33f9d6ce Merge remote-tracking branch 'origin/dev' into dev
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
2026-08-06 10:45:10 +05:30
Anshul Rathore 983c613b8e feat: update changes 2026-08-06 10:41:28 +05:30
nitesh ddb215decf Merge branch 'dev' of https://git.lincpay.tech/CBSNexora/cbsnexora into dev
Publish Image in Docker Hub / build (push) Canceled after 0s
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
2026-08-05 18:33:57 +05:30
niteshandClaude Sonnet 5 2cc8d979ba fix(shares): fix popup fixed-position bug, add Client Compliance card
backdrop-filter on the list-embedded review popup was making it a new
containing block for its own nested position:fixed dialogs (Validate,
Reject, Confirm-Submit), confining them to the popup's box instead of the
viewport — replaced the blur with a plain darker overlay. Also adds a
permanent Client Compliance card (KYC status, risk category, open AML
alerts) on the review screen so a manager can see it without opening the
Validate popup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 18:32:48 +05:30
keshav 569f17ef46 kyc exception at client creation time and some tds work
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
2026-08-05 17:23:56 +05:30
niteshandClaude Sonnet 5 cc5cd4c6ab feat(shares): add Share Withdrawal maker-checker workflow with GL settlement
Tests / testRigor (push) Canceled after 0s
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Clerk submits a governed share redemption request (shares, Board Resolution
No., Meeting Date, settlement Payment Type); Bank Manager reviews with a
live backend-validated checklist (account/GL state, not just submit-time
snapshot) before approving, which redeems the shares and settles the
linked savings account in one transaction. Adds a Passbook tab to the
Share Account view, a reusable search-select control, and a Savings
Product "Map Payment Types to Specific Funds" fund-source override (only
Loan Products had this before) so settlement can be GL-mapped correctly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 16:55:21 +05:30
nitesh 8c7d8287f9 Merge branch 'uate' into dev — sync branches to same level
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
2026-08-04 11:35:08 +05:30
keshav 504d7ee7f6 Merge branch 'demo' of https://git.lincpay.tech/CBSNexora/cbsnexora into demo 2026-08-03 10:51:53 +05:30
keshav 26091c68b0 fix(accounting): prioritize specific Fineract error messages when AML marking fails 2026-08-03 10:51:07 +05:30
niteshandClaude Sonnet 5 f8c532f74a fix(tasks): wrap Approval Center tabs instead of scrolling behind pagination arrows
Replace mat-tab-nav-bar with a plain wrapping pill nav so all 16 approval sections stay
visible and one click away, instead of being hidden behind Material's prev/next scroll
arrows on a single-row strip.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 10:36:13 +05:30
keshav 44b41e504a Merge branch 'demo' of https://git.lincpay.tech/CBSNexora/cbsnexora into demo 2026-08-03 10:32:31 +05:30
keshav e8d91c8f60 feat(accounting): add AML manual referral and marking to general ledger entries 2026-08-03 10:31:32 +05:30
nitesh 8dd9767bdf Merge branch 'demo' of https://git.lincpay.tech/CBSNexora/cbsnexora into demo 2026-08-02 21:48:25 +05:30
niteshandClaude Sonnet 5 c868beb344 chore(npa): rename NPA Interest report/menu item to NPA Report
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 21:44:46 +05:30
keshav b43ed87771 risk review report with on which aml rule 2026-08-02 21:40:34 +05:30
niteshandClaude Sonnet 5 ebe67cc410 feat(investment,npa): RBI-grounded targets, GL-tracked interest posting, date/search pickers
- Correct Investment portfolio allocation and Loan Origination product-mix targets against
  RBI's UCB SLR/Non-SLR framework and PSL circulars, with a Target Basis column distinguishing
  RBI-mandated vs board-policy rows (Excel export included).
- Expand Investment holdings dataset with more near-term maturities so the Due-Date Diary
  reflects a realistic spread across FD/Govt Security/Non-SLR/Bond.
- Add From/To period-based Interest Calculation & Post (with GL selection for both posting and
  receiving) across the Investment Interest module, FD Entry and Bond Entry screens; fix a bug
  where a posted period kept re-appearing as due since accrual wasn't tracked per-holding.
- Add a reusable DD-MM-YYYY date-picker component and roll it out across all Investment date
  fields.
- NPA Summary Report: drop SMA-0/1/2 from the stage-wise table (kept in Day-wise Overdue only).
- NPA Recovery Report: add Account Type / NPA Category / Target-vs-Actual columns, a shared
  Account Type filter driving both the account picker and the register, and a searchable
  (fixed-position, not clipped by card overflow) account select for long lists.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 19:26:19 +05:30
nitesh d7f5bfe5f5 feat(fiu): add Head Office / Branch-wise scoping; remove reconciliation nav dots
FIU: adds a branch selector (Head Office sees all/drills into one branch,
a branch-level login is locked to its own branch) scoping the CTR/STR tabs,
dashboard KPIs and header. A FIU_BRANCH_CODE_TO_CANONICAL map bridges the
module's internal FINnet branch codes to the app-wide BranchMaster codes.
Live backend-driven AML alerts/cases are unchanged (no branch field to scope by).

Shell: removes the "working nav" green-dot indicator from the Reconciliation
mega-menu's 17 report links per request.
2026-08-02 16:06:03 +05:30
Anshul Rathore a982083b14 chore: stop tracking auto-generated .env.ts file 2026-07-31 15:08:35 +05:30
44 changed files with 7313 additions and 737 deletions
@@ -0,0 +1,70 @@
<!--
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/.
-->
<h2 class="mk-title" [class.mk-title--str]="isEscalation">
<fa-icon [icon]="isEscalation ? 'exclamation-triangle' : 'check-circle'"></fa-icon>
{{ isEscalation ? 'Mark as Suspicious' : 'Mark as Whitelisted' }}
</h2>
<mat-dialog-content>
<div class="mk-entry">
<div>
<label>Journal entry</label><span class="mono">#{{ data.journalEntryId }}</span>
</div>
<div>
<label>Transaction</label><span class="mono">{{ data.transactionId }}</span>
</div>
<div>
<label>Date</label><span>{{ data.transactionDate }}</span>
</div>
<div>
<label>Amount</label><span class="mono">&#8377;{{ data.amount | number: '1.2-2' }}</span>
</div>
<div class="wide">
<label>GL account</label><span>{{ data.glAccountName }}</span>
</div>
</div>
@if (isEscalation) {
<p class="mk-warn">
<fa-icon icon="exclamation-triangle"></fa-icon>
This raises an AML alert against the customer behind this entry and escalates it. An STR case opens for the
Principal Officer with the seven working day FIU-IND clock running. It cannot be undone from this screen.
</p>
} @else {
<p class="mk-note">
Records the entry as reviewed and cleared. No report is filed and the customer's risk rating is untouched.
</p>
}
<label class="mk-field">
<span>Reviewer notes {{ isEscalation ? '(required)' : '(optional)' }}</span>
<textarea
rows="4"
[(ngModel)]="notes"
[placeholder]="
isEscalation
? 'Why is this entry suspicious? What did you check?'
: 'What satisfied you that this entry is in order?'
"
></textarea>
</label>
</mat-dialog-content>
<mat-dialog-actions class="mk-actions">
<button class="mk-btn" (click)="dialogRef.close()">Cancel</button>
<button
class="mk-btn"
[class.mk-btn--str]="isEscalation"
[class.mk-btn--ok]="!isEscalation"
[disabled]="!canSubmit"
(click)="confirm()"
>
{{ isEscalation ? 'Escalate to STR' : 'Whitelist entry' }}
</button>
</mat-dialog-actions>
@@ -0,0 +1,165 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
:host {
--mk-g100: #f1f5f9;
--mk-g200: #e2e8f0;
--mk-g300: #cbd5e1;
--mk-g500: #64748b;
--mk-g700: #334155;
--mk-g900: #0f172a;
--mk-green: #15803d;
--mk-red: #c0392b;
--mk-amber-l: #fef3c7;
display: block;
font-family: 'DM Sans', system-ui, sans-serif;
color: var(--mk-g900);
min-width: 460px;
}
.mk-title {
display: flex;
align-items: center;
gap: 9px;
margin: 0;
padding: 18px 22px 10px;
font-size: 16px;
font-weight: 700;
color: var(--mk-green);
&--str {
color: var(--mk-red);
}
}
.mk-entry {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px 18px;
padding: 11px 14px;
background: #fff;
border: 1px solid var(--mk-g200);
border-radius: 8px;
margin-bottom: 12px;
> div {
display: flex;
flex-direction: column;
}
.wide {
grid-column: span 2;
}
label {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--mk-g500);
}
span {
font-size: 12.5px;
}
}
.mk-warn {
display: flex;
align-items: baseline;
gap: 8px;
font-size: 12px;
color: #78350f;
background: var(--mk-amber-l);
border-radius: 7px;
padding: 9px 12px;
margin: 0 0 12px;
}
.mk-note {
font-size: 12px;
color: var(--mk-g500);
margin: 0 0 12px;
}
.mk-field {
display: flex;
flex-direction: column;
gap: 4px;
span {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--mk-g500);
}
textarea {
padding: 9px 11px;
border: 1px solid var(--mk-g300);
border-radius: 7px;
font-size: 13px;
font-family: inherit;
resize: vertical;
&:focus {
outline: none;
border-color: var(--mk-g700);
}
}
}
.mk-actions {
display: flex;
justify-content: flex-end;
gap: 9px;
padding: 8px 22px 18px;
}
.mk-btn {
padding: 8px 16px;
border: 1px solid var(--mk-g300);
background: #fff;
border-radius: 8px;
font-size: 12.5px;
font-weight: 600;
color: var(--mk-g700);
cursor: pointer;
font-family: inherit;
&:hover:not(:disabled) {
background: var(--mk-g100);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&--ok {
background: var(--mk-green);
border-color: var(--mk-green);
color: #fff;
&:hover:not(:disabled) {
background: #12692f;
}
}
&--str {
background: var(--mk-red);
border-color: var(--mk-red);
color: #fff;
&:hover:not(:disabled) {
background: #a5301f;
}
}
}
@@ -0,0 +1,63 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatDialogRef, MAT_DIALOG_DATA, MatDialogContent, MatDialogActions } from '@angular/material/dialog';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
export interface MarkEntryDialogData {
disposition: 'WHITELIST' | 'ESCALATE';
journalEntryId: number;
transactionId: string;
amount: number;
glAccountName: string;
transactionDate: string;
}
/**
* Captures the reviewer's reason before a ledger entry is marked.
*
* Notes are not optional on an escalation: the Principal Officer picks the case up
* with nothing else to go on, and an STR filed on an unexplained referral is not
* defensible. A whitelist may be recorded without them.
*/
@Component({
selector: 'mifosx-mark-entry-dialog',
templateUrl: './mark-entry-dialog.component.html',
styleUrls: ['./mark-entry-dialog.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule,
FaIconComponent,
MatDialogContent,
MatDialogActions
]
})
export class MarkEntryDialogComponent {
dialogRef = inject<MatDialogRef<MarkEntryDialogComponent>>(MatDialogRef);
data = inject<MarkEntryDialogData>(MAT_DIALOG_DATA);
notes = '';
get isEscalation(): boolean {
return this.data.disposition === 'ESCALATE';
}
get canSubmit(): boolean {
return !this.isEscalation || this.notes.trim().length > 0;
}
confirm(): void {
if (this.canSubmit) {
this.dialogRef.close(this.notes.trim());
}
}
}
@@ -535,6 +535,14 @@
</div> </div>
</div> </div>
@if (markError) {
<div class="gl-mark-error">
<fa-icon icon="exclamation-triangle"></fa-icon>
<span>{{ markError }}</span>
<button class="gl-mark-error__close" (click)="markError = ''">&times;</button>
</div>
}
<div class="gl-table-shell"> <div class="gl-table-shell">
<mat-table [dataSource]="dataSource" matSort class="gl-mat-table"> <mat-table [dataSource]="dataSource" matSort class="gl-mat-table">
<!-- #ID --> <!-- #ID -->
@@ -692,6 +700,50 @@
</mat-cell> </mat-cell>
</ng-container> </ng-container>
<!-- AML review — whitelist or refer the entry to STR -->
<ng-container matColumnDef="amlMark">
<mat-header-cell *matHeaderCellDef class="gl-th">AML Review</mat-header-cell>
<mat-cell *matCellDef="let e" class="gl-td">
@if (markOf(e); as mark) {
<span
class="gl-mark"
[class.gl-mark--str]="mark.disposition === 'ESCALATE'"
[matTooltip]="
(mark.disposition === 'ESCALATE'
? 'Escalated to STR' + (mark.strCaseId ? ' — case #' + mark.strCaseId : '')
: 'Whitelisted') +
(mark.reviewedBy ? ' by ' + mark.reviewedBy : '') +
(mark.notes ? ' — ' + mark.notes : '')
"
>
<fa-icon [icon]="mark.disposition === 'ESCALATE' ? 'exclamation-triangle' : 'check-circle'"></fa-icon>
{{ mark.disposition === 'ESCALATE' ? 'Suspicious' : 'Whitelisted' }}
</span>
} @else if (isMarking(e)) {
<span class="gl-mark gl-mark--busy">Saving…</span>
} @else if (canMark(e)) {
<span class="gl-mark-actions">
<button
class="gl-mark-btn gl-mark-btn--ok"
matTooltip="Reviewed and in order — clear this entry"
(click)="openMarkDialog(e, 'WHITELIST')"
>
<fa-icon icon="check-circle"></fa-icon> Whitelist
</button>
<button
class="gl-mark-btn gl-mark-btn--str"
matTooltip="Refer to the Principal Officer as an STR case"
(click)="openMarkDialog(e, 'ESCALATE')"
>
<fa-icon icon="exclamation-triangle"></fa-icon> Suspicious
</button>
</span>
} @else {
<span class="gl-mark gl-mark--na" [matTooltip]="markBlockedReason(e)">—</span>
}
</mat-cell>
</ng-container>
<!-- Actions --> <!-- Actions -->
<ng-container matColumnDef="actions"> <ng-container matColumnDef="actions">
<mat-header-cell *matHeaderCellDef class="gl-th"></mat-header-cell> <mat-header-cell *matHeaderCellDef class="gl-th"></mat-header-cell>
@@ -1328,6 +1328,17 @@
flex: 0 0 130px; flex: 0 0 130px;
} }
// Wide enough to hold both action buttons on one line. Every other column
// declares a width; without one this collapsed and clipped its contents.
::ng-deep .mat-column-amlMark {
width: 184px;
min-width: 184px;
flex: 0 0 184px;
// Keeps the "AML REVIEW" heading on one line instead of clipping it.
white-space: nowrap;
overflow: visible;
}
::ng-deep .mat-column-actions { ::ng-deep .mat-column-actions {
width: 50px; width: 50px;
min-width: 50px; min-width: 50px;
@@ -1556,6 +1567,112 @@
} }
} }
// ── AML review column ─────────────────────────────────────────────────────────
// Never wrap — a wrapped second button used to be clipped by the row height.
.gl-mark-actions {
display: flex;
align-items: center;
gap: 5px;
flex-wrap: nowrap;
width: 100%;
}
.gl-mark-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 4px 8px;
border: 1px solid var(--c-border);
background: var(--c-surface);
border-radius: 6px;
font-size: 0.62rem;
font-weight: 700;
line-height: 1.2;
color: var(--c-text-2);
cursor: pointer;
font-family: inherit;
white-space: nowrap;
flex: 1 1 auto;
transition: all var(--transition);
// Tinted at rest, not only on hover — these have to be findable in a dense
// table without the reviewer hunting for them.
&--ok {
border-color: rgb(16 185 129 / 45%);
background: var(--c-cr-bg);
color: #047857;
&:hover {
border-color: var(--c-cr);
background: #d1fae5;
}
}
&--str {
border-color: rgb(239 68 68 / 45%);
background: var(--c-dr-bg);
color: #b91c1c;
&:hover {
border-color: var(--c-dr);
background: #fee2e2;
}
}
}
.gl-mark {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 9px;
border-radius: 999px;
font-size: 0.62rem;
font-weight: 700;
white-space: nowrap;
background: var(--c-cr-bg);
color: #047857;
&--str {
background: var(--c-dr-bg);
color: #b91c1c;
}
&--busy {
background: #f1f5f9;
color: var(--c-text-3);
}
&--na {
background: transparent;
color: var(--c-text-3);
cursor: help;
}
}
.gl-mark-error {
display: flex;
align-items: center;
gap: 9px;
margin: 0 0 10px;
padding: 9px 14px;
border: 1px solid #fecaca;
background: var(--c-dr-bg);
border-radius: 8px;
font-size: 0.75rem;
color: #b91c1c;
&__close {
margin-left: auto;
border: 0;
background: transparent;
font-size: 1.1rem;
line-height: 1;
color: inherit;
cursor: pointer;
}
}
// ── Empty state ─────────────────────────────────────────────────────────────── // ── Empty state ───────────────────────────────────────────────────────────────
.gl-empty { .gl-empty {
display: flex; display: flex;
@@ -41,9 +41,20 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { MatTooltipModule } from '@angular/material/tooltip'; import { MatTooltipModule } from '@angular/material/tooltip';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { MatDialog } from '@angular/material/dialog';
import { AmlService } from 'app/aml/aml.service';
import { MarkEntryDialogComponent, MarkEntryDialogData } from './mark-entry-dialog.component';
const STATE_KEY = 'gl_ledger_filter_state'; const STATE_KEY = 'gl_ledger_filter_state';
/** How a ledger entry has been marked by a reviewer, keyed on journal entry id. */
interface EntryMark {
disposition: 'WHITELIST' | 'ESCALATE';
strCaseId: number | null;
reviewedBy: string;
notes: string;
}
@Component({ @Component({
selector: 'mifosx-search-journal-entry', selector: 'mifosx-search-journal-entry',
standalone: true, standalone: true,
@@ -87,6 +98,8 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView
private settingsService = inject(SettingsService); private settingsService = inject(SettingsService);
private dateUtils = inject(Dates); private dateUtils = inject(Dates);
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
private amlService = inject(AmlService);
private dialog = inject(MatDialog);
// ── Misc ────────────────────────────────────────────────────────────────── // ── Misc ──────────────────────────────────────────────────────────────────
minDate = new Date(2000, 0, 1); minDate = new Date(2000, 0, 1);
@@ -129,6 +142,7 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView
'manualEntry', 'manualEntry',
'createdByUserName', 'createdByUserName',
'submittedOnDate', 'submittedOnDate',
'amlMark',
'actions' 'actions'
]; ];
dayWiseColumns = [ dayWiseColumns = [
@@ -157,6 +171,14 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView
dayWiseData: any[] = []; dayWiseData: any[] = [];
glAccountInfo: any = null; glAccountInfo: any = null;
// ── AML marking ───────────────────────────────────────────────────────────
/** Marks already recorded, by journal entry id. */
private marks = new Map<number, EntryMark>();
/** Entry ids with a request in flight, so a row cannot be double-submitted. */
private marking = new Set<number>();
markError = '';
// ── UI state ────────────────────────────────────────────────────────────── // ── UI state ──────────────────────────────────────────────────────────────
activeQuickFilter = ''; activeQuickFilter = '';
activeFiltersCount = 0; activeFiltersCount = 0;
@@ -212,6 +234,103 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView
this.setFilteredOffices(); this.setFilteredOffices();
this.getJournalEntries(); this.getJournalEntries();
this.loadLedgerSummary(); this.loadLedgerSummary();
this.loadMarks();
}
// ── AML marking ───────────────────────────────────────────────────────────
/**
* Marks are loaded once for the whole ledger rather than per page, because the
* user pages and re-filters constantly and the set is small — only entries a
* reviewer has actually touched.
*/
private loadMarks(): void {
this.amlService.getManualReferrals().subscribe({
next: (rows) => {
this.marks = new Map(
(rows || []).map((r: any) => [
r.journalEntryId,
{
disposition: r.disposition === 'ESCALATE' ? 'ESCALATE' : 'WHITELIST',
strCaseId: r.strCaseId ?? null,
reviewedBy: r.reviewedByUsername || '',
notes: r.reviewNotes || ''
} as EntryMark
])
);
},
// A ledger that cannot reach the AML service still has to render.
error: () => (this.marks = new Map())
});
}
markOf(entry: any): EntryMark | null {
return this.marks.get(entry?.id) ?? null;
}
isMarking(entry: any): boolean {
return this.marking.has(entry?.id);
}
/**
* An alert needs a customer, and a journal entry only reaches one through the
* savings, loan or client transaction that produced it. A purely manual GL
* posting reaches none, so it cannot be referred — the server enforces this too.
*/
canMark(entry: any): boolean {
return !!entry && !this.markOf(entry) && !!(entry.savingsAccountNo || entry.entityId);
}
markBlockedReason(entry: any): string {
if (this.markOf(entry)) {
return 'Already reviewed — a ledger entry is marked once';
}
return 'No customer behind this entry, so it cannot be referred';
}
openMarkDialog(entry: any, disposition: 'WHITELIST' | 'ESCALATE'): void {
if (!this.canMark(entry) || this.isMarking(entry)) {
return;
}
const data: MarkEntryDialogData = {
disposition,
journalEntryId: entry.id,
transactionId: entry.transactionId,
amount: entry.amount,
glAccountName: `${entry.glAccountCode} — ${entry.glAccountName}`,
transactionDate: this.dateUtils.formatDate(entry.transactionDate, this.settingsService.dateFormat)
};
this.dialog
.open(MarkEntryDialogComponent, { data, width: '520px', autoFocus: false })
.afterClosed()
.subscribe((notes: string | undefined) => {
if (notes !== undefined) {
this.submitMark(entry, disposition, notes);
}
});
}
private submitMark(entry: any, disposition: 'WHITELIST' | 'ESCALATE', notes: string): void {
this.markError = '';
this.marking.add(entry.id);
this.amlService.referJournalEntry(entry.id, disposition, notes).subscribe({
next: (res: any) => {
this.marking.delete(entry.id);
this.marks.set(entry.id, {
disposition,
strCaseId: res?.strCaseId ?? null,
reviewedBy: res?.reviewedByUsername || '',
notes
});
},
error: (err: any) => {
this.marking.delete(entry.id);
this.markError =
err?.error?.defaultUserMessage || err?.error?.errors?.[0]?.defaultUserMessage || 'Could not mark this entry.';
}
});
} }
ngOnDestroy() { ngOnDestroy() {
+22
View File
@@ -34,6 +34,28 @@ export class AmlService {
return this.http.post(`/aml/alerts/${alertId}/review`, { disposition, notes }); return this.http.post(`/aml/alerts/${alertId}/review`, { disposition, notes });
} }
// ═══ Manual referrals from the general ledger ═══
/**
* Ledger entries a user has already marked, keyed on `journalEntryId`.
*
* Separate from `getAlerts()` because these are not engine alerts — they carry the
* journal entry id rather than a savings or loan transaction id, so the ledger
* screen can match them to the rows it is showing.
*/
getManualReferrals(): Observable<any[]> {
return this.http.get<any[]>('/aml/alerts/manual-referrals');
}
/**
* Marks a ledger entry. WHITELIST clears it with no report; ESCALATE opens an STR
* case for the Principal Officer, the same road an escalated engine alert takes.
* The entry must have a customer behind it or the server rejects it.
*/
referJournalEntry(journalEntryId: number, disposition: 'WHITELIST' | 'ESCALATE', notes: string): Observable<any> {
return this.http.post('/aml/alerts/manual-referral', { journalEntryId, disposition, notes });
}
// ═══ STR cases (Principal Officer) ═══ // ═══ STR cases (Principal Officer) ═══
getStrCases(): Observable<any[]> { getStrCases(): Observable<any[]> {
+29 -18
View File
@@ -591,6 +591,9 @@
<button mat-menu-item routerLink="/reports/form-97-98" tabindex="0"> <button mat-menu-item routerLink="/reports/form-97-98" tabindex="0">
<i class="fa fa-file-invoice menu-item-icon"></i> Form 97/98 Transaction Report <i class="fa fa-file-invoice menu-item-icon"></i> Form 97/98 Transaction Report
</button> </button>
<button mat-menu-item routerLink="/reports/risk-review" tabindex="0">
<i class="fa fa-user-shield menu-item-icon"></i> Risk Review Report
</button>
</mat-menu> </mat-menu>
<!-- Admin Mega Menu — 3-column rectangular panel grouping Setup, <!-- Admin Mega Menu — 3-column rectangular panel grouping Setup,
@@ -1279,7 +1282,7 @@
<div class="deposit-menu-group"> <div class="deposit-menu-group">
<div class="deposit-group-title">NPA Reports</div> <div class="deposit-group-title">NPA Reports</div>
<button mat-menu-item tabindex="0" routerLink="/npa/reports/interest"> <button mat-menu-item tabindex="0" routerLink="/npa/reports/interest">
<fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> NPA Interest <fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> NPA Report
</button> </button>
<button mat-menu-item tabindex="0" routerLink="/npa/reports/status"> <button mat-menu-item tabindex="0" routerLink="/npa/reports/status">
<fa-icon icon="file-alt" class="menu-item-icon"></fa-icon> NPA Status Report <fa-icon icon="file-alt" class="menu-item-icon"></fa-icon> NPA Status Report
@@ -1400,27 +1403,27 @@
<!-- Core / Books --> <!-- Core / Books -->
<div class="deposit-menu-group"> <div class="deposit-menu-group">
<div class="deposit-group-title">Core &amp; Books</div> <div class="deposit-group-title">Core &amp; Books</div>
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/voucher"> <button mat-menu-item tabindex="0" routerLink="/reconciliation/voucher">
<fa-icon icon="receipt" class="menu-item-icon" style="color: #0d9488"></fa-icon> <fa-icon icon="receipt" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Voucher Reconciliation Voucher Reconciliation
</button> </button>
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/sundry-dr-cr"> <button mat-menu-item tabindex="0" routerLink="/reconciliation/sundry-dr-cr">
<fa-icon icon="random" class="menu-item-icon" style="color: #0d9488"></fa-icon> <fa-icon icon="random" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Sundry Dr / Cr Reconciliation Sundry Dr / Cr Reconciliation
</button> </button>
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/pnl-impact"> <button mat-menu-item tabindex="0" routerLink="/reconciliation/pnl-impact">
<fa-icon icon="chart-line" class="menu-item-icon" style="color: #0d9488"></fa-icon> <fa-icon icon="chart-line" class="menu-item-icon" style="color: #0d9488"></fa-icon>
P&amp;L Impact Reconciliation P&amp;L Impact Reconciliation
</button> </button>
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/gl-reconciliation"> <button mat-menu-item tabindex="0" routerLink="/reconciliation/gl-reconciliation">
<fa-icon icon="book-open" class="menu-item-icon" style="color: #0d9488"></fa-icon> <fa-icon icon="book-open" class="menu-item-icon" style="color: #0d9488"></fa-icon>
GL Reconciliation GL Reconciliation
</button> </button>
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/fixed-assets"> <button mat-menu-item tabindex="0" routerLink="/reconciliation/fixed-assets">
<fa-icon icon="cube" class="menu-item-icon" style="color: #0d9488"></fa-icon> <fa-icon icon="cube" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Fixed Assets Reconciliation Fixed Assets Reconciliation
</button> </button>
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/inter-company"> <button mat-menu-item tabindex="0" routerLink="/reconciliation/inter-company">
<fa-icon icon="sitemap" class="menu-item-icon" style="color: #0d9488"></fa-icon> <fa-icon icon="sitemap" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Inter-Company Reconciliation Inter-Company Reconciliation
</button> </button>
@@ -1429,19 +1432,19 @@
<!-- Bank & Clearing --> <!-- Bank & Clearing -->
<div class="deposit-menu-group"> <div class="deposit-menu-group">
<div class="deposit-group-title">Bank &amp; Clearing</div> <div class="deposit-group-title">Bank &amp; Clearing</div>
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/bank-reco"> <button mat-menu-item tabindex="0" routerLink="/reconciliation/bank-reco">
<fa-icon icon="university" class="menu-item-icon" style="color: #0d9488"></fa-icon> <fa-icon icon="university" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Bank Reconciliation Bank Reconciliation
</button> </button>
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/api-bank-reco"> <button mat-menu-item tabindex="0" routerLink="/reconciliation/api-bank-reco">
<fa-icon icon="code-branch" class="menu-item-icon" style="color: #0d9488"></fa-icon> <fa-icon icon="code-branch" class="menu-item-icon" style="color: #0d9488"></fa-icon>
API Based Bank Reconciliation API Based Bank Reconciliation
</button> </button>
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/neft-rtgs"> <button mat-menu-item tabindex="0" routerLink="/reconciliation/neft-rtgs">
<fa-icon icon="exchange" class="menu-item-icon" style="color: #0d9488"></fa-icon> <fa-icon icon="exchange" class="menu-item-icon" style="color: #0d9488"></fa-icon>
NEFT / RTGS Reconciliation NEFT / RTGS Reconciliation
</button> </button>
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/clearing-reco"> <button mat-menu-item tabindex="0" routerLink="/reconciliation/clearing-reco">
<fa-icon icon="hand-holding-usd" class="menu-item-icon" style="color: #0d9488"></fa-icon> <fa-icon icon="hand-holding-usd" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Clearing Reconciliation Clearing Reconciliation
</button> </button>
@@ -1450,15 +1453,15 @@
<!-- Digital / Payments --> <!-- Digital / Payments -->
<div class="deposit-menu-group"> <div class="deposit-menu-group">
<div class="deposit-group-title">Digital &amp; Payments</div> <div class="deposit-group-title">Digital &amp; Payments</div>
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/udir"> <button mat-menu-item tabindex="0" routerLink="/reconciliation/udir">
<fa-icon icon="mobile-alt" class="menu-item-icon" style="color: #0d9488"></fa-icon> <fa-icon icon="mobile-alt" class="menu-item-icon" style="color: #0d9488"></fa-icon>
UDIR (IMPS / UPI / ATM) UDIR (IMPS / UPI / ATM)
</button> </button>
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/three-way-digital"> <button mat-menu-item tabindex="0" routerLink="/reconciliation/three-way-digital">
<fa-icon icon="project-diagram" class="menu-item-icon" style="color: #0d9488"></fa-icon> <fa-icon icon="project-diagram" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Three-way Digital Reconciliation Three-way Digital Reconciliation
</button> </button>
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/nach-reco"> <button mat-menu-item tabindex="0" routerLink="/reconciliation/nach-reco">
<fa-icon icon="sync-alt" class="menu-item-icon" style="color: #0d9488"></fa-icon> <fa-icon icon="sync-alt" class="menu-item-icon" style="color: #0d9488"></fa-icon>
NACH Reconciliation NACH Reconciliation
</button> </button>
@@ -1467,11 +1470,11 @@
<!-- Instruments & Members --> <!-- Instruments & Members -->
<div class="deposit-menu-group"> <div class="deposit-menu-group">
<div class="deposit-group-title">Instruments &amp; Members</div> <div class="deposit-group-title">Instruments &amp; Members</div>
<button mat-menu-item [routerLink]="['/sponsor-dd-reconciliation']" tabindex="0" class="menu-item-live"> <button mat-menu-item [routerLink]="['/sponsor-dd-reconciliation']" tabindex="0">
<fa-icon icon="money-check-alt" class="menu-item-icon" style="color: #0d9488"></fa-icon> <fa-icon icon="money-check-alt" class="menu-item-icon" style="color: #0d9488"></fa-icon>
DD / Pay Order Reconciliation DD / Pay Order Reconciliation
</button> </button>
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/share-member"> <button mat-menu-item tabindex="0" routerLink="/reconciliation/share-member">
<fa-icon icon="coins" class="menu-item-icon" style="color: #0d9488"></fa-icon> <fa-icon icon="coins" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Share Member Reconciliation Share Member Reconciliation
</button> </button>
@@ -1480,11 +1483,11 @@
<!-- Compliance & Financials --> <!-- Compliance & Financials -->
<div class="deposit-menu-group"> <div class="deposit-menu-group">
<div class="deposit-group-title">Compliance &amp; Financials</div> <div class="deposit-group-title">Compliance &amp; Financials</div>
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/invoice-payment"> <button mat-menu-item tabindex="0" routerLink="/reconciliation/invoice-payment">
<fa-icon icon="file-invoice" class="menu-item-icon" style="color: #0d9488"></fa-icon> <fa-icon icon="file-invoice" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Invoice Payment Invoice Payment
</button> </button>
<button mat-menu-item tabindex="0" class="menu-item-live" routerLink="/reconciliation/tax-reco"> <button mat-menu-item tabindex="0" routerLink="/reconciliation/tax-reco">
<fa-icon icon="percentage" class="menu-item-icon" style="color: #0d9488"></fa-icon> <fa-icon icon="percentage" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Tax Reconciliation Tax Reconciliation
</button> </button>
@@ -1803,6 +1806,14 @@
> >
<fa-icon icon="shield-alt" class="gl-icon"></fa-icon> AML <fa-icon icon="shield-alt" class="gl-icon"></fa-icon> AML
</a> </a>
<a
class="gl-link"
[class.hidden]="!matchesRbiSearch('Risk Review Report')"
routerLink="/reports/risk-review"
(click)="rbiMenuOpen = false"
>
<fa-icon icon="user-shield" class="gl-icon"></fa-icon> Risk Review Report
</a>
</div> </div>
<!-- OSS Reports --> <!-- OSS Reports -->
<div class="gl-group"> <div class="gl-group">
+333
View File
@@ -6,6 +6,10 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. * file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/ */
import { BRANCHES, BranchMaster } from '../npa/npa-data';
export { BRANCHES, BranchMaster };
/** /**
* Sample FIU-IND data — schema mirrors the FINnet 2.0 CSV submission templates * Sample FIU-IND data — schema mirrors the FINnet 2.0 CSV submission templates
* (TC1, TC2, KC1, KC2, Account_Detail, Account_Person_Relation). * (TC1, TC2, KC1, KC2, Account_Detail, Account_Person_Relation).
@@ -155,6 +159,25 @@ export const FIU_BANK = {
cbwtrThreshold: 500000 // CBWTR threshold ₹5 L cbwtrThreshold: 500000 // CBWTR threshold ₹5 L
}; };
/**
* CTR/STR records below use the bank's own internal numeric branch codes (as FINnet's TC2
* schema expects), not the app-wide BranchMaster codes (BR01, BR02, ...) every other module
* scopes Head-Office-vs-branch access by. This maps one to the other so a branch-level login's
* officeName can be matched consistently, the same way NPA/Reconciliation/Loan Origination do.
*/
export const FIU_BRANCH_CODE_TO_CANONICAL: Record<string, string> = {
'1': 'BR01',
'2': 'BR02',
'3': 'BR03',
'4': 'BR04',
'5': 'BR05',
'6': 'BR06',
'8': 'BR07',
'9': 'BR08',
'10': 'BR02',
'12': 'BR04'
};
/** /**
* ── CTR transactions (FINnet TC2-style) ── * ── CTR transactions (FINnet TC2-style) ──
* Only CASH transactions that breach the RBI/PMLA reporting limit of ₹10,00,000 * Only CASH transactions that breach the RBI/PMLA reporting limit of ₹10,00,000
@@ -297,6 +320,316 @@ export const CTR_TXNS: CtrTransaction[] = [
} }
]; ];
/**
* ── TC1: ATM / card transactions ──
*
* TC2 carries what happened at the counter; TC1 carries what happened at the
* card rails. Both feed the same CTR, because an ATM cash withdrawal is still
* a cash transaction and counts towards the customer's monthly aggregate.
*
* Every row here belongs to an account that is already in CTR_TXNS and carries
* the same reportRefNo, so the six FINnet files reconcile against each other.
*/
export interface CardTransaction {
reportType: ReportType;
reportRefNo: number;
branchCode: string;
accountNumber: string;
/** FINnet account-type code, same codes used in Account_Detail. */
accountType: string;
relationshipFlag: 'Customer' | 'Non-Customer';
/** Settled at the switch, or declined / reversed. */
txnStatus: 'Success' | 'Failed' | 'Reversed';
txnId: number;
txnDate: string;
txnTime: string;
depositOrWithdrawal: 'Deposit' | 'Withdrawal';
txnAmount: number;
cardType: 'RuPay Debit' | 'VISA Debit' | 'Mastercard Debit';
/** Masked to the last four digits — the PAN itself is never put in the file. */
cardNumber: string;
/** FINnet country code; 95 is India, as used across the KC files. */
cardIssuingCountry: string;
cardIssuingBank: string;
/** NFS / NPCI switch reference for the leg. */
switchTxnId: string;
atmId: string;
/** UI convenience, not part of the TC1 schema. */
customerName?: string;
}
export const TC1_TXNS: CardTransaction[] = [
// Ref 1 — ANAND KRISHNAN: routine ATM usage alongside a large counter deposit.
{
reportType: 'CTR',
reportRefNo: 1,
branchCode: '1',
accountNumber: '110001101000838',
accountType: '211',
relationshipFlag: 'Customer',
txnStatus: 'Success',
txnId: 9001,
txnDate: '06-01-2026',
txnTime: '19:42:11',
depositOrWithdrawal: 'Withdrawal',
txnAmount: 20000,
cardType: 'RuPay Debit',
cardNumber: 'XXXXXXXXXXXX4417',
cardIssuingCountry: '95',
cardIssuingBank: 'CBSNexora Bank Ltd',
switchTxnId: 'NFS26010600418',
atmId: 'NEXA0001',
customerName: 'ANAND KRISHNAN'
},
{
reportType: 'CTR',
reportRefNo: 1,
branchCode: '1',
accountNumber: '110001101000838',
accountType: '211',
relationshipFlag: 'Customer',
txnStatus: 'Success',
txnId: 9002,
txnDate: '18-01-2026',
txnTime: '11:07:53',
depositOrWithdrawal: 'Withdrawal',
txnAmount: 25000,
cardType: 'RuPay Debit',
cardNumber: 'XXXXXXXXXXXX4417',
cardIssuingCountry: '95',
cardIssuingBank: 'CBSNexora Bank Ltd',
switchTxnId: 'NFS26011800911',
atmId: 'NEXA0003',
customerName: 'ANAND KRISHNAN'
},
// Ref 2 — MEERA SUNDARAM: card cash-out at another bank's ATM.
{
reportType: 'CTR',
reportRefNo: 2,
branchCode: '3',
accountNumber: '110001204000712',
accountType: '215',
relationshipFlag: 'Customer',
txnStatus: 'Success',
txnId: 9003,
txnDate: '09-01-2026',
txnTime: '20:15:36',
depositOrWithdrawal: 'Withdrawal',
txnAmount: 10000,
cardType: 'VISA Debit',
cardNumber: 'XXXXXXXXXXXX8820',
cardIssuingCountry: '95',
cardIssuingBank: 'CBSNexora Bank Ltd',
switchTxnId: 'NFS26010901277',
atmId: 'SBIN004512',
customerName: 'MEERA SUNDARAM'
},
{
reportType: 'CTR',
reportRefNo: 2,
branchCode: '3',
accountNumber: '110001204000712',
accountType: '215',
relationshipFlag: 'Customer',
// Declined for insufficient balance. Failed legs are still reported: the
// attempt itself is part of the customer's pattern.
txnStatus: 'Failed',
txnId: 9004,
txnDate: '09-01-2026',
txnTime: '20:17:02',
depositOrWithdrawal: 'Withdrawal',
txnAmount: 20000,
cardType: 'VISA Debit',
cardNumber: 'XXXXXXXXXXXX8820',
cardIssuingCountry: '95',
cardIssuingBank: 'CBSNexora Bank Ltd',
switchTxnId: 'NFS26010901284',
atmId: 'SBIN004512',
customerName: 'MEERA SUNDARAM'
},
// Ref 3 — HARPREET SINGH BEDI: cash recycler deposit through the card.
{
reportType: 'CTR',
reportRefNo: 3,
branchCode: '6',
accountNumber: '110001307000455',
accountType: '215',
relationshipFlag: 'Customer',
txnStatus: 'Success',
txnId: 9005,
txnDate: '12-01-2026',
txnTime: '13:28:44',
depositOrWithdrawal: 'Deposit',
txnAmount: 49500,
cardType: 'RuPay Debit',
cardNumber: 'XXXXXXXXXXXX6193',
cardIssuingCountry: '95',
cardIssuingBank: 'CBSNexora Bank Ltd',
switchTxnId: 'NFS26011200630',
atmId: 'NEXA0002-CRM',
customerName: 'HARPREET SINGH BEDI'
},
// Ref 4 — FAROOQ ABDULLAH KHAN: repeated near-limit withdrawals in one day.
{
reportType: 'CTR',
reportRefNo: 4,
branchCode: '2',
accountNumber: '110001408000933',
accountType: '211',
relationshipFlag: 'Customer',
txnStatus: 'Success',
txnId: 9006,
txnDate: '16-01-2026',
txnTime: '09:03:19',
depositOrWithdrawal: 'Withdrawal',
txnAmount: 50000,
cardType: 'Mastercard Debit',
cardNumber: 'XXXXXXXXXXXX2075',
cardIssuingCountry: '95',
cardIssuingBank: 'CBSNexora Bank Ltd',
switchTxnId: 'NFS26011600142',
atmId: 'NEXA0004',
customerName: 'FAROOQ ABDULLAH KHAN'
},
{
reportType: 'CTR',
reportRefNo: 4,
branchCode: '2',
accountNumber: '110001408000933',
accountType: '211',
relationshipFlag: 'Customer',
txnStatus: 'Success',
txnId: 9007,
txnDate: '16-01-2026',
txnTime: '09:06:41',
depositOrWithdrawal: 'Withdrawal',
txnAmount: 50000,
cardType: 'Mastercard Debit',
cardNumber: 'XXXXXXXXXXXX2075',
cardIssuingCountry: '95',
cardIssuingBank: 'CBSNexora Bank Ltd',
switchTxnId: 'NFS26011600149',
atmId: 'NEXA0004',
customerName: 'FAROOQ ABDULLAH KHAN'
},
{
reportType: 'CTR',
reportRefNo: 4,
branchCode: '2',
accountNumber: '110001408000933',
accountType: '211',
relationshipFlag: 'Customer',
// Dispensed short and auto-reversed by the switch the same evening.
txnStatus: 'Reversed',
txnId: 9008,
txnDate: '16-01-2026',
txnTime: '09:09:05',
depositOrWithdrawal: 'Withdrawal',
txnAmount: 50000,
cardType: 'Mastercard Debit',
cardNumber: 'XXXXXXXXXXXX2075',
cardIssuingCountry: '95',
cardIssuingBank: 'CBSNexora Bank Ltd',
switchTxnId: 'NFS26011600155',
atmId: 'NEXA0004',
customerName: 'FAROOQ ABDULLAH KHAN'
},
// Ref 5 — DEEPIKA RANGANATHAN
{
reportType: 'CTR',
reportRefNo: 5,
branchCode: '9',
accountNumber: '110001509000268',
accountType: '215',
relationshipFlag: 'Customer',
txnStatus: 'Success',
txnId: 9009,
txnDate: '21-01-2026',
txnTime: '17:51:28',
depositOrWithdrawal: 'Withdrawal',
txnAmount: 15000,
cardType: 'RuPay Debit',
cardNumber: 'XXXXXXXXXXXX9354',
cardIssuingCountry: '95',
cardIssuingBank: 'CBSNexora Bank Ltd',
switchTxnId: 'NFS26012101508',
atmId: 'HDFC009871',
customerName: 'DEEPIKA RANGANATHAN'
},
// Ref 6 — RAJENDRA PRASAD YADAV
{
reportType: 'CTR',
reportRefNo: 6,
branchCode: '5',
accountNumber: '110001610000841',
accountType: '215',
relationshipFlag: 'Customer',
txnStatus: 'Success',
txnId: 9010,
txnDate: '23-01-2026',
txnTime: '10:34:12',
depositOrWithdrawal: 'Withdrawal',
txnAmount: 40000,
cardType: 'RuPay Debit',
cardNumber: 'XXXXXXXXXXXX1268',
cardIssuingCountry: '95',
cardIssuingBank: 'CBSNexora Bank Ltd',
switchTxnId: 'NFS26012300287',
atmId: 'NEXA0005',
customerName: 'RAJENDRA PRASAD YADAV'
},
{
reportType: 'CTR',
reportRefNo: 6,
branchCode: '5',
accountNumber: '110001610000841',
accountType: '215',
relationshipFlag: 'Customer',
txnStatus: 'Success',
txnId: 9011,
txnDate: '28-01-2026',
txnTime: '18:22:47',
depositOrWithdrawal: 'Withdrawal',
txnAmount: 35000,
cardType: 'RuPay Debit',
cardNumber: 'XXXXXXXXXXXX1268',
cardIssuingCountry: '95',
cardIssuingBank: 'CBSNexora Bank Ltd',
switchTxnId: 'NFS26012801733',
atmId: 'NEXA0005',
customerName: 'RAJENDRA PRASAD YADAV'
},
// Ref 7 — SHABANA TARIQ MIRZA: the account's card is used by a third party,
// which is why the relationship flag differs from the rest.
{
reportType: 'CTR',
reportRefNo: 7,
branchCode: '12',
accountNumber: '110001711000527',
accountType: '215',
relationshipFlag: 'Non-Customer',
txnStatus: 'Success',
txnId: 9012,
txnDate: '27-01-2026',
txnTime: '21:08:33',
depositOrWithdrawal: 'Withdrawal',
txnAmount: 30000,
cardType: 'VISA Debit',
cardNumber: 'XXXXXXXXXXXX7741',
cardIssuingCountry: '95',
cardIssuingBank: 'CBSNexora Bank Ltd',
switchTxnId: 'NFS26012702094',
atmId: 'ICIC007233',
customerName: 'SHABANA TARIQ MIRZA'
}
];
/** /**
* ── STR cases ── * ── STR cases ──
* Raised either by the AML rules engine or by an officer flagging a CTR row as * Raised either by the AML rules engine or by an officer flagging a CTR row as
+120 -253
View File
@@ -14,12 +14,25 @@
<div> <div>
<h2 class="fiu-head-t">Financial Intelligence Unit – India (FIU-IND)</h2> <h2 class="fiu-head-t">Financial Intelligence Unit – India (FIU-IND)</h2>
<div class="fiu-head-s"> <div class="fiu-head-s">
{{ bank.name }} &middot; {{ bank.branch }} &middot; Reporting Month <strong>{{ bank.reportingMonth }}</strong> {{ bank.name }} &middot; {{ scopeName }} &middot; Reporting Month <strong>{{ bank.reportingMonth }}</strong>
&middot; PMLA 2002 / FINnet 2.0 &middot; PMLA 2002 / FINnet 2.0
</div> </div>
</div> </div>
</div> </div>
<div class="fiu-head-meta"> <div class="fiu-head-meta">
<div>
<span class="fiu-meta-l">Branch</span>
@if (isHeadOffice) {
<select class="fiu-inp" [ngModel]="branchFilter" (ngModelChange)="setBranch($event)">
<option value="">All Branches</option>
@for (b of branches; track b.code) {
<option [value]="b.code">{{ b.name }}</option>
}
</select>
} @else {
<span class="fiu-meta-v">{{ scopeName }}</span>
}
</div>
<div> <div>
<span class="fiu-meta-l">Principal Officer</span><span class="fiu-meta-v">{{ bank.principalOfficer }}</span> <span class="fiu-meta-l">Principal Officer</span><span class="fiu-meta-v">{{ bank.principalOfficer }}</span>
</div> </div>
@@ -96,6 +109,52 @@
</div> </div>
</div> </div>
<!-- WHEN TO REPORT — the five PMLA obligations -->
<div class="ob-block">
<div class="ob-head">
<div>
<h3 class="ob-title"><fa-icon icon="gavel"></fa-icon> When to report to FIU-IND</h3>
<p class="ob-sub">
The five reports a reporting entity owes under the Prevention of Money Laundering Act. All are filed
electronically through the FINnet 2.0 portal.
</p>
</div>
</div>
<div class="ob-grid">
@for (o of obligations; track o.code) {
<div class="ob-card" [class.ob-card--off]="!o.handled">
<div class="ob-card-h">
<fa-icon [icon]="o.icon" class="ob-ic"></fa-icon>
<span class="ob-code">{{ o.code }}</span>
<span class="ob-name">{{ o.name }}</span>
@if (o.handled) {
<span class="ob-flag ob-flag--on">Filed here</span>
} @else {
<span class="ob-flag">Tracked</span>
}
</div>
<div class="ob-row">
<label>Report when</label><span>{{ o.trigger }}</span>
</div>
<div class="ob-row">
<label>Deadline</label><span class="ob-due">{{ o.deadline }}</span>
</div>
<div class="ob-note">{{ o.note }}</div>
@if (o.code === 'CTR') {
<button class="dc-btn" (click)="showTab('ctr')">
<fa-icon icon="external-link-alt"></fa-icon> Open CTR
</button>
} @else if (o.code === 'STR') {
<button class="dc-btn" (click)="showTab('str')">
<fa-icon icon="external-link-alt"></fa-icon> Open STR
</button>
}
</div>
}
</div>
</div>
<!-- Compliance cards --> <!-- Compliance cards -->
<div class="fiu-compliance"> <div class="fiu-compliance">
<div class="dc-card"> <div class="dc-card">
@@ -136,148 +195,23 @@
<!-- CTR --> <!-- CTR -->
@if (activeTab === 'ctr') { @if (activeTab === 'ctr') {
<div class="fiu-section"> <div class="fiu-section">
<!-- LIVE: cash-related CTR threshold alerts --> <!-- FINNET FILE SEQUENCE — the CTR submission, in prescribed order -->
<div class="seq-block">
<div class="fiu-form-meta"> <div class="fiu-form-meta">
<div class="fiu-form-title"> <div class="fiu-form-title">
<h3>Live CTR Threshold Alerts</h3> <h3>CTR Submission Files — FINnet 2.0</h3>
<p> <p>
Mechanical cash threshold breaches. CTR is filed regardless of outcome — where the source of funds cannot be The CTR is built in this order: the account is described first, then who is behind it, then the two
evidenced, send the transaction to STR for the Principal Officer to investigate. transaction files. Every grid below is the exact content of its CSV. The customer cards (KC1 / KC2) travel
with the same bundle and are worked on the Customer Cards tab.
</p> </p>
</div> </div>
<select class="fiu-inp" [(ngModel)]="amlAlertStatusFilter" (ngModelChange)="loadAmlAlerts()"> <button class="fiu-btn fiu-btn-xls" (click)="exportAll()">
<option value="">All statuses</option> <fa-icon icon="file-csv"></fa-icon> Download full bundle
<option value="RAISED">Raised</option>
<option value="UNDER_REVIEW">Under Review</option>
<option value="WHITELISTED">Whitelisted</option>
<option value="ESCALATED">Escalated</option>
</select>
</div>
<div class="fiu-tbl-wrap">
<table class="fiu-tbl">
<thead>
<tr>
<th>ID</th>
<th>Client</th>
<th>Type</th>
<th>Rule</th>
<th class="ta-r">Amount</th>
<th>Raised On</th>
<th>Status</th>
<th class="ta-c">Routes To</th>
<th>Notes</th>
<th>Action</th>
</tr>
</thead>
<tbody>
@for (alert of ctrAlerts; track alert.id) {
<tr>
<td>{{ alert.id }}</td>
<td>{{ alert.clientDisplayName }}</td>
<td>{{ alert.transactionType }}</td>
<td>{{ alert.ruleCode }} &mdash; {{ alert.ruleDescription }}</td>
<td class="ta-r mono">&#8377;{{ alert.amount | number: '1.0-0' }}</td>
<td>{{ alert.raisedOn | date: 'medium' }}</td>
<td>
<span [class]="amlStatusClass(alert.status)">{{ alert.status }}</span>
</td>
<td class="ta-c">
<span [class]="alertRouteClass(alert.status)">{{ alertRouteLabel(alert.status) }}</span>
</td>
<td>
@if (alert.status === 'RAISED' || alert.status === 'UNDER_REVIEW') {
<input
type="text"
class="fiu-inp"
placeholder="Review notes"
[(ngModel)]="amlReviewNotes[alert.id]"
/>
} @else {
<span class="muted">{{ alert.reviewNotes }}</span>
}
</td>
<td>
@if (alert.status === 'RAISED' || alert.status === 'UNDER_REVIEW') {
<div class="fiu-action-group" *mifosxHasPermission="'REVIEW_AML_ALERT'">
<button class="fiu-btn fiu-btn-xml" (click)="escalateAlert(alert)">Send to STR</button>
</div>
}
</td>
</tr>
} @empty {
<tr>
<td colspan="10" class="fiu-empty">No live alerts.</td>
</tr>
}
</tbody>
</table>
</div>
<p class="fiu-tbl-legend">
<span class="report-pill report-pending">Pending</span> cash breach awaiting review &middot;
<span class="report-pill report-str">Referred to STR</span> source of funds not evidenced — sent to the
Principal Officer, who decides there whether to whitelist it or file an STR
</p>
<!-- LIVE: monthly CTR reports (mechanical, threshold-based) -->
<div class="fiu-form-meta">
<div class="fiu-form-title">
<h3>Monthly CTR Reports</h3>
<p>
Threshold: <strong>&#8377;{{ bank.cashThreshold | number }}</strong> per single txn / connected aggregate
per month
</p>
</div>
<div class="fiu-action-group">
<input class="fiu-inp" type="month" [(ngModel)]="ctrGenerateMonth" />
<button class="fiu-btn fiu-btn-g" *mifosxHasPermission="'REVIEW_AML_ALERT'" (click)="generateCtrReport()">
Generate
</button>
</div>
</div>
<div class="fiu-tbl-wrap">
<table class="fiu-tbl">
<thead>
<tr>
<th>Month</th>
<th>Generated On</th>
<th class="ta-c">Records</th>
<th>Status</th>
<th>Submitted On</th>
</tr>
</thead>
<tbody>
@for (r of amlCtrReports; track r.id) {
<tr>
<td>{{ r.reportMonth }}</td>
<td>{{ r.generatedOn | date: 'medium' }}</td>
<td class="ta-c">{{ r.recordCount }}</td>
<td>
<span [class]="amlStatusClass(r.status)">{{ r.status }}</span>
</td>
<td>{{ r.submittedOn ? (r.submittedOn | date: 'medium') : '—' }}</td>
</tr>
} @empty {
<tr>
<td colspan="5" class="fiu-empty">No CTR reports generated yet.</td>
</tr>
}
</tbody>
</table>
</div>
<!-- FINnet export template (separate concern — TC1/TC2 file-format generation) -->
<div class="fiu-form-meta">
<div class="fiu-form-title">
<h3>FINnet TC2 Export Template</h3>
<p>
All cash transactions ≥ &#8377;{{ bank.cashThreshold | number }} (single or aggregate per month per account)
</p>
</div>
<button class="fiu-btn fiu-btn-csv" (click)="exportTc2()">
<fa-icon icon="file-csv"></fa-icon> Download TC2.csv
</button> </button>
</div> </div>
<!-- Scope: these controls decide which transactions the six files carry. -->
<div class="fiu-filters"> <div class="fiu-filters">
<div class="fiu-filter"> <div class="fiu-filter">
<label>Type</label> <label>Type</label>
@@ -298,139 +232,69 @@
<button class="fiu-btn fiu-btn-g" (click)="resetCtrFilters()">Reset</button> <button class="fiu-btn fiu-btn-g" (click)="resetCtrFilters()">Reset</button>
</div> </div>
<div class="fiu-tbl-wrap"> <!-- Step rail -->
<table class="fiu-tbl"> <div class="seq-rail">
@for (f of finnetSequence; track f.key) {
<button class="seq-step" [class.active]="activeFinnetFile === f.key" (click)="showFinnetFile(f.key)">
<span class="seq-n">{{ f.step }}</span>
<span class="seq-txt">
<strong>{{ f.name }}</strong>
<em>{{ f.table.rows.length }} {{ f.table.rows.length === 1 ? 'row' : 'rows' }}</em>
</span>
</button>
@if (!$last) {
<fa-icon icon="chevron-right" class="seq-arrow"></fa-icon>
}
}
</div>
<!-- Open file -->
<div class="seq-panel">
<div class="seq-panel-h">
<div>
<h4>Step {{ openFinnetFile.step }} — {{ openFinnetFile.title }}</h4>
<p>{{ openFinnetFile.purpose }}</p>
</div>
<button class="fiu-btn fiu-btn-csv" (click)="downloadOpenFinnetFile()">
<fa-icon icon="file-csv"></fa-icon> {{ openFinnetFile.name }}.csv
</button>
</div>
<div class="seq-tbl-wrap">
<table class="seq-tbl">
<thead> <thead>
<tr> <tr>
<th class="ta-c">Ref</th> <th class="seq-rownum">#</th>
<th>Account</th> @for (h of openFinnetFile.table.headers; track $index) {
<th>Customer</th> <th>{{ h }}</th>
<th class="ta-c">Type</th> }
<th>Date / Time</th>
<th class="ta-r">Amount</th>
<th>Instrument</th>
<th>Branch</th>
<th class="ta-c">Relationship</th>
<th class="ta-c">Filed Under</th>
<th>Compliance Action</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@for (t of filteredCtr; track t.txnId) { @for (r of openFinnetFile.table.rows; track $index) {
<tr> <tr>
<td class="ta-c">{{ t.reportRefNo }}</td> <td class="seq-rownum">{{ $index + 1 }}</td>
<td class="mono">{{ t.accountNumber }}</td> @for (c of r; track $index) {
<td>{{ t.customerName }}</td> <td>{{ c }}</td>
<td class="ta-c">
<span
class="dir-pill"
[class.deposit]="t.depositOrWithdrawal === 'Deposit'"
[class.withdrawal]="t.depositOrWithdrawal === 'Withdrawal'"
>
{{ t.depositOrWithdrawal }}
</span>
</td>
<td>
{{ t.txnDate }} <span class="muted">{{ t.txnTime }}</span>
</td>
<td class="ta-r mono">
<strong>&#8377;{{ t.txnAmount | number: '1.0-0' }}</strong>
</td>
<td>
{{ t.instrumentType }} <span class="muted">{{ t.instrumentId }}</span>
</td>
<td class="mono">{{ t.branchCode }}</td>
<td class="ta-c">
<span class="rel-pill" [class.non-cust]="t.relationshipFlag === 'Non-Customer'">{{
t.relationshipFlag
}}</span>
</td>
<td class="ta-c">
<span class="report-pill report-ctr" title="Always included in the monthly CTR / TC2 file">CTR</span>
@if (t.complianceStatus === 'REFERRED') {
<span
class="report-pill report-str"
title="Also under investigation as an STR case — the transaction remains in the CTR file"
>+ STR</span
>
} }
</td>
<td>
@if (t.complianceStatus === 'REFERRED') {
<span class="status-pill status-escalated">REFERRED TO STR</span>
<button class="fiu-link-btn" (click)="openStrCase(t)">View in STR tab &rarr;</button>
} @else if (t.complianceStatus === 'EVIDENCED') {
<span class="status-pill status-whitelisted">EVIDENCE ON FILE</span>
<div class="muted" style="margin-top: 3px">
{{ t.evidenceDocType }}
@if (t.evidenceReference) {
<span> · {{ t.evidenceReference }}</span>
}
</div>
} @else {
<div class="fiu-action-group" style="gap: 4px" *mifosxHasPermission="'REVIEW_AML_ALERT'">
<button class="fiu-btn fiu-btn-evidence" (click)="startCtrEvidence(t)">Record evidence</button>
<button class="fiu-btn fiu-btn-refer" (click)="referCtrTxnToStr(t)">Send to STR</button>
</div>
}
</td>
</tr> </tr>
<!-- Inline evidence capture for this transaction -->
@if (evidencingTxn?.txnId === t.txnId) {
<tr class="evidence-row">
<td colspan="12">
<div class="evidence-panel">
<div class="evidence-t">
<fa-icon icon="check-circle"></fa-icon>
Record source-of-funds evidence for {{ t.customerName }} &mdash; &#8377;{{
t.txnAmount | number: '1.0-0'
}}
</div>
<div class="evidence-grid">
<div class="form-row">
<label for="ctrDoc{{ t.txnId }}">Document produced</label>
<select id="ctrDoc{{ t.txnId }}" class="fiu-inp" [(ngModel)]="ctrEvidenceDocType">
<option value="">Select document…</option>
@for (d of evidenceDocTypes; track d) {
<option [value]="d">{{ d }}</option>
}
</select>
</div>
<div class="form-row">
<label for="ctrRef{{ t.txnId }}">Document / reference number</label>
<input id="ctrRef{{ t.txnId }}" class="fiu-inp" type="text" [(ngModel)]="ctrEvidenceRef" />
</div>
<div class="fiu-action-group">
<button class="fiu-btn fiu-btn-evidence" (click)="confirmCtrEvidence()">
Confirm — CTR only
</button>
<button class="fiu-btn" (click)="cancelCtrEvidence()">Cancel</button>
</div>
</div>
<p class="evidence-note">
Recording evidence is a documentation step, not a suspicion verdict. The transaction is reported
in the CTR either way — this simply confirms no STR referral is needed.
</p>
</div>
</td>
</tr>
}
} @empty { } @empty {
<tr> <tr>
<td colspan="11" class="fiu-empty">No transactions match the filters.</td> <td [attr.colspan]="openFinnetFile.table.headers.length + 1" class="seq-empty">
@if (openFinnetFile.key === 'tc1') {
No ATM or card transactions match the current scope. When the bank books none at all, TC1 is
still submitted as a header-only file — that is what FIU-IND expects.
} @else {
No rows for the current filters.
}
</td>
</tr> </tr>
} }
</tbody> </tbody>
</table> </table>
</div> </div>
<p class="fiu-tbl-legend"> </div>
<span class="report-pill report-ctr">CTR</span> every cash txn ≥ threshold is reported here regardless of </div>
outcome. <strong>Record evidence</strong> when the customer produces valid documents — CTR only, no STR &middot;
<span class="report-pill report-str">+ STR</span> <strong>Send to STR</strong> when the source of funds cannot
be evidenced; the Principal Officer investigates and decides whether to file with FIU-IND. A referred
transaction stays in the CTR file as well.
</p>
</div> </div>
} }
@@ -779,6 +643,9 @@
<div> <div>
<label>Instrument</label><span>{{ s.instrumentType }} {{ s.instrumentId }}</span> <label>Instrument</label><span>{{ s.instrumentType }} {{ s.instrumentId }}</span>
</div> </div>
<div>
<label>Branch</label><span class="mono">{{ s.branchCode }} {{ branchNameFor(s.branchCode) }}</span>
</div>
</div> </div>
<div class="str-narrative"> <div class="str-narrative">
<label>Narrative</label> <label>Narrative</label>
+298
View File
@@ -1549,3 +1549,301 @@
color: var(--f-g500); color: var(--f-g500);
} }
} }
/* ═══ WHEN TO REPORT — the five PMLA obligations ═══ */
.ob-block {
margin: 18px 0 20px;
}
.ob-head {
margin-bottom: 12px;
}
.ob-title {
margin: 0;
font-size: 15px;
font-weight: 700;
display: flex;
align-items: center;
gap: 8px;
}
.ob-sub {
margin: 3px 0 0;
font-size: 12px;
color: #64748b;
max-width: 860px;
}
.ob-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 12px;
}
.ob-card {
background: #fff;
border: 1px solid #e2e8f0;
border-left: 4px solid #1a237e;
border-radius: 11px;
padding: 13px 15px;
display: flex;
flex-direction: column;
gap: 7px;
/* Obligations the module only tracks read back a shade quieter than the
two it actually files, so the distinction is visible at a glance. */
&--off {
border-left-color: #cbd5e1;
background: #fcfcfd;
}
}
.ob-card-h {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.ob-ic {
color: #1a237e;
font-size: 15px;
}
.ob-code {
font-weight: 800;
font-size: 13px;
letter-spacing: 0.02em;
}
.ob-name {
font-size: 12px;
color: #334155;
font-weight: 600;
}
.ob-flag {
margin-left: auto;
font-size: 10px;
font-weight: 700;
padding: 1px 8px;
border-radius: 999px;
background: #f1f5f9;
color: #64748b;
white-space: nowrap;
&--on {
background: #dcfce7;
color: #15803d;
}
}
.ob-row {
display: flex;
flex-direction: column;
gap: 1px;
label {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #94a3b8;
}
span {
font-size: 12.5px;
color: #0f172a;
line-height: 1.45;
}
}
.ob-due {
font-weight: 700;
color: #b45309 !important;
}
.ob-note {
font-size: 11.5px;
color: #64748b;
font-style: italic;
border-top: 1px dashed #e2e8f0;
padding-top: 6px;
}
/* ═══ FINNET FILE SEQUENCE ═══ */
.seq-block {
margin-top: 26px;
padding-top: 18px;
border-top: 2px solid #e2e8f0;
}
.seq-rail {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
margin-bottom: 14px;
}
.seq-step {
display: inline-flex;
align-items: center;
gap: 9px;
padding: 7px 13px;
border: 1px solid #cbd5e1;
background: #fff;
border-radius: 9px;
cursor: pointer;
font-family: inherit;
text-align: left;
transition: all 0.15s;
&:hover {
border-color: #1a237e;
}
&.active {
background: #1a237e;
border-color: #1a237e;
.seq-n {
background: #fff;
color: #1a237e;
}
.seq-txt strong,
.seq-txt em {
color: #fff;
}
}
}
.seq-n {
display: inline-flex;
align-items: center;
justify-content: center;
width: 21px;
height: 21px;
border-radius: 50%;
background: #1a237e;
color: #fff;
font-size: 11px;
font-weight: 800;
flex: 0 0 21px;
}
.seq-txt {
display: flex;
flex-direction: column;
line-height: 1.25;
strong {
font-size: 12.5px;
color: #0f172a;
font-weight: 700;
}
em {
font-size: 10.5px;
color: #64748b;
font-style: normal;
}
}
.seq-arrow {
color: #cbd5e1;
font-size: 11px;
}
.seq-panel {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 11px;
overflow: hidden;
}
.seq-panel-h {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
padding: 12px 16px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
flex-wrap: wrap;
h4 {
margin: 0;
font-size: 13.5px;
font-weight: 700;
}
p {
margin: 2px 0 0;
font-size: 12px;
color: #64748b;
max-width: 780px;
}
}
.seq-tbl-wrap {
max-height: 460px;
overflow: auto;
}
.seq-tbl {
width: 100%;
border-collapse: collapse;
font-size: 11.5px;
white-space: nowrap;
th {
position: sticky;
top: 0;
z-index: 1;
text-align: left;
padding: 8px 10px;
background: #eef2f7;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
color: #334155;
border-bottom: 1px solid #cbd5e1;
}
td {
padding: 6px 10px;
border-bottom: 1px solid #f1f5f9;
font-family: 'DM Mono', monospace;
color: #0f172a;
}
tbody tr:hover td {
background: #f8fafc;
}
}
.seq-rownum {
color: #94a3b8 !important;
font-weight: 700;
text-align: right;
position: sticky;
left: 0;
background: #fff;
}
.seq-tbl thead .seq-rownum {
background: #eef2f7;
}
.seq-empty {
padding: 36px !important;
text-align: center;
color: #64748b;
font-family: inherit !important;
white-space: normal;
font-size: 12.5px;
}
+371 -43
View File
@@ -13,9 +13,14 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { AmlService } from 'app/aml/aml.service'; import { AmlService } from 'app/aml/aml.service';
import { AuthenticationService } from 'app/core/authentication/authentication.service'; import { AuthenticationService } from 'app/core/authentication/authentication.service';
import { import {
BRANCHES,
BranchMaster,
CardTransaction,
CtrTransaction, CtrTransaction,
CTR_TXNS, CTR_TXNS,
TC1_TXNS,
FIU_BANK, FIU_BANK,
FIU_BRANCH_CODE_TO_CANONICAL,
KC1_INDIVIDUALS, KC1_INDIVIDUALS,
KC2_ENTITIES, KC2_ENTITIES,
KycEntity, KycEntity,
@@ -26,6 +31,90 @@ import {
STR_TXNS STR_TXNS
} from './fiu-data'; } from './fiu-data';
/** A FINnet file rendered as a grid — the same rows that go into the CSV. */
export interface FinnetTable {
headers: string[];
rows: string[][];
}
export interface FinnetFile {
key: string;
/** File name without the extension, as FIU-IND names it. */
name: string;
/** Position in the prescribed submission order. */
step: number;
title: string;
purpose: string;
table: FinnetTable;
}
/**
* The five reports a reporting entity owes FIU-IND under the PMLA, with the
* trigger for each. All of them are filed electronically through FINnet 2.0.
*/
export interface FiuObligation {
code: string;
name: string;
trigger: string;
deadline: string;
icon: string;
/** Whether this module currently files the report or only tracks the rule. */
handled: boolean;
note: string;
}
export const FIU_OBLIGATIONS: FiuObligation[] = [
{
code: 'CTR',
name: 'Cash Transaction Report',
trigger:
'Cash transactions exceeding ₹10,00,000 in a calendar month — a single transaction or a series of connected ones.',
deadline: 'By the 15th of the following month',
icon: 'coins',
handled: true,
note: 'Filed from the CTR tab as the six-file FINnet bundle.'
},
{
code: 'STR',
name: 'Suspicious Transaction Report',
trigger:
'Any transaction that gives rise to a reasonable ground of suspicion of money laundering or a related offence — regardless of amount, and whether or not it was completed.',
deadline: 'Within 7 working days of forming the suspicion',
icon: 'exclamation-triangle',
handled: true,
note: 'Filed from the STR tab as FINnet STR XML.'
},
{
code: 'CBWTR',
name: 'Cross-Border Wire Transfer Report',
trigger:
'Cross-border wire transfers exceeding ₹5,00,000, where the origin or destination of the funds is outside India.',
deadline: 'By the 15th of the following month',
icon: 'globe',
handled: false,
note: 'Not applicable — this bank holds no AD licence and books no cross-border wires.'
},
{
code: 'CCR',
name: 'Counterfeit Currency Report',
trigger:
'Any forged or counterfeit currency note detected, at the counter or in a remittance. No monetary threshold applies.',
deadline: 'By the 15th of the following month',
icon: 'money-bill-wave',
handled: false,
note: 'Raised from the branch counterfeit-note register; not yet wired into this module.'
},
{
code: 'NTR',
name: 'Non-Profit Organisation Transaction Report',
trigger: 'Receipts by a non-profit organisation exceeding ₹10,00,000, or its equivalent in foreign currency.',
deadline: 'By the 15th of the following month',
icon: 'hand-holding-heart',
handled: false,
note: 'Depends on NPO flagging in the customer master; not yet wired into this module.'
}
];
@Component({ @Component({
selector: 'mifosx-fiu', selector: 'mifosx-fiu',
templateUrl: './fiu.component.html', templateUrl: './fiu.component.html',
@@ -45,12 +134,29 @@ export class FiuComponent implements OnInit {
/** Active tab */ /** Active tab */
activeTab: 'dashboard' | 'ctr' | 'str' | 'customers' | 'files' = 'dashboard'; activeTab: 'dashboard' | 'ctr' | 'str' | 'customers' | 'files' = 'dashboard';
/** The five PMLA reporting obligations, shown on the FIU landing tab. */
readonly obligations = FIU_OBLIGATIONS;
bank = FIU_BANK; bank = FIU_BANK;
ctrTxns: CtrTransaction[] = CTR_TXNS; ctrTxns: CtrTransaction[] = CTR_TXNS;
tc1Txns: CardTransaction[] = TC1_TXNS;
strTxns: StrTransaction[] = STR_TXNS; strTxns: StrTransaction[] = STR_TXNS;
kc1: KycIndividual[] = KC1_INDIVIDUALS; kc1: KycIndividual[] = KC1_INDIVIDUALS;
kc2: KycEntity[] = KC2_ENTITIES; kc2: KycEntity[] = KC2_ENTITIES;
// -------------------- Head Office vs Branch scope --------------------
// CTR/STR filing itself is a bank-wide Principal-Officer function (one filing for the whole
// bank, not per branch), but the underlying cash transactions a branch's own staff work
// (evidence capture, referral) are still branch-attributable — scoped the same way every other
// report in the app splits Head Office (sees everything, can drill into one branch) vs a
// branch-level login (locked to its own branch).
readonly branches = BRANCHES;
isHeadOffice = false;
currentOfficeName = 'Branch Office';
private ownBranchCode: string | null = null;
/** '' = All Branches (Head Office consolidated view); otherwise a BranchMaster.code. */
branchFilter = '';
/** CTR filters */ /** CTR filters */
ctrFilterMode: '' | 'Deposit' | 'Withdrawal' = ''; ctrFilterMode: '' | 'Deposit' | 'Withdrawal' = '';
ctrFilterAccount = ''; ctrFilterAccount = '';
@@ -223,6 +329,18 @@ export class FiuComponent implements OnInit {
} }
ngOnInit(): void { ngOnInit(): void {
const creds = this.authenticationService.getCredentials();
this.isHeadOffice = this.authenticationService.isHeadOfficeUser();
this.currentOfficeName = creds?.officeName || 'Branch Office';
if (this.isHeadOffice) {
this.branchFilter = '';
} else {
this.ownBranchCode =
this.branches.find((b: BranchMaster) => this.currentOfficeName.toLowerCase().includes(b.name.toLowerCase()))
?.code || null;
this.branchFilter = this.ownBranchCode || this.branches[0].code;
}
this.loadAmlAlerts(); this.loadAmlAlerts();
this.loadAmlCtrReports(); this.loadAmlCtrReports();
this.loadAmlStrCases(); this.loadAmlStrCases();
@@ -614,21 +732,21 @@ export class FiuComponent implements OnInit {
// -------------------- KPIs -------------------- // -------------------- KPIs --------------------
get totalReports(): number { get totalReports(): number {
return new Set(this.ctrTxns.map((t) => t.reportRefNo)).size; return new Set(this.scopedCtrTxns.map((t) => t.reportRefNo)).size;
} }
get totalCtrTxns(): number { get totalCtrTxns(): number {
return this.ctrTxns.length; return this.scopedCtrTxns.length;
} }
get totalCtrAmount(): number { get totalCtrAmount(): number {
return this.ctrTxns.reduce((s, t) => s + t.txnAmount, 0); return this.scopedCtrTxns.reduce((s, t) => s + t.txnAmount, 0);
} }
get pendingStr(): number { get pendingStr(): number {
return this.strTxns.filter( return this.scopedStrTxns.filter(
(s) => s.status === 'draft' || s.status === 'po-review' || s.status === 'awaiting-filing' (s) => s.status === 'draft' || s.status === 'po-review' || s.status === 'awaiting-filing'
).length; ).length;
} }
get filedStr(): number { get filedStr(): number {
return this.strTxns.filter((s) => s.status === 'filed').length; return this.scopedStrTxns.filter((s) => s.status === 'filed').length;
} }
// ── Live STR case counts (backend `amlStrCases`) — these are the cases the // ── Live STR case counts (backend `amlStrCases`) — these are the cases the
@@ -671,10 +789,48 @@ export class FiuComponent implements OnInit {
return this.amlAlerts.filter((a) => !ctrRuleCodes.includes(a.ruleCode)); return this.amlAlerts.filter((a) => !ctrRuleCodes.includes(a.ruleCode));
} }
// -------------------- Head Office vs Branch scope --------------------
/** Maps a FINnet-style internal branch code to the app-wide canonical branch code. */
private canonicalBranchOf(branchCode: string): string | undefined {
return FIU_BRANCH_CODE_TO_CANONICAL[branchCode];
}
/** Branch name for a row's raw FINnet branch code, for display alongside the code itself. */
branchNameFor(branchCode: string): string {
const canonical = this.canonicalBranchOf(branchCode);
return this.branches.find((b) => b.code === canonical)?.name || '';
}
setBranch(code: string): void {
this.branchFilter = code;
}
/** Name of the branch/scope currently being viewed, for display in the header. */
get scopeName(): string {
if (!this.isHeadOffice) {
return this.currentOfficeName;
}
return this.branchFilter
? this.branches.find((b) => b.code === this.branchFilter)?.name || ''
: 'All Branches (Consolidated)';
}
get scopedCtrTxns(): CtrTransaction[] {
return this.branchFilter
? this.ctrTxns.filter((t) => this.canonicalBranchOf(t.branchCode) === this.branchFilter)
: this.ctrTxns;
}
get scopedStrTxns(): StrTransaction[] {
return this.branchFilter
? this.strTxns.filter((s) => this.canonicalBranchOf(s.branchCode) === this.branchFilter)
: this.strTxns;
}
// -------------------- Filtered lists -------------------- // -------------------- Filtered lists --------------------
get filteredCtr(): CtrTransaction[] { get filteredCtr(): CtrTransaction[] {
const acc = this.ctrFilterAccount.trim(); const acc = this.ctrFilterAccount.trim();
return this.ctrTxns.filter((t) => { return this.scopedCtrTxns.filter((t) => {
if (this.ctrFilterMode && t.depositOrWithdrawal !== this.ctrFilterMode) return false; if (this.ctrFilterMode && t.depositOrWithdrawal !== this.ctrFilterMode) return false;
if (acc && !t.accountNumber.includes(acc)) return false; if (acc && !t.accountNumber.includes(acc)) return false;
if (this.ctrFilterMinAmount > 0 && t.txnAmount < this.ctrFilterMinAmount) return false; if (this.ctrFilterMinAmount > 0 && t.txnAmount < this.ctrFilterMinAmount) return false;
@@ -682,8 +838,19 @@ export class FiuComponent implements OnInit {
}); });
} }
/** ATM / card legs under the same scope controls, so TC1 and TC2 stay in step. */
get filteredTc1(): CardTransaction[] {
const acc = this.ctrFilterAccount.trim();
return this.tc1Txns.filter((c) => {
if (this.ctrFilterMode && c.depositOrWithdrawal !== this.ctrFilterMode) return false;
if (acc && !c.accountNumber.includes(acc)) return false;
if (this.ctrFilterMinAmount > 0 && c.txnAmount < this.ctrFilterMinAmount) return false;
return true;
});
}
get filteredStr(): StrTransaction[] { get filteredStr(): StrTransaction[] {
return this.strTxns.filter((s) => { return this.scopedStrTxns.filter((s) => {
if (this.strFilterStatus && s.status !== this.strFilterStatus) return false; if (this.strFilterStatus && s.status !== this.strFilterStatus) return false;
if (this.strFilterRisk && s.riskCategory !== this.strFilterRisk) return false; if (this.strFilterRisk && s.riskCategory !== this.strFilterRisk) return false;
return true; return true;
@@ -731,9 +898,14 @@ export class FiuComponent implements OnInit {
return map[c]; return map[c];
} }
// -------------------- Exports -------------------- // -------------------- FINnet file builders --------------------
/** TC2 — Branch / cash transaction CSV */ /**
exportTc2(): void { * Each FINnet file is built once, here, and used for both the on-screen
* preview and the CSV download. Keeping a single builder per file is what
* guarantees the branch sees on screen exactly what is submitted — the two
* used to be written out separately, which let them drift.
*/
private buildTc2(): FinnetTable {
const headers = [ const headers = [
'Report Type', 'Report Type',
'Report Reference Number', 'Report Reference Number',
@@ -752,7 +924,7 @@ export class FiuComponent implements OnInit {
'Third Party PAN (Non-Customer)', 'Third Party PAN (Non-Customer)',
'Declaration (If Third Party PAN (Non-Customer) is not available)' 'Declaration (If Third Party PAN (Non-Customer) is not available)'
]; ];
const rows = [headers]; const rows: string[][] = [];
for (const t of this.filteredCtr) { for (const t of this.filteredCtr) {
rows.push([ rows.push([
t.reportType, t.reportType,
@@ -773,12 +945,11 @@ export class FiuComponent implements OnInit {
t.declaration t.declaration
]); ]);
} }
this.saveCsv(rows, 'TC2.csv'); return { headers, rows };
this.toast(`TC2 exported (${this.filteredCtr.length} rows)`);
} }
/** TC1 header-only template (UI doesn't track ATM/card txns) */ /** TC1 — ATM / card transactions. Header-only: this bank books no card txns. */
exportTc1(): void { private buildTc1(): FinnetTable {
const headers = [ const headers = [
'Relationship Flag', 'Relationship Flag',
'Transaction Status', 'Transaction Status',
@@ -799,12 +970,34 @@ export class FiuComponent implements OnInit {
'Branch Code of Account', 'Branch Code of Account',
'Report Type' 'Report Type'
]; ];
this.saveCsv([headers], 'TC1.csv'); const rows: string[][] = [];
this.toast('TC1 template exported (header only)'); for (const c of this.filteredTc1) {
rows.push([
c.relationshipFlag,
c.txnStatus,
c.txnId.toString(),
c.txnDate.replace(/-/g, '/'),
c.txnTime,
c.depositOrWithdrawal,
c.txnAmount.toString(),
c.accountNumber,
c.accountType,
c.cardType,
c.cardNumber,
c.cardIssuingCountry,
c.cardIssuingBank,
c.reportRefNo.toString(),
c.switchTxnId,
c.atmId,
c.branchCode,
c.reportType
]);
}
return { headers, rows };
} }
/** KC1 — Individual customers */ /** KC1 — Individual customers */
exportKc1(): void { private buildKc1(): FinnetTable {
const headers = [ const headers = [
'Report Reference Number', 'Report Reference Number',
'UCIC', 'UCIC',
@@ -851,7 +1044,7 @@ export class FiuComponent implements OnInit {
'Other Identifier Number', 'Other Identifier Number',
'Primary Entity' 'Primary Entity'
]; ];
const rows = [headers]; const rows: string[][] = [];
for (const k of this.kc1) { for (const k of this.kc1) {
rows.push([ rows.push([
k.reportRefNo.toString(), k.reportRefNo.toString(),
@@ -900,12 +1093,11 @@ export class FiuComponent implements OnInit {
'Yes' 'Yes'
]); ]);
} }
this.saveCsv(rows, 'KC1.csv'); return { headers, rows };
this.toast(`KC1 exported (${this.kc1.length} individuals)`);
} }
/** KC2 — Entity customers */ /** KC2 — Entity customers */
exportKc2(): void { private buildKc2(): FinnetTable {
const headers = [ const headers = [
'Report Reference Number', 'Report Reference Number',
'UCIC', 'UCIC',
@@ -940,7 +1132,7 @@ export class FiuComponent implements OnInit {
'UCIC of Owner of Sole Proprietor Firm', 'UCIC of Owner of Sole Proprietor Firm',
'Name of Owner of Sole Proprietor Firm' 'Name of Owner of Sole Proprietor Firm'
]; ];
const rows = [headers]; const rows: string[][] = [];
for (const k of this.kc2) { for (const k of this.kc2) {
rows.push([ rows.push([
k.reportRefNo.toString(), k.reportRefNo.toString(),
@@ -977,12 +1169,11 @@ export class FiuComponent implements OnInit {
k.ownerName k.ownerName
]); ]);
} }
this.saveCsv(rows, 'KC2.csv'); return { headers, rows };
this.toast(`KC2 exported (${this.kc2.length} entities)`);
} }
/** Account_Detail.csv — per-report account aggregates (12-month) */ /** Account_Detail — per-report account aggregates (12-month) */
exportAccountDetail(): void { private buildAccountDetail(): FinnetTable {
const headers = [ const headers = [
'Report Type', 'Report Type',
'Report Reference Number', 'Report Reference Number',
@@ -1001,7 +1192,7 @@ export class FiuComponent implements OnInit {
'Total Cash Deposit (In last 12 months)', 'Total Cash Deposit (In last 12 months)',
'Total Cash Withdrawal (In last 12 months) Amount' 'Total Cash Withdrawal (In last 12 months) Amount'
]; ];
const rows = [headers]; const rows: string[][] = [];
// Build one aggregate row per (reportRefNo, accountNumber) pair // Build one aggregate row per (reportRefNo, accountNumber) pair
const seen = new Set<string>(); const seen = new Set<string>();
for (const t of this.filteredCtr) { for (const t of this.filteredCtr) {
@@ -1039,12 +1230,11 @@ export class FiuComponent implements OnInit {
.toString() .toString()
]); ]);
} }
this.saveCsv(rows, 'Account_Detail.csv'); return { headers, rows };
this.toast(`Account_Detail exported (${rows.length - 1} accounts)`);
} }
/** Account_Person_Relation.csv — account ↔ customer mapping */ /** Account_Person_Relation — account ↔ customer mapping */
exportAccountPersonRelation(): void { private buildAccountPersonRelation(): FinnetTable {
const headers = [ const headers = [
'Report Type', 'Report Type',
'Report Reference Number', 'Report Reference Number',
@@ -1055,7 +1245,7 @@ export class FiuComponent implements OnInit {
'Individual / Non-individual', 'Individual / Non-individual',
'Name of Non-customer' 'Name of Non-customer'
]; ];
const rows = [headers]; const rows: string[][] = [];
const seen = new Set<string>(); const seen = new Set<string>();
for (const t of this.filteredCtr) { for (const t of this.filteredCtr) {
const key = `${t.reportRefNo}|${t.accountNumber}`; const key = `${t.reportRefNo}|${t.accountNumber}`;
@@ -1074,8 +1264,145 @@ export class FiuComponent implements OnInit {
t.relationshipFlag === 'Non-Customer' ? t.customerName || '' : '' t.relationshipFlag === 'Non-Customer' ? t.customerName || '' : ''
]); ]);
} }
this.saveCsv(rows, 'Account_Person_Relation.csv'); return { headers, rows };
this.toast(`Account_Person_Relation exported (${rows.length - 1} rows)`); }
// -------------------- FINnet file sequence --------------------
/**
* The six CTR files in the order FIU-IND expects them: the account is
* described first, then who is behind it, then the transactions, and finally
* the customer cards those transactions belong to.
*/
private get allFinnetFiles(): FinnetFile[] {
return [
{
key: 'account-detail',
name: 'Account_Detail',
step: 1,
title: 'Account Detail',
purpose:
'One row per reported account — status, opening date and the 12-month debit, credit and cash aggregates.',
table: this.buildAccountDetail()
},
{
key: 'account-person-relation',
name: 'Account_Person_Relation',
step: 2,
title: 'Account ↔ Person Relation',
purpose:
'Links each account to the customers behind it, with the relationship type and whether they are an individual or an entity.',
table: this.buildAccountPersonRelation()
},
{
key: 'tc1',
name: 'TC1',
step: 3,
title: 'TC1 — ATM / Card Transactions',
purpose:
'The card-rail leg of the same CTR: ATM withdrawals, recycler deposits and declined attempts. An ATM cash withdrawal is still cash, so it counts towards the customer’s monthly aggregate alongside TC2.',
table: this.buildTc1()
},
{
key: 'tc2',
name: 'TC2',
step: 4,
title: 'TC2 — Branch / Cash Transactions',
purpose: 'The transaction detail itself — every cash transaction that put the account over the CTR threshold.',
table: this.buildTc2()
},
{
key: 'kc1',
name: 'KC1',
step: 5,
title: 'KC1 — Individual Customer Cards',
purpose: 'Full KYC card for every individual named in the report: identifiers, address, income, risk level.',
table: this.buildKc1()
},
{
key: 'kc2',
name: 'KC2',
step: 6,
title: 'KC2 — Entity Customer Cards',
purpose: 'The same for non-individuals: firms, HUFs and societies, with GSTIN and sole-proprietor ownership.',
table: this.buildKc2()
}
];
}
/**
* The files walked through in the CTR tab: account, then who is behind it,
* then the two transaction files. The customer cards are excluded here and
* live on the Customer Cards tab instead, though they still travel with the
* full bundle.
*/
get finnetSequence(): FinnetFile[] {
return this.allFinnetFiles.filter((f) => f.key !== 'kc1' && f.key !== 'kc2');
}
/** Which file in the sequence is open in the CTR tab. */
activeFinnetFile = 'account-detail';
get openFinnetFile(): FinnetFile {
return this.finnetSequence.find((f) => f.key === this.activeFinnetFile) ?? this.finnetSequence[0];
}
showFinnetFile(key: string): void {
this.activeFinnetFile = key;
}
/** Downloads whichever file is currently previewed, so the button matches the table. */
downloadOpenFinnetFile(): void {
const f = this.openFinnetFile;
this.saveCsv(
[
f.table.headers,
...f.table.rows
],
`${f.name}.csv`
);
this.toast(`${f.name} exported (${f.table.rows.length} rows)`);
}
// -------------------- Exports --------------------
private saveTable(file: FinnetFile): void {
this.saveCsv(
[
file.table.headers,
...file.table.rows
],
`${file.name}.csv`
);
this.toast(`${file.name} exported (${file.table.rows.length} rows)`);
}
private fileByKey(key: string): FinnetFile {
return this.allFinnetFiles.find((f) => f.key === key)!;
}
exportTc1(): void {
this.saveTable(this.fileByKey('tc1'));
}
exportTc2(): void {
this.saveTable(this.fileByKey('tc2'));
}
exportKc1(): void {
this.saveTable(this.fileByKey('kc1'));
}
exportKc2(): void {
this.saveTable(this.fileByKey('kc2'));
}
exportAccountDetail(): void {
this.saveTable(this.fileByKey('account-detail'));
}
exportAccountPersonRelation(): void {
this.saveTable(this.fileByKey('account-person-relation'));
} }
/** STR XML stub (FINnet 2.0 STR XML) */ /** STR XML stub (FINnet 2.0 STR XML) */
@@ -1121,15 +1448,16 @@ export class FiuComponent implements OnInit {
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
/** Bundle: the 6 CSVs plus the STR XML — emitted as separate downloads sequentially */ /**
* Bundle: the 6 CSVs plus the STR XML, emitted as separate downloads in the
* order FIU-IND expects them — account, then relation, then TC, then KC.
*/
exportAll(): void { exportAll(): void {
setTimeout(() => this.exportTc1(), 0); // All six travel together — the customer cards are not walked through in the
setTimeout(() => this.exportTc2(), 200); // CTR tab, but a bundle without them would be incomplete.
setTimeout(() => this.exportKc1(), 400); const files = this.allFinnetFiles;
setTimeout(() => this.exportKc2(), 600); files.forEach((f, i) => setTimeout(() => this.saveTable(f), i * 200));
setTimeout(() => this.exportAccountDetail(), 800); setTimeout(() => this.exportStrXml(), files.length * 200);
setTimeout(() => this.exportAccountPersonRelation(), 1000);
setTimeout(() => this.exportStrXml(), 1200);
} }
private escapeXml(s: string): string { private escapeXml(s: string): string {
@@ -192,8 +192,8 @@
><span>Deal No</span><input type="text" [(ngModel)]="purchaseForm.dealNo" readonly ><span>Deal No</span><input type="text" [(ngModel)]="purchaseForm.dealNo" readonly
/></label> /></label>
<label class="inv-form-field" <label class="inv-form-field"
><span>* Deal Date</span><input type="text" [(ngModel)]="purchaseForm.dealDate" placeholder="DD-MM-YYYY" ><span>* Deal Date</span><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.dealDate"></mifosx-date-ddmmyyyy
/></label> ></label>
<label class="inv-form-field" <label class="inv-form-field"
><span>* Deal Time (24 hr, HH:mm:ss)</span ><span>* Deal Time (24 hr, HH:mm:ss)</span
><input type="text" [(ngModel)]="purchaseForm.dealTime" placeholder="HH:mm:ss" ><input type="text" [(ngModel)]="purchaseForm.dealTime" placeholder="HH:mm:ss"
@@ -221,8 +221,8 @@
</label> </label>
<label class="inv-form-field" <label class="inv-form-field"
><span>Settlement Dt</span ><span>Settlement Dt</span
><input type="text" [(ngModel)]="purchaseForm.settlementDt" placeholder="DD-MM-YYYY" ><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.settlementDt"></mifosx-date-ddmmyyyy
/></label> ></label>
<label class="inv-form-field"> <label class="inv-form-field">
<span>* Party Code</span> <span>* Party Code</span>
<select [(ngModel)]="purchaseForm.partyCode"> <select [(ngModel)]="purchaseForm.partyCode">
@@ -333,8 +333,8 @@
</select> </select>
</label> </label>
<label class="inv-form-field" <label class="inv-form-field"
><span>* Issue Date</span><input type="text" [(ngModel)]="purchaseForm.issueDate" placeholder="DD-MM-YYYY" ><span>* Issue Date</span><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.issueDate"></mifosx-date-ddmmyyyy
/></label> ></label>
<label class="inv-form-field" <label class="inv-form-field"
><span>Period(Year)</span><input type="number" [(ngModel)]="purchaseForm.periodYear" min="0" ><span>Period(Year)</span><input type="number" [(ngModel)]="purchaseForm.periodYear" min="0"
/></label> /></label>
@@ -354,8 +354,8 @@
><span>Call Option</span><input type="text" [(ngModel)]="purchaseForm.callOption" ><span>Call Option</span><input type="text" [(ngModel)]="purchaseForm.callOption"
/></label> /></label>
<label class="inv-form-field" <label class="inv-form-field"
><span>Meet Dt</span><input type="text" [(ngModel)]="purchaseForm.meetDt" placeholder="DD-MM-YYYY" ><span>Meet Dt</span><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.meetDt"></mifosx-date-ddmmyyyy
/></label> ></label>
<label class="inv-form-field" <label class="inv-form-field"
><span>Reso No</span><input type="text" [(ngModel)]="purchaseForm.resoNo" ><span>Reso No</span><input type="text" [(ngModel)]="purchaseForm.resoNo"
/></label> /></label>
@@ -394,11 +394,11 @@
</select> </select>
</label> </label>
<label class="inv-form-field" <label class="inv-form-field"
><span>Int Date</span><input type="text" [(ngModel)]="purchaseForm.intDate" placeholder="DD-MM-YYYY" ><span>Int Date</span><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.intDate"></mifosx-date-ddmmyyyy
/></label> ></label>
<label class="inv-form-field" <label class="inv-form-field"
><span>* Last Int.dt</span><input type="text" [(ngModel)]="purchaseForm.lastIntDt" placeholder="DD-MM-YYYY" ><span>* Last Int.dt</span><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.lastIntDt"></mifosx-date-ddmmyyyy
/></label> ></label>
<label class="inv-form-field" <label class="inv-form-field"
><span>Accrued Interest (computed)</span ><span>Accrued Interest (computed)</span
><input type="text" [value]="purchaseAccruedInterest | number: '1.2-2'" readonly ><input type="text" [value]="purchaseAccruedInterest | number: '1.2-2'" readonly
@@ -554,8 +554,8 @@
/></label> /></label>
} }
<label class="inv-form-field" <label class="inv-form-field"
><span>Redemption Date *</span><input type="text" [(ngModel)]="redeemDate" placeholder="DD-MM-YYYY" ><span>Redemption Date *</span><mifosx-date-ddmmyyyy [(ngModel)]="redeemDate"></mifosx-date-ddmmyyyy
/></label> ></label>
<label class="inv-form-field" <label class="inv-form-field"
><span>Redemption Proceeds (computed)</span ><span>Redemption Proceeds (computed)</span
><input type="text" [value]="'₹' + (redeemAmount | number: '1.2-2')" readonly ><input type="text" [value]="'₹' + (redeemAmount | number: '1.2-2')" readonly
@@ -575,11 +575,14 @@
<div class="pos-section-label">Investment - Govt./Bond Interest calculation</div> <div class="pos-section-label">Investment - Govt./Bond Interest calculation</div>
<div class="inv-form-grid"> <div class="inv-form-grid">
<label class="inv-form-field" <label class="inv-form-field"
><span>Date</span><input type="text" [(ngModel)]="intCalcDate" placeholder="DD-MM-YYYY" ><span>From Date</span><mifosx-date-ddmmyyyy [(ngModel)]="intCalcFromDate"></mifosx-date-ddmmyyyy
/></label> ></label>
<label class="inv-form-field"
><span>To Date</span><mifosx-date-ddmmyyyy [(ngModel)]="intCalcToDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"> <label class="inv-form-field">
<span>Type</span> <span>Type</span>
<select [(ngModel)]="intCalcType"> <select [(ngModel)]="intCalcType" (ngModelChange)="onIntCalcTypeChange()">
<option value="">-- All Types --</option> <option value="">-- All Types --</option>
@for (o of bondTypeOptions; track o.value) { @for (o of bondTypeOptions; track o.value) {
<option [value]="o.value">{{ o.label }}</option> <option [value]="o.value">{{ o.label }}</option>
@@ -599,11 +602,25 @@
<label class="inv-form-field" <label class="inv-form-field"
><span>ALL</span><input type="checkbox" [(ngModel)]="intCalcAll" style="height: 18px; width: 18px" ><span>ALL</span><input type="checkbox" [(ngModel)]="intCalcAll" style="height: 18px; width: 18px"
/></label> /></label>
<label class="inv-form-field">
<span>Post To (Interest Income GL)</span>
<select [(ngModel)]="intCalcGlCode">
@for (g of interestIncomeGlOptions; track g.glCode) {
<option [value]="g.glCode">{{ g.glCode }} — {{ g.glName }}</option>
}
</select>
</label>
</div> </div>
<div class="inv-form-actions"> <div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="processInterest()">Process</button> <button type="button" class="pg-btn pg-btn--primary" (click)="processInterest()">Process</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="setTab('holdings')">Exit</button> <button type="button" class="pg-btn pg-btn--ghost" (click)="setTab('holdings')">Exit</button>
</div> </div>
@if (intCalcExcludedNpi.length > 0) {
<p class="pg-sub" style="padding: 0 16px 12px">
{{ intCalcExcludedNpi.length }} holding(s) excluded — classified Non-Performing, interest is not posted on
accrual basis (collectible on cash basis only, via Interest Receive in the Investment Interest module).
</p>
}
<div class="pos-section-label">Holdings matching the filter ({{ intCalcTargets.length }})</div> <div class="pos-section-label">Holdings matching the filter ({{ intCalcTargets.length }})</div>
<table class="pg-table"> <table class="pg-table">
<thead> <thead>
@@ -694,8 +711,8 @@
><span>TBill GL.A/C.</span><input type="text" [(ngModel)]="sellForm.tbillGlAc" ><span>TBill GL.A/C.</span><input type="text" [(ngModel)]="sellForm.tbillGlAc"
/></label> /></label>
<label class="inv-form-field" <label class="inv-form-field"
><span>* Deal Date</span><input type="text" [(ngModel)]="sellForm.dealDate" placeholder="DD-MM-YYYY" ><span>* Deal Date</span><mifosx-date-ddmmyyyy [(ngModel)]="sellForm.dealDate"></mifosx-date-ddmmyyyy
/></label> ></label>
<label class="inv-form-field" <label class="inv-form-field"
><span>* Deal Time</span><input type="text" [(ngModel)]="sellForm.dealTime" placeholder="HH:mm:ss" ><span>* Deal Time</span><input type="text" [(ngModel)]="sellForm.dealTime" placeholder="HH:mm:ss"
/></label> /></label>
@@ -708,8 +725,8 @@
</select> </select>
</label> </label>
<label class="inv-form-field" <label class="inv-form-field"
><span>Settlement Dt</span><input type="text" [(ngModel)]="sellForm.settlementDt" placeholder="DD-MM-YYYY" ><span>Settlement Dt</span><mifosx-date-ddmmyyyy [(ngModel)]="sellForm.settlementDt"></mifosx-date-ddmmyyyy
/></label> ></label>
<label class="inv-form-field" <label class="inv-form-field"
><span>* Face Value</span><input type="number" [(ngModel)]="sellForm.faceValue" min="0" ><span>* Face Value</span><input type="number" [(ngModel)]="sellForm.faceValue" min="0"
/></label> /></label>
@@ -942,8 +959,8 @@
</select> </select>
</label> </label>
<label class="inv-form-field" <label class="inv-form-field"
><span>Transfer Date *</span><input type="text" [(ngModel)]="goiTransferDate" placeholder="DD-MM-YYYY" ><span>Transfer Date *</span><mifosx-date-ddmmyyyy [(ngModel)]="goiTransferDate"></mifosx-date-ddmmyyyy
/></label> ></label>
</div> </div>
<div class="inv-form-actions"> <div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="saveGoiTransfer()">Save</button> <button type="button" class="pg-btn pg-btn--primary" (click)="saveGoiTransfer()">Save</button>
@@ -958,8 +975,8 @@
<div class="pos-section-label">Investment - Investment Depreciation (Mark-to-Market Provisioning)</div> <div class="pos-section-label">Investment - Investment Depreciation (Mark-to-Market Provisioning)</div>
<div class="inv-form-grid"> <div class="inv-form-grid">
<label class="inv-form-field" <label class="inv-form-field"
><span>Valuation Date</span><input type="text" [(ngModel)]="depreciationDate" placeholder="DD-MM-YYYY" ><span>Valuation Date</span><mifosx-date-ddmmyyyy [(ngModel)]="depreciationDate"></mifosx-date-ddmmyyyy
/></label> ></label>
<label class="inv-form-field"> <label class="inv-form-field">
<span>Category</span> <span>Category</span>
<select [(ngModel)]="depreciationCategory"> <select [(ngModel)]="depreciationCategory">
@@ -1043,8 +1060,8 @@
><span>Units to Transfer *</span><input type="number" [(ngModel)]="mfTransferUnits" min="0" step="0.001" ><span>Units to Transfer *</span><input type="number" [(ngModel)]="mfTransferUnits" min="0" step="0.001"
/></label> /></label>
<label class="inv-form-field" <label class="inv-form-field"
><span>Transfer Date *</span><input type="text" [(ngModel)]="mfTransferDate" placeholder="DD-MM-YYYY" ><span>Transfer Date *</span><mifosx-date-ddmmyyyy [(ngModel)]="mfTransferDate"></mifosx-date-ddmmyyyy
/></label> ></label>
<label class="inv-form-field" <label class="inv-form-field"
><span>Transfer Value (computed)</span ><span>Transfer Value (computed)</span
><input type="text" [value]="'₹' + (mfTransferValue | number: '1.2-2')" readonly ><input type="text" [value]="'₹' + (mfTransferValue | number: '1.2-2')" readonly
@@ -1069,6 +1086,7 @@
<th>Cert No</th> <th>Cert No</th>
<th>Type</th> <th>Type</th>
<th>Particulars</th> <th>Particulars</th>
<th>GL Posted</th>
<th class="num">Amount</th> <th class="num">Amount</th>
</tr> </tr>
</thead> </thead>
@@ -1082,11 +1100,12 @@
<td>{{ t.certNo }}</td> <td>{{ t.certNo }}</td>
<td>{{ t.txnType }}</td> <td>{{ t.txnType }}</td>
<td>{{ t.particulars }}</td> <td>{{ t.particulars }}</td>
<td>{{ t.glCode ? t.glCode + ' — ' + t.glName : '—' }}</td>
<td class="num">₹{{ t.amount | number: '1.2-2' }}</td> <td class="num">₹{{ t.amount | number: '1.2-2' }}</td>
</tr> </tr>
} @empty { } @empty {
<tr> <tr>
<td colspan="6" class="pg-empty">No transactions yet.</td> <td colspan="7" class="pg-empty">No transactions yet.</td>
</tr> </tr>
} }
</tbody> </tbody>
@@ -9,6 +9,7 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { DateDdmmyyyyPickerComponent } from '../date-ddmmyyyy-picker/date-ddmmyyyy-picker.component';
import { import {
GOVT_SECURITY_HOLDINGS, GOVT_SECURITY_HOLDINGS,
NON_SLR_HOLDINGS, NON_SLR_HOLDINGS,
@@ -30,12 +31,16 @@ import {
BOND_RATING_OPTIONS, BOND_RATING_OPTIONS,
INT_TYPE_OPTIONS, INT_TYPE_OPTIONS,
AUTHORITY_OPTIONS, AUTHORITY_OPTIONS,
INTEREST_INCOME_GL_MASTER,
INTEREST_INCOME_GL_BY_TYPE,
InvestmentHolding, InvestmentHolding,
InvestmentTxn, InvestmentTxn,
InstrumentType, InstrumentType,
InterestFreq, InterestFreq,
INSTRUMENT_LABELS, INSTRUMENT_LABELS,
accruedInterest, accruedInterest,
accruedInterestBetween,
canAccrueInterest,
daysToMaturity, daysToMaturity,
maturityAmount, maturityAmount,
addPeriod, addPeriod,
@@ -173,7 +178,8 @@ interface BondSellForm {
standalone: true, standalone: true,
imports: [ imports: [
CommonModule, CommonModule,
FormsModule FormsModule,
DateDdmmyyyyPickerComponent
] ]
}) })
export class BondInvestmentComponent { export class BondInvestmentComponent {
@@ -224,12 +230,17 @@ export class BondInvestmentComponent {
redeemDate = this.asOnDate; redeemDate = this.asOnDate;
redeemRemarks = ''; redeemRemarks = '';
// Interest Calculation (legacy: Date / Type / Script / Deal No / ALL + Process) // Interest Calculation (legacy: From/To Date / Type / Script / Deal No / ALL + Process) —
intCalcDate = this.asOnDate; // posts interest accrued strictly within [intCalcFromDate, intCalcToDate], same period-based
// basis as the Investment Interest module's Govt./Bond Interest Calculation.
intCalcFromDate = addPeriod(this.asOnDate, 0, -3);
intCalcToDate = this.asOnDate;
intCalcType: BondType | '' = ''; intCalcType: BondType | '' = '';
intCalcScript = ''; intCalcScript = '';
intCalcDealNo = ''; intCalcDealNo = '';
intCalcAll = false; intCalcAll = false;
intCalcGlCode = INTEREST_INCOME_GL_BY_TYPE.GOVT_SECURITY;
readonly interestIncomeGlOptions = INTEREST_INCOME_GL_MASTER;
// Write-off // Write-off
writeoffCertNo = ''; writeoffCertNo = '';
@@ -647,13 +658,15 @@ export class BondInvestmentComponent {
} }
// -------------------- Interest calculation -------------------- // -------------------- Interest calculation --------------------
/** Holdings the Interest Calculation "Process" run would cover, given the filters. */ /** Holdings the Interest Calculation "Process" run would cover, given the filters. A
* Non-Performing holding is always excluded — same accrual-basis rule as the Investment
* Interest module (it's still collectible on cash basis via Interest Receive there). */
get intCalcTargets(): InvestmentHolding[] { get intCalcTargets(): InvestmentHolding[] {
const base = this.holdings.filter((h) => h.status === 'ACTIVE' && canAccrueInterest(h));
if (this.intCalcAll) { if (this.intCalcAll) {
return this.holdings.filter((h) => h.status === 'ACTIVE'); return base;
} }
return this.holdings.filter((h) => { return base.filter((h) => {
if (h.status !== 'ACTIVE') return false;
if (this.intCalcType && h.instrumentType !== this.intCalcType) return false; if (this.intCalcType && h.instrumentType !== this.intCalcType) return false;
if (this.intCalcScript && h.scriptCode !== this.intCalcScript) return false; if (this.intCalcScript && h.scriptCode !== this.intCalcScript) return false;
if (this.intCalcDealNo && h.certNo !== this.intCalcDealNo.trim()) return false; if (this.intCalcDealNo && h.certNo !== this.intCalcDealNo.trim()) return false;
@@ -661,31 +674,62 @@ export class BondInvestmentComponent {
}); });
} }
get intCalcExcludedNpi(): InvestmentHolding[] {
return this.holdings.filter((h) => h.status === 'ACTIVE' && !canAccrueInterest(h));
}
/** Re-suggests the Interest Income GL when the Type filter changes — still overridable. */
onIntCalcTypeChange(): void {
if (this.intCalcType) {
this.intCalcGlCode = INTEREST_INCOME_GL_BY_TYPE[this.intCalcType];
}
}
glName(glCode: string): string {
return this.interestIncomeGlOptions.find((g) => g.glCode === glCode)?.glName ?? glCode;
}
/** Interest accrued strictly within [intCalcFromDate, intCalcToDate] — a period-based
* Interest Calculation & Post run, same basis as the Investment Interest module. */
interestAccrued(h: InvestmentHolding): number { interestAccrued(h: InvestmentHolding): number {
return accruedInterest(h, this.intCalcDate || this.asOnDate); return accruedInterestBetween(h, this.intCalcFromDate || h.purchaseDate, this.intCalcToDate || this.asOnDate);
} }
processInterest(): void { processInterest(): void {
const targets = this.intCalcTargets; const targets = this.intCalcTargets.filter((h) => this.interestAccrued(h) > 0);
if (targets.length === 0) { if (targets.length === 0) {
this.toast('No holdings match the selected filters.'); this.toast('No holdings have interest due to be posted for this period.');
return; return;
} }
const newTxns: InvestmentTxn[] = targets.map((h, i) => ({ const toDate = this.intCalcToDate || this.asOnDate;
const fromDate = this.intCalcFromDate || toDate;
const glCode = this.intCalcGlCode;
const glName = this.glName(glCode);
const newTxns: InvestmentTxn[] = targets.map((h, i) => {
const amt = this.interestAccrued(h);
h.interestPosted = (h.interestPosted ?? 0) + amt;
h.interestPostedTillDate = toDate;
return {
certNo: h.certNo, certNo: h.certNo,
instrumentType: h.instrumentType, instrumentType: h.instrumentType,
txnType: 'INTEREST', txnType: 'INTEREST',
voucherNo: `INV/INT/${h.certNo}-${this.txns.length + i + 1}`, voucherNo: `INV/INT/${h.certNo}-${this.txns.length + i + 1}`,
txnDate: this.intCalcDate || this.asOnDate, txnDate: toDate,
amount: this.interestAccrued(h), amount: amt,
particulars: `Interest calculated - ${h.scriptName}` particulars: `Interest calculated (accrual) for period ${fromDate} to ${toDate} - ${h.scriptName}`,
})); glCode,
glName
};
});
this.txns = [ this.txns = [
...newTxns, ...newTxns,
...this.txns ...this.txns
]; ];
this.intCalcFromDate = toDate;
const total = newTxns.reduce((s, t) => s + t.amount, 0); const total = newTxns.reduce((s, t) => s + t.amount, 0);
this.toast(`Interest processed for ${targets.length} holding(s). Total ₹${total.toLocaleString('en-IN')}.`); this.toast(
`Interest processed for ${targets.length} holding(s) to ${glCode} - ${glName}. Total ₹${total.toLocaleString('en-IN')}.`
);
} }
// -------------------- Sell / Redeem Entry -------------------- // -------------------- Sell / Redeem Entry --------------------
@@ -0,0 +1,213 @@
/**
* 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, ElementRef, ViewChild, forwardRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms';
/**
* Drop-in replacement for `<input type="text" placeholder="DD-MM-YYYY">` used throughout the
* Investment module. Keeps the DD-MM-YYYY string every date field/helper in investment-data.ts
* already expects (parseDDMMYYYY, addPeriod, etc.), but adds a calendar button that opens the
* browser's native date picker and converts the picked value back to DD-MM-YYYY — so users can
* either type the date or pick it, without changing any consuming component's data model.
*
* Usage: <mifosx-date-ddmmyyyy [(ngModel)]="someField"></mifosx-date-ddmmyyyy>
*/
@Component({
selector: 'mifosx-date-ddmmyyyy',
standalone: true,
imports: [
CommonModule,
FormsModule
],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => DateDdmmyyyyPickerComponent),
multi: true
}
],
template: `
<div class="inv-date-field">
<input
type="text"
class="inv-date-field__text"
[ngModel]="value"
(ngModelChange)="onTextChange($event)"
(blur)="onTouched()"
placeholder="DD-MM-YYYY"
[disabled]="disabled"
/>
<button
type="button"
class="inv-date-field__btn"
tabindex="-1"
[disabled]="disabled"
(click)="openPicker()"
aria-label="Pick a date"
title="Pick a date"
>
📅
</button>
<input
#native
type="date"
class="inv-date-field__native"
tabindex="-1"
[ngModel]="isoValue"
(ngModelChange)="onPickerChange($event)"
/>
</div>
`,
styles: [
`
/* Angular view encapsulation stops the host page's .inv-form-field styles from reaching
* inside this component's own template, so the look of the surrounding form fields
* (height, border, radius, font) is deliberately re-declared here instead of inherited. */
:host {
display: block;
width: 100%;
}
.inv-date-field {
position: relative;
display: flex;
align-items: center;
gap: 6px;
width: 100%;
}
.inv-date-field__text {
flex: 1;
min-width: 0;
width: 100%;
height: 36px;
padding: 0 10px;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 13px;
color: #0d1b2a;
font-family: inherit;
background: #fff;
box-sizing: border-box;
}
.inv-date-field__btn {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
height: 36px;
width: 36px;
padding: 0;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
cursor: pointer;
font-size: 14px;
line-height: 1;
}
.inv-date-field__btn:hover:not(:disabled) {
background: #f2f4f7;
}
.inv-date-field__btn:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.inv-date-field__native {
position: absolute;
width: 0;
height: 0;
padding: 0;
border: none;
opacity: 0;
pointer-events: none;
}
`
]
})
export class DateDdmmyyyyPickerComponent implements ControlValueAccessor {
@ViewChild('native') private nativeInput?: ElementRef<HTMLInputElement>;
value = '';
disabled = false;
private onChangeFn: (v: string) => void = () => {};
onTouched: () => void = () => {};
/** DD-MM-YYYY -> yyyy-MM-dd, the format <input type="date"> requires. */
get isoValue(): string {
const parts = (this.value || '').split('-');
if (parts.length !== 3) {
return '';
}
const [
dd,
mm,
yyyy
] = parts;
if (!/^\d{2}$/.test(dd) || !/^\d{2}$/.test(mm) || !/^\d{4}$/.test(yyyy)) {
return '';
}
return `${yyyy}-${mm}-${dd}`;
}
writeValue(v: string): void {
this.value = v ?? '';
}
registerOnChange(fn: (v: string) => void): void {
this.onChangeFn = fn;
}
registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
}
onTextChange(v: string): void {
this.value = v;
this.onChangeFn(v);
}
/** yyyy-MM-dd (from the native picker) -> DD-MM-YYYY, the format the rest of the app expects. */
onPickerChange(iso: string): void {
const parts = (iso || '').split('-');
if (parts.length !== 3) {
return;
}
const [
yyyy,
mm,
dd
] = parts;
const ddmmyyyy = `${dd}-${mm}-${yyyy}`;
this.value = ddmmyyyy;
this.onChangeFn(ddmmyyyy);
this.onTouched();
}
openPicker(): void {
const el = this.nativeInput?.nativeElement;
if (!el || this.disabled) {
return;
}
const showPicker = (el as HTMLInputElement & { showPicker?: () => void }).showPicker;
if (typeof showPicker === 'function') {
try {
showPicker.call(el);
return;
} catch {
// Some browsers throw if not called from a "user gesture" context they recognise —
// fall back to a plain click(), which still opens the native picker.
}
}
el.click();
}
}
@@ -179,8 +179,8 @@
><span>Certificate No.</span><input type="text" [(ngModel)]="purchaseForm.certNo" ><span>Certificate No.</span><input type="text" [(ngModel)]="purchaseForm.certNo"
/></label> /></label>
<label class="inv-form-field" <label class="inv-form-field"
><span>Certificate Date</span><input type="text" [(ngModel)]="purchaseForm.certDate" placeholder="DD-MM-YYYY" ><span>Certificate Date</span><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.certDate"></mifosx-date-ddmmyyyy
/></label> ></label>
<label class="inv-form-field"> <label class="inv-form-field">
<span>Sub Type</span> <span>Sub Type</span>
<select [(ngModel)]="purchaseForm.subType"> <select [(ngModel)]="purchaseForm.subType">
@@ -198,12 +198,12 @@
</label> </label>
<label class="inv-form-field" <label class="inv-form-field"
><span>Transaction Date</span ><span>Transaction Date</span
><input type="text" [(ngModel)]="purchaseForm.transactionDate" placeholder="DD-MM-YYYY" ><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.transactionDate"></mifosx-date-ddmmyyyy
/></label> ></label>
<label class="inv-form-field" <label class="inv-form-field"
><span>Deposit Date *</span ><span>Deposit Date *</span
><input type="text" [(ngModel)]="purchaseForm.depositDate" placeholder="DD-MM-YYYY" ><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.depositDate"></mifosx-date-ddmmyyyy
/></label> ></label>
<label class="inv-form-field" <label class="inv-form-field"
><span>Deposit Amount *</span><input type="number" [(ngModel)]="purchaseForm.depositAmount" min="0" ><span>Deposit Amount *</span><input type="number" [(ngModel)]="purchaseForm.depositAmount" min="0"
/></label> /></label>
@@ -217,8 +217,8 @@
</label> </label>
<label class="inv-form-field" <label class="inv-form-field"
><span>Interest Date</span ><span>Interest Date</span
><input type="text" [(ngModel)]="purchaseForm.interestDate" placeholder="DD-MM-YYYY" ><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.interestDate"></mifosx-date-ddmmyyyy
/></label> ></label>
<label class="inv-form-field" <label class="inv-form-field"
><span>Interest Rate *</span><input type="number" [(ngModel)]="purchaseForm.interestRate" min="0" step="0.01" ><span>Interest Rate *</span><input type="number" [(ngModel)]="purchaseForm.interestRate" min="0" step="0.01"
/></label> /></label>
@@ -235,8 +235,8 @@
} @else { } @else {
<label class="inv-form-field" <label class="inv-form-field"
><span>Maturity Date (manual) *</span ><span>Maturity Date (manual) *</span
><input type="text" [(ngModel)]="purchaseForm.manualMaturityDate" placeholder="DD-MM-YYYY" ><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.manualMaturityDate"></mifosx-date-ddmmyyyy
/></label> ></label>
} }
<label class="inv-form-field" <label class="inv-form-field"
><span>Resolution No.</span><input type="text" [(ngModel)]="purchaseForm.resolutionNo" ><span>Resolution No.</span><input type="text" [(ngModel)]="purchaseForm.resolutionNo"
@@ -327,11 +327,25 @@
<span>Certificate No *</span> <span>Certificate No *</span>
<select [(ngModel)]="interestCertNo"> <select [(ngModel)]="interestCertNo">
<option value="">-- Select --</option> <option value="">-- Select --</option>
@for (h of activeHoldings; track h.certNo) { @for (h of interestEligibleHoldings; track h.certNo) {
<option [value]="h.certNo">{{ h.certNo }} — {{ h.party }}</option> <option [value]="h.certNo">{{ h.certNo }} — {{ h.party }}</option>
} }
</select> </select>
</label> </label>
<label class="inv-form-field"
><span>From Date</span><mifosx-date-ddmmyyyy [(ngModel)]="interestFromDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"
><span>To Date</span><mifosx-date-ddmmyyyy [(ngModel)]="interestToDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field">
<span>Post To (Interest Income GL)</span>
<select [(ngModel)]="interestGlCode">
@for (g of interestIncomeGlOptions; track g.glCode) {
<option [value]="g.glCode">{{ g.glCode }} — {{ g.glName }}</option>
}
</select>
</label>
@if (interestHolding; as h) { @if (interestHolding; as h) {
<label class="inv-form-field"><span>Party</span><input type="text" [value]="h.party" readonly /></label> <label class="inv-form-field"><span>Party</span><input type="text" [value]="h.party" readonly /></label>
<label class="inv-form-field" <label class="inv-form-field"
@@ -341,7 +355,7 @@
><span>Frequency</span><input type="text" [value]="h.interestFreq" readonly ><span>Frequency</span><input type="text" [value]="h.interestFreq" readonly
/></label> /></label>
<label class="inv-form-field" <label class="inv-form-field"
><span>Accrued Interest (as on {{ asOnDate }})</span ><span>Interest for Period</span
><input type="text" [value]="'₹' + (interestAccrued(h) | number: '1.2-2')" readonly ><input type="text" [value]="'₹' + (interestAccrued(h) | number: '1.2-2')" readonly
/></label> /></label>
} }
@@ -376,8 +390,8 @@
/></label> /></label>
} }
<label class="inv-form-field" <label class="inv-form-field"
><span>Sell / Premature Date *</span><input type="text" [(ngModel)]="sellDate" placeholder="DD-MM-YYYY" ><span>Sell / Premature Date *</span><mifosx-date-ddmmyyyy [(ngModel)]="sellDate"></mifosx-date-ddmmyyyy
/></label> ></label>
<label class="inv-form-field"><span>Remarks</span><input type="text" [(ngModel)]="sellRemark" /></label> <label class="inv-form-field"><span>Remarks</span><input type="text" [(ngModel)]="sellRemark" /></label>
</div> </div>
<div class="inv-form-actions"> <div class="inv-form-actions">
@@ -454,6 +468,7 @@
<th>Cert No</th> <th>Cert No</th>
<th>Type</th> <th>Type</th>
<th>Particulars</th> <th>Particulars</th>
<th>GL Posted</th>
<th class="num">Amount</th> <th class="num">Amount</th>
</tr> </tr>
</thead> </thead>
@@ -467,6 +482,7 @@
<td>{{ t.certNo }}</td> <td>{{ t.certNo }}</td>
<td>{{ t.txnType }}</td> <td>{{ t.txnType }}</td>
<td>{{ t.particulars }}</td> <td>{{ t.particulars }}</td>
<td>{{ t.glCode ? t.glCode + ' — ' + t.glName : '—' }}</td>
<td class="num">₹{{ t.amount | number: '1.2-2' }}</td> <td class="num">₹{{ t.amount | number: '1.2-2' }}</td>
</tr> </tr>
} }
@@ -9,6 +9,7 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { DateDdmmyyyyPickerComponent } from '../date-ddmmyyyy-picker/date-ddmmyyyy-picker.component';
import { import {
FD_HOLDINGS, FD_HOLDINGS,
ALL_TXNS, ALL_TXNS,
@@ -17,10 +18,14 @@ import {
FD_TYPE_OPTIONS, FD_TYPE_OPTIONS,
INT_TYPE_OPTIONS, INT_TYPE_OPTIONS,
LIEN_REASON_MASTER, LIEN_REASON_MASTER,
INTEREST_INCOME_GL_MASTER,
INTEREST_INCOME_GL_BY_TYPE,
InvestmentHolding, InvestmentHolding,
InvestmentTxn, InvestmentTxn,
InterestFreq, InterestFreq,
accruedInterest, accruedInterest,
accruedInterestBetween,
canAccrueInterest,
daysToMaturity, daysToMaturity,
maturityAmount, maturityAmount,
addPeriod, addPeriod,
@@ -83,7 +88,8 @@ interface FdPurchaseForm {
standalone: true, standalone: true,
imports: [ imports: [
CommonModule, CommonModule,
FormsModule FormsModule,
DateDdmmyyyyPickerComponent
] ]
}) })
export class FdInvestmentComponent { export class FdInvestmentComponent {
@@ -98,6 +104,7 @@ export class FdInvestmentComponent {
readonly fdTypeOptions = FD_TYPE_OPTIONS; readonly fdTypeOptions = FD_TYPE_OPTIONS;
readonly intTypeOptions = INT_TYPE_OPTIONS; readonly intTypeOptions = INT_TYPE_OPTIONS;
readonly lienReasonOptions = LIEN_REASON_MASTER; readonly lienReasonOptions = LIEN_REASON_MASTER;
readonly interestIncomeGlOptions = INTEREST_INCOME_GL_MASTER;
readonly branchOptions = [ readonly branchOptions = [
'HEAD OFFICE', 'HEAD OFFICE',
'BRANCH 1', 'BRANCH 1',
@@ -114,6 +121,9 @@ export class FdInvestmentComponent {
searchCertNo = ''; searchCertNo = '';
interestCertNo = ''; interestCertNo = '';
interestFromDate = addPeriod(this.asOnDate, 0, -3);
interestToDate = this.asOnDate;
interestGlCode = INTEREST_INCOME_GL_BY_TYPE.FD;
sellCertNo = ''; sellCertNo = '';
sellDate = this.asOnDate; sellDate = this.asOnDate;
sellRemark = ''; sellRemark = '';
@@ -313,12 +323,24 @@ export class FdInvestmentComponent {
} }
// -------------------- Interest -------------------- // -------------------- Interest --------------------
/** Certificates eligible for accrual-basis posting — same rule as the Investment Interest
* module: a Non-Performing holding never appears here, only through Interest Receive. */
get interestEligibleHoldings(): InvestmentHolding[] {
return this.activeHoldings.filter((h) => canAccrueInterest(h));
}
get interestHolding(): InvestmentHolding | undefined { get interestHolding(): InvestmentHolding | undefined {
return this.holdings.find((h) => h.certNo === this.interestCertNo); return this.holdings.find((h) => h.certNo === this.interestCertNo);
} }
/** Interest accrued strictly within [interestFromDate, interestToDate] — a period-based
* Interest Calculation & Post run, same basis as the Investment Interest module. */
interestAccrued(h: InvestmentHolding): number { interestAccrued(h: InvestmentHolding): number {
return accruedInterest(h, this.asOnDate); return accruedInterestBetween(h, this.interestFromDate || h.purchaseDate, this.interestToDate || this.asOnDate);
}
glName(glCode: string): string {
return this.interestIncomeGlOptions.find((g) => g.glCode === glCode)?.glName ?? glCode;
} }
postInterest(): void { postInterest(): void {
@@ -328,19 +350,34 @@ export class FdInvestmentComponent {
return; return;
} }
const amt = this.interestAccrued(h); const amt = this.interestAccrued(h);
if (amt <= 0) {
this.toast('No interest due to be posted for this period.');
return;
}
const toDate = this.interestToDate || this.asOnDate;
const fromDate = this.interestFromDate || toDate;
const glCode = this.interestGlCode;
const glName = this.glName(glCode);
h.interestPosted = (h.interestPosted ?? 0) + amt;
h.interestPostedTillDate = toDate;
this.interestFromDate = toDate;
this.txns = [ this.txns = [
{ {
certNo: h.certNo, certNo: h.certNo,
instrumentType: 'FD', instrumentType: 'FD',
txnType: 'INTEREST', txnType: 'INTEREST',
voucherNo: `INV/INT/FD-${h.certNo}-${this.txns.length + 1}`, voucherNo: `INV/INT/FD-${h.certNo}-${this.txns.length + 1}`,
txnDate: this.asOnDate, txnDate: toDate,
amount: amt, amount: amt,
particulars: `Interest posted - ${h.party} (${h.interestFreq})` particulars: `Interest posted (accrual) for period ${fromDate} to ${toDate} - ${h.party} (${h.interestFreq})`,
glCode,
glName
}, },
...this.txns ...this.txns
]; ];
this.toast(`Interest of ₹${amt.toLocaleString('en-IN')} posted for Certificate ${h.certNo}.`); this.toast(
`Interest of ₹${amt.toLocaleString('en-IN')} posted to ${glCode} - ${glName} for Certificate ${h.certNo}.`
);
this.interestCertNo = ''; this.interestCertNo = '';
} }
@@ -85,10 +85,16 @@
<div class="pg-card"> <div class="pg-card">
<div class="pos-section-label">Portfolio Allocation — Target vs Actual</div> <div class="pos-section-label">Portfolio Allocation — Target vs Actual</div>
<p class="pg-sub">
Govt Security, Non-SLR, Mutual Fund and Share targets trace to RBI's SLR floor (18% of NDTL) and Non-SLR ceiling
(≤10% of deposits) for Urban Co-operative Banks; Fixed Deposit and Bond are board-approved policy choices within
the room that framework leaves.
</p>
<table class="pg-table"> <table class="pg-table">
<thead> <thead>
<tr> <tr>
<th>Instrument Type</th> <th>Instrument Type</th>
<th>Target Basis</th>
<th class="num">Target %</th> <th class="num">Target %</th>
<th class="num">Actual %</th> <th class="num">Actual %</th>
<th class="num">Actual Value</th> <th class="num">Actual Value</th>
@@ -100,6 +106,11 @@
@for (a of allocationVsTarget; track a.type) { @for (a of allocationVsTarget; track a.type) {
<tr> <tr>
<td>{{ a.label }}</td> <td>{{ a.label }}</td>
<td>
<span class="inv-badge" [class.inv-badge--matured]="a.isRbiLinked">
{{ a.isRbiLinked ? 'RBI SLR / Non-SLR Norm' : 'Board Policy' }}
</span>
</td>
<td class="num">{{ a.targetPct }}%</td> <td class="num">{{ a.targetPct }}%</td>
<td class="num">{{ a.actualPct }}%</td> <td class="num">{{ a.actualPct }}%</td>
<td class="num">₹{{ a.actualValue | number: '1.2-2' }}</td> <td class="num">₹{{ a.actualValue | number: '1.2-2' }}</td>
@@ -11,6 +11,7 @@ import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { import {
ALL_HOLDINGS, ALL_HOLDINGS,
ALLOCATION_TARGET_IS_RBI_LINKED,
INSTRUMENT_LABELS, INSTRUMENT_LABELS,
InstrumentType, InstrumentType,
InvestmentHolding, InvestmentHolding,
@@ -39,6 +40,9 @@ interface AllocationRow {
actualValue: number; actualValue: number;
variancePct: number; variancePct: number;
statusLabel: 'Within Target' | 'Over-Allocated' | 'Under-Allocated'; statusLabel: 'Within Target' | 'Over-Allocated' | 'Under-Allocated';
/** True when the target itself traces to an RBI SLR/Non-SLR rule rather than being a
* board-only policy choice — see TARGET_ALLOCATION_PCT's doc comment in investment-data.ts. */
isRbiLinked: boolean;
} }
/** Cross-instrument portfolio dashboard — every figure is derived from ALL_HOLDINGS, never /** Cross-instrument portfolio dashboard — every figure is derived from ALL_HOLDINGS, never
@@ -114,7 +118,8 @@ export class InvestmentDashboardComponent {
actualPct: +actualPct.toFixed(1), actualPct: +actualPct.toFixed(1),
actualValue: value, actualValue: value,
variancePct, variancePct,
statusLabel statusLabel,
isRbiLinked: ALLOCATION_TARGET_IS_RBI_LINKED[type]
}; };
}); });
} }
@@ -195,6 +200,7 @@ export class InvestmentDashboardComponent {
subtitle: `As on ${this.asOnDate}`, subtitle: `As on ${this.asOnDate}`,
headers: [ headers: [
'Instrument Type', 'Instrument Type',
'Target Basis',
'Target %', 'Target %',
'Actual %', 'Actual %',
'Actual Value', 'Actual Value',
@@ -203,6 +209,7 @@ export class InvestmentDashboardComponent {
], ],
widths: [ widths: [
26, 26,
20,
12, 12,
12, 12,
18, 18,
@@ -211,6 +218,7 @@ export class InvestmentDashboardComponent {
], ],
rows: this.allocationVsTarget.map((a) => [ rows: this.allocationVsTarget.map((a) => [
a.label, a.label,
a.isRbiLinked ? 'RBI SLR / Non-SLR Norm' : 'Board Policy',
a.targetPct, a.targetPct,
a.actualPct, a.actualPct,
a.actualValue, a.actualValue,
+361 -17
View File
@@ -53,16 +53,45 @@ export const PROVISION_STATUS_LABELS: Record<InvestmentProvisionStatus, string>
LOSS: 'Loss (NPI)' LOSS: 'Loss (NPI)'
}; };
/** Board-approved portfolio-policy target — % of the total investment corpus each instrument /**
* type should hold. Compared against actual holdings on the Dashboard so a treasury desk can * Portfolio-policy target — % of the total investment corpus each instrument type should hold.
* see at a glance where the book has drifted from policy. */ * Compared against actual holdings on the Dashboard so a treasury desk can see at a glance where
* the book has drifted from policy. Four of these six trace back to real RBI rules for Urban
* Co-operative Banks (RBI Master Direction/Circular — Investment Portfolio of Primary (Urban)
* Co-operative Banks):
* • SLR: UCBs must hold 18% of NDTL in approved securities — predominantly Central/State Govt
* securities (and SLR-eligible State Development Loan bonds). A well-run treasury holds a
* safety buffer above the bare floor, so Govt Security dominates the book far beyond the 18%
* minimum — it isn't itself an upper target, just the reason this bucket is the largest.
* • Non-SLR ceiling: a UCB's Non-SLR investments must not exceed 10% of its total deposits as
* on 31 March of the previous year — a hard regulatory cap, not a board choice.
* • Mutual Funds: RBI permits UCBs to invest only in debt-oriented schemes of SEBI-registered
* mutual funds, in strictly limited amounts — equity/balanced schemes are not permitted.
* • Shares: RBI does not permit UCBs to invest in equity shares of companies as a general
* investment class; any "Share" holding is essentially the (small, largely static) shares a
* UCB holds in affiliated co-operative institutions, not a discretionary trading book.
* Fixed Deposit (treasury placements with other banks) and Bond (a mix of SLR-eligible SDL and
* Non-SLR PSU/FI bonds) have no RBI-mandated percentage of their own — their split here is a
* board-approved policy choice within the room RBI's SLR/Non-SLR framework leaves.
*/
export const TARGET_ALLOCATION_PCT: Record<InstrumentType, number> = { export const TARGET_ALLOCATION_PCT: Record<InstrumentType, number> = {
GOVT_SECURITY: 55,
BOND: 18,
FD: 15, FD: 15,
GOVT_SECURITY: 35, NON_SLR: 7,
NON_SLR: 10, MUTUAL_FUND: 3,
BOND: 20, SHARE: 2
MUTUAL_FUND: 12, };
SHARE: 8
/** True for the instrument types whose target traces to an actual RBI rule (SLR floor / Non-SLR
* ceiling) rather than a board-only policy choice — see TARGET_ALLOCATION_PCT's doc comment. */
export const ALLOCATION_TARGET_IS_RBI_LINKED: Record<InstrumentType, boolean> = {
GOVT_SECURITY: true,
BOND: false,
FD: false,
NON_SLR: true,
MUTUAL_FUND: true,
SHARE: true
}; };
export interface InvestmentHolding { export interface InvestmentHolding {
@@ -90,6 +119,10 @@ export interface InvestmentHolding {
interestPosted?: number; interestPosted?: number;
/** Cumulative interest actually received in cash to date. */ /** Cumulative interest actually received in cash to date. */
interestReceived?: number; interestReceived?: number;
/** DD-MM-YYYY through which interest has already been calculated & posted. accruedInterestBetween()
* never re-counts days up to this date, so re-running a Calculation & Post for an overlapping
* or repeated period always yields 0 instead of double-posting the same interest. */
interestPostedTillDate?: string;
} }
export interface InvestmentTxn { export interface InvestmentTxn {
@@ -100,6 +133,10 @@ export interface InvestmentTxn {
txnDate: string; txnDate: string;
amount: number; amount: number;
particulars: string; particulars: string;
/** GL account this entry was posted/received against — the Interest Income GL for a Post,
* the Cash/Bank GL for a Receive. Absent on older/seeded entries that predate GL selection. */
glCode?: string;
glName?: string;
} }
// -------------------- Date / interest helpers -------------------- // -------------------- Date / interest helpers --------------------
@@ -175,15 +212,6 @@ export function canAccrueInterest(h: InvestmentHolding): boolean {
return !isNpi(h); return !isNpi(h);
} }
/** Interest still to be calculated & posted this cycle. Always 0 for an NPI holding — posting
* an accrual on a non-performing investment would overstate income. */
export function interestToPost(h: InvestmentHolding, asOnStr: string): number {
if (!canAccrueInterest(h)) {
return 0;
}
return Math.max(0, accruedInterest(h, asOnStr) - (h.interestPosted ?? 0));
}
/** Outstanding "Interest Receivable" GL balance — interest already posted (booked as income) /** Outstanding "Interest Receivable" GL balance — interest already posted (booked as income)
* but not yet actually received in cash. Always 0 for an NPI holding, since nothing is ever * but not yet actually received in cash. Always 0 for an NPI holding, since nothing is ever
* posted as a receivable on one. */ * posted as a receivable on one. */
@@ -203,6 +231,23 @@ export function suspenseInterest(h: InvestmentHolding, asOnStr: string): number
return Math.max(0, accruedInterest(h, asOnStr) - (h.interestReceived ?? 0)); return Math.max(0, accruedInterest(h, asOnStr) - (h.interestReceived ?? 0));
} }
/** Interest accrued strictly within [fromStr, toStr], clipped to the holding's own
* purchase→maturity window — the basis for a period-based Interest Calculation & Post run
* (as opposed to accruedInterest(), which is always measured from purchase date). Returns 0
* for a Non-Performing holding, same accrual-basis rule as accruedInterest(). */
export function accruedInterestBetween(h: InvestmentHolding, fromStr: string, toStr: string): number {
if (!canAccrueInterest(h)) {
return 0;
}
const purchase = parseDDMMYYYY(h.purchaseDate).getTime();
const maturity = parseDDMMYYYY(h.maturityDate).getTime();
const postedTill = h.interestPostedTillDate ? parseDDMMYYYY(h.interestPostedTillDate).getTime() : purchase;
const from = Math.max(parseDDMMYYYY(fromStr).getTime(), purchase, postedTill);
const to = Math.min(parseDDMMYYYY(toStr).getTime(), maturity);
const days = Math.max(0, (to - from) / (24 * 3600 * 1000));
return Math.round(h.faceValue * (h.interestRate / 100) * (days / 365.25));
}
export function formatDDMMYYYY(d: Date): string { export function formatDDMMYYYY(d: Date): string {
return `${String(d.getDate()).padStart(2, '0')}-${String(d.getMonth() + 1).padStart(2, '0')}-${d.getFullYear()}`; return `${String(d.getDate()).padStart(2, '0')}-${String(d.getMonth() + 1).padStart(2, '0')}-${d.getFullYear()}`;
} }
@@ -388,6 +433,68 @@ export const FD_HOLDINGS: InvestmentHolding[] = [
crToAc: '310070', crToAc: '310070',
brokerCode: '', brokerCode: '',
status: 'ACTIVE' status: 'ACTIVE'
},
{
certNo: '15628',
instrumentType: 'FD',
scriptCode: 'FD-CUM',
scriptName: 'Cumulative Fixed Deposit',
party: 'Zilla Sahakari Bank Ltd',
subType: 'NON SLR',
faceValue: 1800000,
purchaseRate: 100,
units: 1,
purchaseDate: '20-08-2025',
maturityDate: '20-08-2026',
interestRate: 6.8,
interestFreq: 'CUMULATIVE',
glCode: '320020',
glName: 'FIXED DEPO. CO-OPER. BANK MAIN',
crToAc: '310070',
brokerCode: '',
status: 'ACTIVE'
},
{
certNo: '15629',
instrumentType: 'FD',
scriptCode: 'FD-MIP',
scriptName: 'Monthly Interest Payout FD',
party: 'Vidarbha Cooperative Bank',
subType: 'NON SLR',
faceValue: 2200000,
purchaseRate: 100,
units: 1,
purchaseDate: '25-09-2025',
maturityDate: '25-09-2026',
interestRate: 7.15,
interestFreq: 'MONTHLY',
glCode: '320020',
glName: 'FIXED DEPO. CO-OPER. BANK MAIN',
crToAc: '310070',
brokerCode: '',
status: 'ACTIVE',
interestPosted: 143220,
interestReceived: 130800
},
{
certNo: '15630',
instrumentType: 'FD',
scriptCode: 'FD-CUM',
scriptName: 'Cumulative Fixed Deposit',
party: 'Konkan Cooperative Bank Ltd',
subType: 'NON SLR',
faceValue: 900000,
purchaseRate: 100,
units: 1,
purchaseDate: '28-10-2024',
maturityDate: '28-10-2026',
interestRate: 6.75,
interestFreq: 'YEARLY',
glCode: '320020',
glName: 'FIXED DEPO. CO-OPER. BANK MAIN',
crToAc: '310070',
brokerCode: '',
status: 'ACTIVE'
} }
]; ];
@@ -515,6 +622,46 @@ export const GOVT_SECURITY_HOLDINGS: InvestmentHolding[] = [
crToAc: '310070', crToAc: '310070',
brokerCode: 'BRK-002', brokerCode: 'BRK-002',
status: 'ACTIVE' status: 'ACTIVE'
},
{
certNo: 'GSEC-2007',
instrumentType: 'GOVT_SECURITY',
scriptCode: 'TBILL-91D',
scriptName: '91 Days Treasury Bill',
party: 'RBI - SGL',
subType: 'SLR',
faceValue: 2000000,
purchaseRate: 98.2,
units: 20000,
purchaseDate: '15-05-2026',
maturityDate: '13-08-2026',
interestRate: 6.2,
interestFreq: 'CUMULATIVE',
glCode: '310020',
glName: 'INVESTMENT IN GOVT. SECURITIES',
crToAc: '310070',
brokerCode: 'BRK-003',
status: 'ACTIVE'
},
{
certNo: 'GSEC-2008',
instrumentType: 'GOVT_SECURITY',
scriptCode: 'GJ-SDL-2026',
scriptName: '7.85% Gujarat SDL 2026',
party: 'RBI - SGL',
subType: 'SLR',
faceValue: 1800000,
purchaseRate: 100.2,
units: 18000,
purchaseDate: '18-09-2025',
maturityDate: '18-09-2026',
interestRate: 7.85,
interestFreq: 'HALF_YEARLY',
glCode: '310020',
glName: 'INVESTMENT IN GOVT. SECURITIES',
crToAc: '310070',
brokerCode: 'BRK-001',
status: 'ACTIVE'
} }
]; ];
@@ -620,6 +767,46 @@ export const NON_SLR_HOLDINGS: InvestmentHolding[] = [
status: 'ACTIVE', status: 'ACTIVE',
interestPosted: 89400, interestPosted: 89400,
interestReceived: 60000 interestReceived: 60000
},
{
certNo: 'NSLR-3006',
instrumentType: 'NON_SLR',
scriptCode: 'IRFC-7.55-2026',
scriptName: 'IRFC Bond 7.55% 2026',
party: 'Indian Railway Finance Corp',
subType: 'NON SLR',
faceValue: 1600000,
purchaseRate: 100,
units: 16000,
purchaseDate: '25-08-2025',
maturityDate: '25-08-2026',
interestRate: 7.55,
interestFreq: 'YEARLY',
glCode: '310030',
glName: 'INVESTMENT IN NON SLR SECURITIES',
crToAc: '310070',
brokerCode: 'BRK-004',
status: 'ACTIVE'
},
{
certNo: 'NSLR-3007',
instrumentType: 'NON_SLR',
scriptCode: 'NHB-7.3-2026',
scriptName: 'NHB Bond 7.30% 2026',
party: 'National Housing Bank',
subType: 'NON SLR',
faceValue: 1000000,
purchaseRate: 100,
units: 10000,
purchaseDate: '10-10-2025',
maturityDate: '10-10-2026',
interestRate: 7.3,
interestFreq: 'YEARLY',
glCode: '310030',
glName: 'INVESTMENT IN NON SLR SECURITIES',
crToAc: '310070',
brokerCode: 'BRK-003',
status: 'ACTIVE'
} }
]; ];
@@ -726,6 +913,46 @@ export const BOND_HOLDINGS: InvestmentHolding[] = [
crToAc: '310070', crToAc: '310070',
brokerCode: 'BRK-005', brokerCode: 'BRK-005',
status: 'ACTIVE' status: 'ACTIVE'
},
{
certNo: 'BOND-4006',
instrumentType: 'BOND',
scriptCode: 'CHOLA-8.35-2026',
scriptName: 'Cholamandalam Finance NCD 8.35% 2026',
party: 'Cholamandalam Investment & Finance',
subType: 'CORPORATE BOND',
faceValue: 1400000,
purchaseRate: 100,
units: 14000,
purchaseDate: '15-09-2024',
maturityDate: '15-09-2026',
interestRate: 8.35,
interestFreq: 'YEARLY',
glCode: '310040',
glName: 'INVESTMENT IN CORPORATE BONDS',
crToAc: '310070',
brokerCode: 'BRK-006',
status: 'ACTIVE'
},
{
certNo: 'BOND-4007',
instrumentType: 'BOND',
scriptCode: 'PEL-8.4-2026',
scriptName: 'Piramal Enterprises NCD 8.40% 2026',
party: 'Piramal Enterprises Ltd',
subType: 'CORPORATE BOND',
faceValue: 900000,
purchaseRate: 100,
units: 9000,
purchaseDate: '28-10-2025',
maturityDate: '28-10-2026',
interestRate: 8.4,
interestFreq: 'YEARLY',
glCode: '310040',
glName: 'INVESTMENT IN CORPORATE BONDS',
crToAc: '310070',
brokerCode: 'BRK-005',
status: 'ACTIVE'
} }
]; ];
@@ -829,6 +1056,46 @@ export const MUTUAL_FUND_HOLDINGS: InvestmentHolding[] = [
crToAc: '310070', crToAc: '310070',
brokerCode: '', brokerCode: '',
status: 'ACTIVE' status: 'ACTIVE'
},
{
certNo: 'MF-5006',
instrumentType: 'MUTUAL_FUND',
scriptCode: 'SBI-BPSU',
scriptName: 'SBI Banking & PSU Fund',
party: 'SBI Mutual Fund',
subType: 'DEBT - BANKING & PSU',
faceValue: 700000,
purchaseRate: 18.6,
units: 37634.4,
purchaseDate: '10-09-2025',
maturityDate: '10-09-2026',
interestRate: 7.1,
interestFreq: 'CUMULATIVE',
glCode: '310050',
glName: 'INVESTMENT IN MUTUAL FUNDS',
crToAc: '310070',
brokerCode: '',
status: 'ACTIVE'
},
{
certNo: 'MF-5007',
instrumentType: 'MUTUAL_FUND',
scriptCode: 'ICICI-CB',
scriptName: 'ICICI Pru Corporate Bond Fund',
party: 'ICICI Prudential Mutual Fund',
subType: 'DEBT - CORPORATE BOND',
faceValue: 1100000,
purchaseRate: 24.8,
units: 44354.84,
purchaseDate: '14-07-2026',
maturityDate: '14-07-2027',
interestRate: 7.05,
interestFreq: 'CUMULATIVE',
glCode: '310050',
glName: 'INVESTMENT IN MUTUAL FUNDS',
crToAc: '310070',
brokerCode: '',
status: 'ACTIVE'
} }
]; ];
@@ -932,6 +1199,46 @@ export const SHARE_HOLDINGS: InvestmentHolding[] = [
crToAc: '310070', crToAc: '310070',
brokerCode: '', brokerCode: '',
status: 'ACTIVE' status: 'ACTIVE'
},
{
certNo: 'SH-6006',
instrumentType: 'SHARE',
scriptCode: 'ICICIBANK',
scriptName: 'ICICI Bank Ltd',
party: 'NSE/BSE',
subType: 'EQUITY',
faceValue: 420000,
purchaseRate: 1050,
units: 400,
purchaseDate: '05-03-2026',
maturityDate: '05-03-2036',
interestRate: 2.0,
interestFreq: 'YEARLY',
glCode: '310060',
glName: 'INVESTMENT IN SHARES',
crToAc: '310070',
brokerCode: 'BRK-007',
status: 'ACTIVE'
},
{
certNo: 'SH-6007',
instrumentType: 'SHARE',
scriptCode: 'PDCC',
scriptName: 'Pune District Central Coop Bank Shares',
party: 'Pune District Central Cooperative Bank',
subType: 'CO-OP SHARE',
faceValue: 60000,
purchaseRate: 50,
units: 1200,
purchaseDate: '18-07-2025',
maturityDate: '18-07-2035',
interestRate: 5.0,
interestFreq: 'YEARLY',
glCode: '310060',
glName: 'INVESTMENT IN SHARES',
crToAc: '310070',
brokerCode: '',
status: 'ACTIVE'
} }
]; ];
@@ -1060,6 +1367,43 @@ export const ALL_TXNS: InvestmentTxn[] = [
// -------------------- Masters -------------------- // -------------------- Masters --------------------
export interface InvestmentGlOption {
glCode: string;
glName: string;
}
/** Interest Income (Cr) GL per instrument type — the GL an Interest Calculation & Post run
* credits. Selectable on the FD/Govt./Bond Interest Calculation screens instead of being
* hard-wired, so a period's posting can be routed to a different income head when needed. */
export const INTEREST_INCOME_GL_MASTER: InvestmentGlOption[] = [
{ glCode: '410010', glName: 'INTEREST ON GOVT. SECURITIES' },
{ glCode: '410020', glName: 'INTEREST ON NON SLR SECURITIES' },
{ glCode: '410030', glName: 'INTEREST ON CORPORATE BONDS' },
{ glCode: '410040', glName: 'INTEREST ON FIXED DEPOSITS' },
{ glCode: '410050', glName: 'INCOME FROM MUTUAL FUNDS' },
{ glCode: '410060', glName: 'DIVIDEND ON SHARES' }
];
/** Default Interest Income GL suggested per instrument type — pre-fills the selector above,
* which the user can still override before posting. */
export const INTEREST_INCOME_GL_BY_TYPE: Record<InstrumentType, string> = {
GOVT_SECURITY: '410010',
NON_SLR: '410020',
BOND: '410030',
FD: '410040',
MUTUAL_FUND: '410050',
SHARE: '410060'
};
/** Cash/Bank (Dr) GL credited when an Interest Receive entry is booked — the account the cash
* actually lands in. Selectable on the Interest Receive screen. */
export const CASH_BANK_GL_MASTER: InvestmentGlOption[] = [
{ glCode: '100010', glName: 'CASH IN HAND' },
{ glCode: '110010', glName: 'RBI CURRENT ACCOUNT' },
{ glCode: '110020', glName: 'SBI CURRENT ACCOUNT' },
{ glCode: '110030', glName: 'HDFC BANK CURRENT ACCOUNT' }
];
export interface ScriptMaster { export interface ScriptMaster {
scriptCode: string; scriptCode: string;
scriptName: string; scriptName: string;
@@ -184,8 +184,11 @@
<div class="pos-section-label">Investment - FD Interest Calculation &amp; Post</div> <div class="pos-section-label">Investment - FD Interest Calculation &amp; Post</div>
<div class="inv-form-grid"> <div class="inv-form-grid">
<label class="inv-form-field" <label class="inv-form-field"
><span>Date</span><input type="text" [(ngModel)]="fdCalcDate" placeholder="DD-MM-YYYY" ><span>From Date</span><mifosx-date-ddmmyyyy [(ngModel)]="fdCalcFromDate"></mifosx-date-ddmmyyyy
/></label> ></label>
<label class="inv-form-field"
><span>To Date</span><mifosx-date-ddmmyyyy [(ngModel)]="fdCalcToDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"> <label class="inv-form-field">
<span>Certificate No</span> <span>Certificate No</span>
<select [(ngModel)]="fdCalcCertNo"> <select [(ngModel)]="fdCalcCertNo">
@@ -198,6 +201,14 @@
<label class="inv-form-field" <label class="inv-form-field"
><span>ALL</span><input type="checkbox" [(ngModel)]="fdCalcAll" style="height: 18px; width: 18px" ><span>ALL</span><input type="checkbox" [(ngModel)]="fdCalcAll" style="height: 18px; width: 18px"
/></label> /></label>
<label class="inv-form-field">
<span>Post To (Interest Income GL)</span>
<select [(ngModel)]="fdCalcGlCode">
@for (g of interestIncomeGlOptions; track g.glCode) {
<option [value]="g.glCode">{{ g.glCode }} — {{ g.glName }}</option>
}
</select>
</label>
<label class="inv-form-field" <label class="inv-form-field"
><span>Total Interest to Post</span ><span>Total Interest to Post</span
><input type="text" [value]="'₹' + (fdCalcTotal | number: '1.2-2')" readonly ><input type="text" [value]="'₹' + (fdCalcTotal | number: '1.2-2')" readonly
@@ -257,11 +268,14 @@
<div class="pos-section-label">Investment - Govt./Bond Interest Calculation &amp; Post</div> <div class="pos-section-label">Investment - Govt./Bond Interest Calculation &amp; Post</div>
<div class="inv-form-grid"> <div class="inv-form-grid">
<label class="inv-form-field" <label class="inv-form-field"
><span>Date</span><input type="text" [(ngModel)]="bondCalcDate" placeholder="DD-MM-YYYY" ><span>From Date</span><mifosx-date-ddmmyyyy [(ngModel)]="bondCalcFromDate"></mifosx-date-ddmmyyyy
/></label> ></label>
<label class="inv-form-field"
><span>To Date</span><mifosx-date-ddmmyyyy [(ngModel)]="bondCalcToDate"></mifosx-date-ddmmyyyy
></label>
<label class="inv-form-field"> <label class="inv-form-field">
<span>Type</span> <span>Type</span>
<select [(ngModel)]="bondCalcType"> <select [(ngModel)]="bondCalcType" (ngModelChange)="onBondCalcTypeChange()">
<option value="">-- All Types --</option> <option value="">-- All Types --</option>
<option value="GOVT_SECURITY">GOVT SECURITY</option> <option value="GOVT_SECURITY">GOVT SECURITY</option>
<option value="NON_SLR">NON SLR</option> <option value="NON_SLR">NON SLR</option>
@@ -281,6 +295,14 @@
<label class="inv-form-field" <label class="inv-form-field"
><span>ALL</span><input type="checkbox" [(ngModel)]="bondCalcAll" style="height: 18px; width: 18px" ><span>ALL</span><input type="checkbox" [(ngModel)]="bondCalcAll" style="height: 18px; width: 18px"
/></label> /></label>
<label class="inv-form-field">
<span>Post To (Interest Income GL)</span>
<select [(ngModel)]="bondCalcGlCode">
@for (g of interestIncomeGlOptions; track g.glCode) {
<option [value]="g.glCode">{{ g.glCode }} — {{ g.glName }}</option>
}
</select>
</label>
<label class="inv-form-field" <label class="inv-form-field"
><span>Total Interest to Post</span ><span>Total Interest to Post</span
><input type="text" [value]="'₹' + (bondCalcTotal | number: '1.2-2')" readonly ><input type="text" [value]="'₹' + (bondCalcTotal | number: '1.2-2')" readonly
@@ -342,6 +364,16 @@
@if (activeTab === 'receive') { @if (activeTab === 'receive') {
<div class="pg-card"> <div class="pg-card">
<div class="pos-section-label">Interest Receive — Record Actual Cash Receipt</div> <div class="pos-section-label">Interest Receive — Record Actual Cash Receipt</div>
<div class="inv-form-grid">
<label class="inv-form-field">
<span>Receive Into (Cash/Bank GL)</span>
<select [(ngModel)]="receiveGlCode">
@for (g of cashBankGlOptions; track g.glCode) {
<option [value]="g.glCode">{{ g.glCode }} — {{ g.glName }}</option>
}
</select>
</label>
</div>
<table class="pg-table"> <table class="pg-table">
<thead> <thead>
<tr> <tr>
@@ -396,6 +428,7 @@
<th>Voucher No</th> <th>Voucher No</th>
<th>Cert No</th> <th>Cert No</th>
<th>Particulars</th> <th>Particulars</th>
<th>GL Credited</th>
<th class="num col-in">Amount Received</th> <th class="num col-in">Amount Received</th>
</tr> </tr>
</thead> </thead>
@@ -408,17 +441,18 @@
</td> </td>
<td>{{ t.certNo }}</td> <td>{{ t.certNo }}</td>
<td>{{ t.particulars }}</td> <td>{{ t.particulars }}</td>
<td>{{ t.glCode ? t.glCode + ' — ' + t.glName : '—' }}</td>
<td class="num col-in">₹{{ t.amount | number: '1.2-2' }}</td> <td class="num col-in">₹{{ t.amount | number: '1.2-2' }}</td>
</tr> </tr>
} @empty { } @empty {
<tr> <tr>
<td colspan="5" class="pg-empty">No interest received yet.</td> <td colspan="6" class="pg-empty">No interest received yet.</td>
</tr> </tr>
} }
</tbody> </tbody>
<tfoot> <tfoot>
<tr class="cctr-total-row"> <tr class="cctr-total-row">
<td colspan="4">Total Received</td> <td colspan="5">Total Received</td>
<td class="num">₹{{ receivedLogTotal | number: '1.2-2' }}</td> <td class="num">₹{{ receivedLogTotal | number: '1.2-2' }}</td>
</tr> </tr>
</tfoot> </tfoot>
@@ -438,6 +472,7 @@
<th>Cert No</th> <th>Cert No</th>
<th>Instrument</th> <th>Instrument</th>
<th>Particulars</th> <th>Particulars</th>
<th>GL Posted</th>
<th class="num col-in">Interest Amount</th> <th class="num col-in">Interest Amount</th>
</tr> </tr>
</thead> </thead>
@@ -451,17 +486,18 @@
<td>{{ t.certNo }}</td> <td>{{ t.certNo }}</td>
<td>{{ instrumentLabel(t.instrumentType) }}</td> <td>{{ instrumentLabel(t.instrumentType) }}</td>
<td>{{ t.particulars }}</td> <td>{{ t.particulars }}</td>
<td>{{ t.glCode ? t.glCode + ' — ' + t.glName : '—' }}</td>
<td class="num col-in">₹{{ t.amount | number: '1.2-2' }}</td> <td class="num col-in">₹{{ t.amount | number: '1.2-2' }}</td>
</tr> </tr>
} @empty { } @empty {
<tr> <tr>
<td colspan="6" class="pg-empty">No bond interest entries recorded yet.</td> <td colspan="7" class="pg-empty">No bond interest entries recorded yet.</td>
</tr> </tr>
} }
</tbody> </tbody>
<tfoot> <tfoot>
<tr class="cctr-total-row"> <tr class="cctr-total-row">
<td colspan="5">Total</td> <td colspan="6">Total</td>
<td class="num">₹{{ bondIntRegisterTotal | number: '1.2-2' }}</td> <td class="num">₹{{ bondIntRegisterTotal | number: '1.2-2' }}</td>
</tr> </tr>
</tfoot> </tfoot>
@@ -480,6 +516,7 @@
<th>Voucher No</th> <th>Voucher No</th>
<th>Cert No</th> <th>Cert No</th>
<th>Particulars</th> <th>Particulars</th>
<th>GL Posted</th>
<th class="num col-in">Interest Amount</th> <th class="num col-in">Interest Amount</th>
</tr> </tr>
</thead> </thead>
@@ -492,17 +529,18 @@
</td> </td>
<td>{{ t.certNo }}</td> <td>{{ t.certNo }}</td>
<td>{{ t.particulars }}</td> <td>{{ t.particulars }}</td>
<td>{{ t.glCode ? t.glCode + ' — ' + t.glName : '—' }}</td>
<td class="num col-in">₹{{ t.amount | number: '1.2-2' }}</td> <td class="num col-in">₹{{ t.amount | number: '1.2-2' }}</td>
</tr> </tr>
} @empty { } @empty {
<tr> <tr>
<td colspan="5" class="pg-empty">No FD interest entries recorded yet.</td> <td colspan="6" class="pg-empty">No FD interest entries recorded yet.</td>
</tr> </tr>
} }
</tbody> </tbody>
<tfoot> <tfoot>
<tr class="cctr-total-row"> <tr class="cctr-total-row">
<td colspan="4">Total</td> <td colspan="5">Total</td>
<td class="num">₹{{ fdIntRegisterTotal | number: '1.2-2' }}</td> <td class="num">₹{{ fdIntRegisterTotal | number: '1.2-2' }}</td>
</tr> </tr>
</tfoot> </tfoot>
@@ -9,6 +9,7 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { DateDdmmyyyyPickerComponent } from '../date-ddmmyyyy-picker/date-ddmmyyyy-picker.component';
import { import {
FD_HOLDINGS, FD_HOLDINGS,
GOVT_SECURITY_HOLDINGS, GOVT_SECURITY_HOLDINGS,
@@ -16,6 +17,9 @@ import {
BOND_HOLDINGS, BOND_HOLDINGS,
ALL_TXNS, ALL_TXNS,
SCRIPT_MASTER, SCRIPT_MASTER,
INTEREST_INCOME_GL_MASTER,
INTEREST_INCOME_GL_BY_TYPE,
CASH_BANK_GL_MASTER,
InvestmentHolding, InvestmentHolding,
InvestmentTxn, InvestmentTxn,
InstrumentType, InstrumentType,
@@ -23,12 +27,13 @@ import {
INSTRUMENT_LABELS, INSTRUMENT_LABELS,
PROVISION_STATUS_LABELS, PROVISION_STATUS_LABELS,
accruedInterest, accruedInterest,
accruedInterestBetween,
addPeriod,
daysToMaturity, daysToMaturity,
maturityAmount, maturityAmount,
todayDDMMYYYY, todayDDMMYYYY,
isNpi, isNpi,
canAccrueInterest, canAccrueInterest,
interestToPost,
interestReceivableBalance, interestReceivableBalance,
suspenseInterest, suspenseInterest,
provisionRequiredInv provisionRequiredInv
@@ -67,7 +72,8 @@ type InterestTab =
standalone: true, standalone: true,
imports: [ imports: [
CommonModule, CommonModule,
FormsModule FormsModule,
DateDdmmyyyyPickerComponent
] ]
}) })
export class InvestmentInterestComponent { export class InvestmentInterestComponent {
@@ -83,6 +89,8 @@ export class InvestmentInterestComponent {
txns: InvestmentTxn[] = [...ALL_TXNS]; txns: InvestmentTxn[] = [...ALL_TXNS];
readonly scriptOptions = SCRIPT_MASTER; readonly scriptOptions = SCRIPT_MASTER;
readonly interestIncomeGlOptions = INTEREST_INCOME_GL_MASTER;
readonly cashBankGlOptions = CASH_BANK_GL_MASTER;
/** Draft "amount to receive" per certificate, keyed by certNo, for the Interest Receive tab. */ /** Draft "amount to receive" per certificate, keyed by certNo, for the Interest Receive tab. */
receiveDrafts: Record<string, number | null> = {}; receiveDrafts: Record<string, number | null> = {};
@@ -92,17 +100,24 @@ export class InvestmentInterestComponent {
toastVisible = false; toastVisible = false;
private toastTimer: ReturnType<typeof setTimeout> | undefined; private toastTimer: ReturnType<typeof setTimeout> | undefined;
// 01 FD Interest Calculation // 01 FD Interest Calculation — posts interest accrued strictly within [fromDate, toDate]
fdCalcDate = this.asOnDate; fdCalcFromDate = addPeriod(this.asOnDate, 0, -3);
fdCalcToDate = this.asOnDate;
fdCalcCertNo = ''; fdCalcCertNo = '';
fdCalcAll = false; fdCalcAll = false;
fdCalcGlCode = INTEREST_INCOME_GL_BY_TYPE.FD;
// 02 Govt./Bond Int. Calculation // 02 Govt./Bond Int. Calculation — posts interest accrued strictly within [fromDate, toDate]
bondCalcDate = this.asOnDate; bondCalcFromDate = addPeriod(this.asOnDate, 0, -3);
bondCalcToDate = this.asOnDate;
bondCalcType: InstrumentType | '' = ''; bondCalcType: InstrumentType | '' = '';
bondCalcScript = ''; bondCalcScript = '';
bondCalcDealNo = ''; bondCalcDealNo = '';
bondCalcAll = false; bondCalcAll = false;
bondCalcGlCode = INTEREST_INCOME_GL_BY_TYPE.GOVT_SECURITY;
// Interest Receive — Cash/Bank GL credited for the day's collection voucher
receiveGlCode = CASH_BANK_GL_MASTER[0].glCode;
// Register filters // Register filters
registerFromDate = ''; registerFromDate = '';
@@ -184,38 +199,56 @@ export class InvestmentInterestComponent {
} }
fdInterest(h: InvestmentHolding): number { fdInterest(h: InvestmentHolding): number {
return interestToPost(h, this.fdCalcDate || this.asOnDate); return accruedInterestBetween(h, this.fdCalcFromDate || h.purchaseDate, this.fdCalcToDate || this.asOnDate);
} }
get fdCalcTotal(): number { get fdCalcTotal(): number {
return this.fdCalcTargets.reduce((s, h) => s + this.fdInterest(h), 0); return this.fdCalcTargets.reduce((s, h) => s + this.fdInterest(h), 0);
} }
glName(glCode: string): string {
return (
this.interestIncomeGlOptions.find((g) => g.glCode === glCode)?.glName ??
this.cashBankGlOptions.find((g) => g.glCode === glCode)?.glName ??
glCode
);
}
processFdInterest(): void { processFdInterest(): void {
const targets = this.fdCalcTargets.filter((h) => this.fdInterest(h) > 0); const targets = this.fdCalcTargets.filter((h) => this.fdInterest(h) > 0);
if (targets.length === 0) { if (targets.length === 0) {
this.toast('No FD holdings have interest due to be posted.'); this.toast('No FD holdings have interest due to be posted for this period.');
return; return;
} }
const toDate = this.fdCalcToDate || this.asOnDate;
const fromDate = this.fdCalcFromDate || toDate;
const glCode = this.fdCalcGlCode;
const glName = this.glName(glCode);
const newTxns: InvestmentTxn[] = targets.map((h, i) => { const newTxns: InvestmentTxn[] = targets.map((h, i) => {
const amt = this.fdInterest(h); const amt = this.fdInterest(h);
h.interestPosted = this.posted(h) + amt; h.interestPosted = this.posted(h) + amt;
h.interestPostedTillDate = toDate;
return { return {
certNo: h.certNo, certNo: h.certNo,
instrumentType: 'FD', instrumentType: 'FD',
txnType: 'INTEREST', txnType: 'INTEREST',
voucherNo: `INV/FD-INT/${(this.fdCalcDate || this.asOnDate).replace(/-/g, '')}-${i + 1}`, voucherNo: `INV/FD-INT/${toDate.replace(/-/g, '')}-${i + 1}`,
txnDate: this.fdCalcDate || this.asOnDate, txnDate: toDate,
amount: amt, amount: amt,
particulars: `FD interest posted (accrual) - ${h.party}` particulars: `FD interest posted (accrual) for period ${fromDate} to ${toDate} - ${h.party}`,
glCode,
glName
}; };
}); });
this.fdCalcFromDate = toDate;
const total = newTxns.reduce((s, t) => s + t.amount, 0); const total = newTxns.reduce((s, t) => s + t.amount, 0);
this.txns = [ this.txns = [
...newTxns, ...newTxns,
...this.txns ...this.txns
]; ];
this.toast(`FD interest posted for ${targets.length} certificate(s). Total ₹${total.toLocaleString('en-IN')}.`); this.toast(
`FD interest posted for ${targets.length} certificate(s) to ${glCode} - ${glName}. Total ₹${total.toLocaleString('en-IN')}.`
);
} }
// -------------------- 02 Govt./Bond Int. Calculation -------------------- // -------------------- 02 Govt./Bond Int. Calculation --------------------
@@ -239,30 +272,44 @@ export class InvestmentInterestComponent {
} }
bondInterest(h: InvestmentHolding): number { bondInterest(h: InvestmentHolding): number {
return interestToPost(h, this.bondCalcDate || this.asOnDate); return accruedInterestBetween(h, this.bondCalcFromDate || h.purchaseDate, this.bondCalcToDate || this.asOnDate);
} }
get bondCalcTotal(): number { get bondCalcTotal(): number {
return this.bondCalcTargets.reduce((s, h) => s + this.bondInterest(h), 0); return this.bondCalcTargets.reduce((s, h) => s + this.bondInterest(h), 0);
} }
/** Re-suggests the Interest Income GL when the Type filter changes — still overridable. */
onBondCalcTypeChange(): void {
if (this.bondCalcType) {
this.bondCalcGlCode = INTEREST_INCOME_GL_BY_TYPE[this.bondCalcType];
}
}
processBondInterest(): void { processBondInterest(): void {
const targets = this.bondCalcTargets.filter((h) => this.bondInterest(h) > 0); const targets = this.bondCalcTargets.filter((h) => this.bondInterest(h) > 0);
if (targets.length === 0) { if (targets.length === 0) {
this.toast('No Govt/Bond holdings have interest due to be posted.'); this.toast('No Govt/Bond holdings have interest due to be posted for this period.');
return; return;
} }
const toDate = this.bondCalcToDate || this.asOnDate;
const fromDate = this.bondCalcFromDate || toDate;
const glCode = this.bondCalcGlCode;
const glName = this.glName(glCode);
const newTxns: InvestmentTxn[] = targets.map((h, i) => { const newTxns: InvestmentTxn[] = targets.map((h, i) => {
const amt = this.bondInterest(h); const amt = this.bondInterest(h);
h.interestPosted = this.posted(h) + amt; h.interestPosted = this.posted(h) + amt;
h.interestPostedTillDate = toDate;
return { return {
certNo: h.certNo, certNo: h.certNo,
instrumentType: h.instrumentType, instrumentType: h.instrumentType,
txnType: 'INTEREST', txnType: 'INTEREST',
voucherNo: `INV/BOND-INT/${(this.bondCalcDate || this.asOnDate).replace(/-/g, '')}-${i + 1}`, voucherNo: `INV/BOND-INT/${toDate.replace(/-/g, '')}-${i + 1}`,
txnDate: this.bondCalcDate || this.asOnDate, txnDate: toDate,
amount: amt, amount: amt,
particulars: `Coupon/interest posted (accrual) - ${h.scriptName}` particulars: `Coupon/interest posted (accrual) for period ${fromDate} to ${toDate} - ${h.scriptName}`,
glCode,
glName
}; };
}); });
const total = newTxns.reduce((s, t) => s + t.amount, 0); const total = newTxns.reduce((s, t) => s + t.amount, 0);
@@ -270,7 +317,10 @@ export class InvestmentInterestComponent {
...newTxns, ...newTxns,
...this.txns ...this.txns
]; ];
this.toast(`Interest posted for ${targets.length} holding(s). Total ₹${total.toLocaleString('en-IN')}.`); this.bondCalcFromDate = toDate;
this.toast(
`Interest posted for ${targets.length} holding(s) to ${glCode} - ${glName}. Total ₹${total.toLocaleString('en-IN')}.`
);
} }
// -------------------- Interest Receivable (all interest-bearing types) -------------------- // -------------------- Interest Receivable (all interest-bearing types) --------------------
@@ -331,6 +381,8 @@ export class InvestmentInterestComponent {
h.interestReceived = this.received(h) + amt; h.interestReceived = this.received(h) + amt;
particulars = `Interest received on cash basis (${this.provisionLabel(h)}) - ${h.scriptName || h.party}`; particulars = `Interest received on cash basis (${this.provisionLabel(h)}) - ${h.scriptName || h.party}`;
} }
const glCode = this.receiveGlCode;
const glName = this.glName(glCode);
this.txns = [ this.txns = [
{ {
certNo: h.certNo, certNo: h.certNo,
@@ -339,11 +391,13 @@ export class InvestmentInterestComponent {
voucherNo: `INV/INT-RCV/${h.certNo}-${this.receivedLog.length + 1}`, voucherNo: `INV/INT-RCV/${h.certNo}-${this.receivedLog.length + 1}`,
txnDate: this.asOnDate, txnDate: this.asOnDate,
amount: amt, amount: amt,
particulars particulars: `${particulars} (Dr. ${glCode} - ${glName})`,
glCode,
glName
}, },
...this.txns ...this.txns
]; ];
this.toast(`₹${amt.toLocaleString('en-IN')} interest received for ${h.certNo}.`); this.toast(`₹${amt.toLocaleString('en-IN')} interest received into ${glCode} - ${glName} for ${h.certNo}.`);
delete this.receiveDrafts[h.certNo]; delete this.receiveDrafts[h.certNo];
} }
@@ -400,7 +454,9 @@ export class InvestmentInterestComponent {
t.certNo, t.certNo,
t.instrumentType, t.instrumentType,
t.particulars, t.particulars,
t.amount t.amount,
t.glCode ?? '',
t.glName ?? ''
]; ];
} }
@@ -412,7 +468,9 @@ export class InvestmentInterestComponent {
'Cert No', 'Cert No',
'Type', 'Type',
'Particulars', 'Particulars',
'Amount' 'Amount',
'GL Code',
'GL Name'
]; ];
const txnWidths = [ const txnWidths = [
14, 14,
@@ -420,7 +478,9 @@ export class InvestmentInterestComponent {
14, 14,
14, 14,
42, 42,
16 16,
12,
28
]; ];
const one = ( const one = (
sheetName: string, sheetName: string,
@@ -501,10 +561,12 @@ export class InvestmentInterestComponent {
return sheets; return sheets;
} }
case 'fdCalc': case 'fdCalc':
return one( return [
'FD Interest Calc', {
'FD Interest Calculation & Post', sheetName: 'FD Interest Calc',
[ title: 'FD Interest Calculation & Post',
subtitle: `Period ${this.fdCalcFromDate || '—'} to ${this.fdCalcToDate || this.asOnDate} · Post to ${this.fdCalcGlCode} - ${this.glName(this.fdCalcGlCode)}`,
headers: [
'Cert No', 'Cert No',
'Party', 'Party',
'Face Value', 'Face Value',
@@ -512,7 +574,7 @@ export class InvestmentInterestComponent {
'Frequency', 'Frequency',
'Interest' 'Interest'
], ],
[ widths: [
14, 14,
30, 30,
16, 16,
@@ -520,7 +582,7 @@ export class InvestmentInterestComponent {
14, 14,
16 16
], ],
this.fdCalcTargets.map((h) => [ rows: this.fdCalcTargets.map((h) => [
h.certNo, h.certNo,
h.party, h.party,
h.faceValue, h.faceValue,
@@ -528,12 +590,15 @@ export class InvestmentInterestComponent {
h.interestFreq, h.interestFreq,
this.fdInterest(h) this.fdInterest(h)
]) ])
); }
];
case 'bondCalc': case 'bondCalc':
return one( return [
'Bond Interest Calc', {
'Govt./Bond Interest Calculation & Post', sheetName: 'Bond Interest Calc',
[ title: 'Govt./Bond Interest Calculation & Post',
subtitle: `Period ${this.bondCalcFromDate || '—'} to ${this.bondCalcToDate || this.asOnDate} · Post to ${this.bondCalcGlCode} - ${this.glName(this.bondCalcGlCode)}`,
headers: [
'Cert No', 'Cert No',
'Script', 'Script',
'Type', 'Type',
@@ -542,7 +607,7 @@ export class InvestmentInterestComponent {
'Frequency', 'Frequency',
'Interest' 'Interest'
], ],
[ widths: [
14, 14,
30, 30,
16, 16,
@@ -551,7 +616,7 @@ export class InvestmentInterestComponent {
14, 14,
16 16
], ],
this.bondCalcTargets.map((h) => [ rows: this.bondCalcTargets.map((h) => [
h.certNo, h.certNo,
h.scriptName, h.scriptName,
this.instrumentLabel(h.instrumentType), this.instrumentLabel(h.instrumentType),
@@ -560,13 +625,14 @@ export class InvestmentInterestComponent {
h.interestFreq, h.interestFreq,
this.bondInterest(h) this.bondInterest(h)
]) ])
); }
];
case 'receive': { case 'receive': {
const sheets: ExportSheetSpec[] = [ const sheets: ExportSheetSpec[] = [
{ {
sheetName: 'Receive Targets', sheetName: 'Receive Targets',
title: 'Interest Receive — Outstanding for Collection', title: 'Interest Receive — Outstanding for Collection',
subtitle, subtitle: `${subtitle} · Receive into ${this.receiveGlCode} - ${this.glName(this.receiveGlCode)}`,
headers: [ headers: [
'Cert No', 'Cert No',
'Script / Party', 'Script / Party',
@@ -267,8 +267,8 @@
><span>Contact No</span><input type="text" [(ngModel)]="newBroker.contactNo" ><span>Contact No</span><input type="text" [(ngModel)]="newBroker.contactNo"
/></label> /></label>
<label class="inv-form-field" <label class="inv-form-field"
><span>Empanelled On</span><input type="text" [(ngModel)]="newBroker.empanelledOn" placeholder="DD-MM-YYYY" ><span>Empanelled On</span><mifosx-date-ddmmyyyy [(ngModel)]="newBroker.empanelledOn"></mifosx-date-ddmmyyyy
/></label> ></label>
</div> </div>
<div class="inv-form-actions"> <div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="addBroker()">Save</button> <button type="button" class="pg-btn pg-btn--primary" (click)="addBroker()">Save</button>
@@ -349,8 +349,8 @@
<div class="pos-section-label">Add Daily Script Rate</div> <div class="pos-section-label">Add Daily Script Rate</div>
<div class="inv-form-grid"> <div class="inv-form-grid">
<label class="inv-form-field" <label class="inv-form-field"
><span>Rate Date *</span><input type="text" [(ngModel)]="newDailyRate.rateDate" placeholder="DD-MM-YYYY" ><span>Rate Date *</span><mifosx-date-ddmmyyyy [(ngModel)]="newDailyRate.rateDate"></mifosx-date-ddmmyyyy
/></label> ></label>
<label class="inv-form-field"> <label class="inv-form-field">
<span>Script Code *</span> <span>Script Code *</span>
<select [(ngModel)]="newDailyRate.scriptCode"> <select [(ngModel)]="newDailyRate.scriptCode">
@@ -9,6 +9,7 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { DateDdmmyyyyPickerComponent } from '../date-ddmmyyyy-picker/date-ddmmyyyy-picker.component';
import { import {
SCRIPT_MASTER, SCRIPT_MASTER,
BROKER_MASTER, BROKER_MASTER,
@@ -60,7 +61,8 @@ type MasterTab =
standalone: true, standalone: true,
imports: [ imports: [
CommonModule, CommonModule,
FormsModule FormsModule,
DateDdmmyyyyPickerComponent
] ]
}) })
export class InvestmentMastersComponent { export class InvestmentMastersComponent {
@@ -138,8 +138,8 @@
/></label> /></label>
<label class="inv-form-field" <label class="inv-form-field"
><span>Purchase Date *</span ><span>Purchase Date *</span
><input type="text" [(ngModel)]="purchaseForm.purchaseDate" placeholder="DD-MM-YYYY" ><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.purchaseDate"></mifosx-date-ddmmyyyy
/></label> ></label>
<label class="inv-form-field" <label class="inv-form-field"
><span>Broker Code</span><input type="text" [(ngModel)]="purchaseForm.brokerCode" ><span>Broker Code</span><input type="text" [(ngModel)]="purchaseForm.brokerCode"
/></label> /></label>
@@ -9,6 +9,7 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { DateDdmmyyyyPickerComponent } from '../date-ddmmyyyy-picker/date-ddmmyyyy-picker.component';
import { SHARE_HOLDINGS, ALL_TXNS, InvestmentHolding, InvestmentTxn, todayDDMMYYYY } from '../investment-data'; import { SHARE_HOLDINGS, ALL_TXNS, InvestmentHolding, InvestmentTxn, todayDDMMYYYY } from '../investment-data';
import { exportWorkbook } from '../investment-export'; import { exportWorkbook } from '../investment-export';
@@ -41,7 +42,8 @@ interface SharePurchaseForm {
standalone: true, standalone: true,
imports: [ imports: [
CommonModule, CommonModule,
FormsModule FormsModule,
DateDdmmyyyyPickerComponent
] ]
}) })
export class ShareInvestmentComponent { export class ShareInvestmentComponent {
@@ -1470,16 +1470,30 @@ export function branchTargetAmount(branchCode: string): number {
return BRANCH_DISBURSEMENT_TARGET[branchCode] || 0; return BRANCH_DISBURSEMENT_TARGET[branchCode] || 0;
} }
/** Target product mix (% of total disbursement) — weighted towards secured Housing/Vehicle /**
* lending plus the priority-sector KCC/Cash Credit book, adding up to 100%. */ * Target product mix (% of total disbursement). Two of these are genuinely RBI-mandated
* Priority Sector Lending sub-targets (RBI Master Direction — Priority Sector Lending: Targets
* and Classification, FIDD.CO.Plan.BC.13/04.09.01/2020-21, as extended to UCBs) —
* • Agriculture: 18% of Adjusted Net Bank Credit (ANBC) — mapped here to Kisan Credit Card.
* • Micro Enterprises: 7.5% of ANBC — mapped here to Cash Credit (MSME).
* RBI sets these as a % of the BANK'S WHOLE loan book (ANBC), not of any one product line's own
* disbursement — shown here against this module's own mix as the closest available proxy, since
* this demo has no bank-wide ANBC figure to check against. The remaining five products (Housing,
* Vehicle, Gold, Personal, Education) have no RBI-mandated minimum share of their own — Housing
* and Education are Priority-Sector-*eligible* within RBI's loan-amount ceilings but aren't
* separately targeted, and Vehicle/Gold/Personal aren't Priority Sector categories at all — so
* their split below is an internal business-plan assumption, not a regulatory requirement.
* (RBI's other PSL sub-targets — Weaker Sections 12%, overall PSL 40% — cut across products by
* borrower category rather than by loan type, so they aren't represented as their own row here.)
*/
export const PRODUCT_TARGET_PCT: Record<string, number> = { export const PRODUCT_TARGET_PCT: Record<string, number> = {
PL: 12, PL: 12,
HL: 28, HL: 30,
VL: 15, VL: 15,
GL: 10, GL: 12,
CC: 15, CC: 7.5, // RBI Micro Enterprises PSL sub-target
KCC: 12, KCC: 18, // RBI Agriculture PSL sub-target
EL: 8 EL: 5.5
}; };
/** Target channel mix (% of disbursement) — the bank's push towards BC/Digital origination and /** Target channel mix (% of disbursement) — the bank's push towards BC/Digital origination and
@@ -483,10 +483,17 @@
<div class="pg-card"> <div class="pg-card">
<div class="pos-section-label">Product Mix — Target % vs Actual % (of Disbursed Amount)</div> <div class="pos-section-label">Product Mix — Target % vs Actual % (of Disbursed Amount)</div>
<p class="pg-sub npa-report-note">
KCC and Cash Credit (MSME) targets are RBI's actual Priority Sector Lending sub-targets — Agriculture 18% and
Micro Enterprises 7.5% of Adjusted Net Bank Credit (shown here against this module's own mix, since RBI sets
them against the bank's whole loan book, not one product line). The rest are internal business-plan targets, not
RBI-mandated.
</p>
<table class="pg-table"> <table class="pg-table">
<thead> <thead>
<tr> <tr>
<th>Product</th> <th>Product</th>
<th>Target Basis</th>
<th class="num">Target %</th> <th class="num">Target %</th>
<th class="num col-in">Actual %</th> <th class="num col-in">Actual %</th>
<th class="num">Variance</th> <th class="num">Variance</th>
@@ -497,7 +504,12 @@
@for (row of productTargetRows; track row.code) { @for (row of productTargetRows; track row.code) {
<tr> <tr>
<td>{{ row.label }}</td> <td>{{ row.label }}</td>
<td class="num">{{ row.targetPct | number: '1.0-0' }}%</td> <td>
<span class="loan-badge" [class.loan-badge--progress]="row.isRbiMandated">
{{ row.isRbiMandated ? 'RBI PSL Sub-Target' : 'Internal Target' }}
</span>
</td>
<td class="num">{{ row.targetPct | number: '1.0-1' }}%</td>
<td class="num col-in">{{ row.actualPct | number: '1.1-1' }}%</td> <td class="num col-in">{{ row.actualPct | number: '1.1-1' }}%</td>
<td class="num" [class.col-out]="row.variancePct < 0" [class.col-in]="row.variancePct > 0"> <td class="num" [class.col-out]="row.variancePct < 0" [class.col-in]="row.variancePct > 0">
{{ row.variancePct > 0 ? '+' : '' }}{{ row.variancePct | number: '1.1-1' }} pp {{ row.variancePct > 0 ? '+' : '' }}{{ row.variancePct | number: '1.1-1' }} pp
@@ -533,7 +545,7 @@
@for (row of channelTargetRows; track row.code) { @for (row of channelTargetRows; track row.code) {
<tr> <tr>
<td>{{ row.label }}</td> <td>{{ row.label }}</td>
<td class="num">{{ row.targetPct | number: '1.0-0' }}%</td> <td class="num">{{ row.targetPct | number: '1.0-1' }}%</td>
<td class="num col-in">{{ row.actualPct | number: '1.1-1' }}%</td> <td class="num col-in">{{ row.actualPct | number: '1.1-1' }}%</td>
<td class="num" [class.col-out]="row.variancePct < 0" [class.col-in]="row.variancePct > 0"> <td class="num" [class.col-out]="row.variancePct < 0" [class.col-in]="row.variancePct > 0">
{{ row.variancePct > 0 ? '+' : '' }}{{ row.variancePct | number: '1.1-1' }} pp {{ row.variancePct > 0 ? '+' : '' }}{{ row.variancePct | number: '1.1-1' }} pp
@@ -71,6 +71,9 @@ interface MixTargetRow {
actualPct: number; actualPct: number;
variancePct: number; variancePct: number;
status: TargetStatus; status: TargetStatus;
/** True only for the two rows whose target is an actual RBI PSL sub-target (Agriculture 18% /
* Micro Enterprises 7.5% of ANBC) rather than an internal business-plan assumption. */
isRbiMandated: boolean;
} }
type ViewId = 'pipeline' | 'new' | 'channel' | 'bc' | 'share' | 'target'; type ViewId = 'pipeline' | 'new' | 'channel' | 'bc' | 'share' | 'target';
@@ -389,7 +392,8 @@ export class LoanPipelineComponent implements OnInit {
targetPct, targetPct,
actualPct, actualPct,
variancePct: actualPct - targetPct, variancePct: actualPct - targetPct,
status: targetStatus(actualPct, targetPct, 3) status: targetStatus(actualPct, targetPct, 3),
isRbiMandated: p.code === 'KCC' || p.code === 'CC'
}; };
}); });
} }
@@ -413,7 +417,8 @@ export class LoanPipelineComponent implements OnInit {
targetPct, targetPct,
actualPct, actualPct,
variancePct: actualPct - targetPct, variancePct: actualPct - targetPct,
status: targetStatus(actualPct, targetPct, 3) status: targetStatus(actualPct, targetPct, 3),
isRbiMandated: false
}; };
}); });
} }
@@ -626,6 +631,7 @@ export class LoanPipelineComponent implements OnInit {
subtitle, subtitle,
headers: [ headers: [
'Mix', 'Mix',
'Target Basis',
'Target %', 'Target %',
'Actual %', 'Actual %',
'Variance (pp)', 'Variance (pp)',
@@ -633,6 +639,7 @@ export class LoanPipelineComponent implements OnInit {
], ],
widths: [ widths: [
26, 26,
18,
12, 12,
12, 12,
14, 14,
@@ -641,6 +648,7 @@ export class LoanPipelineComponent implements OnInit {
rows: [ rows: [
...this.productTargetRows.map((r) => [ ...this.productTargetRows.map((r) => [
`Product — ${r.label}`, `Product — ${r.label}`,
r.isRbiMandated ? 'RBI PSL Sub-Target' : 'Internal Target',
r.targetPct, r.targetPct,
Number(r.actualPct.toFixed(1)), Number(r.actualPct.toFixed(1)),
Number(r.variancePct.toFixed(1)), Number(r.variancePct.toFixed(1)),
@@ -648,6 +656,7 @@ export class LoanPipelineComponent implements OnInit {
]), ]),
...this.channelTargetRows.map((r) => [ ...this.channelTargetRows.map((r) => [
`Channel — ${r.label}`, `Channel — ${r.label}`,
'Internal Target',
r.targetPct, r.targetPct,
Number(r.actualPct.toFixed(1)), Number(r.actualPct.toFixed(1)),
Number(r.variancePct.toFixed(1)), Number(r.variancePct.toFixed(1)),
+1 -1
View File
@@ -956,7 +956,7 @@ export interface ReportMeta {
label: string; label: string;
} }
export const REPORT_META: ReportMeta[] = [ export const REPORT_META: ReportMeta[] = [
{ id: 'interest', group: 'reports', label: 'NPA Interest' }, { id: 'interest', group: 'reports', label: 'NPA Report' },
{ id: 'status', group: 'reports', label: 'NPA Status Report' }, { id: 'status', group: 'reports', label: 'NPA Status Report' },
{ id: 'recovery', group: 'reports', label: 'NPA Recovery Report' }, { id: 'recovery', group: 'reports', label: 'NPA Recovery Report' },
{ id: 'summary', group: 'reports', label: 'NPA Summary Report' }, { id: 'summary', group: 'reports', label: 'NPA Summary Report' },
@@ -103,7 +103,7 @@
<!-- ===================== 01 NPA INTEREST ===================== --> <!-- ===================== 01 NPA INTEREST ===================== -->
@if (activeReport === 'interest') { @if (activeReport === 'interest') {
<div class="pg-card"> <div class="pg-card">
<div class="pos-section-label">NPA Interest — Income Reversed on Non-Performing Accounts</div> <div class="pos-section-label">NPA Report — Income Reversed on Non-Performing Accounts</div>
<table class="pg-table"> <table class="pg-table">
<thead> <thead>
<tr> <tr>
@@ -264,16 +264,22 @@
<div class="pos-section-label">Record a Recovery Against an NPA Account</div> <div class="pos-section-label">Record a Recovery Against an NPA Account</div>
<div class="inv-form-grid"> <div class="inv-form-grid">
<label class="inv-form-field"> <label class="inv-form-field">
<span>* NPA Account</span> <span>Account Type</span>
<select [(ngModel)]="newRecovery.accountNo"> <select [ngModel]="recoveryProductFilter" (ngModelChange)="setRecoveryProductFilter($event)">
<option value="">-- Select --</option> <option value="">-- All Account Types --</option>
@for (a of recoveryOptions; track a.accountNo) { @for (p of recoveryProductOptions; track p) {
<option [value]="a.accountNo"> <option [value]="p">{{ p }}</option>
{{ a.accountNo }} — {{ a.customerName }} ({{ stageLabel(stage(a)) }})
</option>
} }
</select> </select>
</label> </label>
<label class="inv-form-field">
<span>* NPA Account</span>
<mifosx-search-select
[options]="recoveryAccountSearchOptions"
[(ngModel)]="newRecovery.accountNo"
placeholder="Search account no. or customer name…"
></mifosx-search-select>
</label>
<label class="inv-form-field"> <label class="inv-form-field">
<span>* Recovery Date</span> <span>* Recovery Date</span>
<input type="text" [(ngModel)]="newRecovery.date" placeholder="DD-MM-YYYY" /> <input type="text" [(ngModel)]="newRecovery.date" placeholder="DD-MM-YYYY" />
@@ -299,45 +305,66 @@
</div> </div>
<div class="pg-card"> <div class="pg-card">
<div class="pos-section-label"> <div
class="pos-section-label"
style="display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap"
>
<span>
NPA Recovery Register — ₹{{ totalRecoveredAmount | number: '1.0-0' }} recovered against ₹{{ NPA Recovery Register — ₹{{ totalRecoveredAmount | number: '1.0-0' }} recovered against ₹{{
gnpaAmount | number: '1.0-0' gnpaAmount | number: '1.0-0'
}} }}
GNPA outstanding GNPA outstanding
</span>
@if (recoveryProductFilter) {
<span class="pg-chip">Filtered: {{ recoveryProductFilter }}</span>
}
</div> </div>
<p class="pg-sub" style="padding: 8px 16px 12px">
Target Recovery is the account's contractual due this month (EMI / interest-servicing / bullet amount) — same
basis as the Recovery Management Dashboard.
</p>
<table class="pg-table"> <table class="pg-table">
<thead> <thead>
<tr> <tr>
<th>Date</th> <th>Date</th>
<th>Account Type</th>
<th>NPA Category</th>
<th>Account No</th> <th>Account No</th>
<th>Customer</th> <th>Customer</th>
<th>Branch</th> <th>Branch</th>
<th>Mode</th> <th>Mode</th>
<th class="num col-in">Recovered Amount</th> <th class="num">Target Recovery</th>
<th class="num col-in">Recovered Amount (Actual)</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@for (r of paged(recoveryRows); track r.accountNo + r.date + r.amount) { @for (r of paged(recoveryRows); track r.accountNo + r.date + r.amount) {
<tr> <tr>
<td>{{ r.date }}</td> <td>{{ r.date }}</td>
<td>{{ r.productName }}</td>
<td>
<span [class]="r.npaCategoryClass">{{ r.npaCategory }}</span>
</td>
<td> <td>
<span class="pg-chip">{{ r.accountNo }}</span> <span class="pg-chip">{{ r.accountNo }}</span>
</td> </td>
<td>{{ r.customerName }}</td> <td>{{ r.customerName }}</td>
<td>{{ r.branchName }}</td> <td>{{ r.branchName }}</td>
<td>{{ r.mode }}</td> <td>{{ r.mode }}</td>
<td class="num">₹{{ r.targetRecovery | number: '1.0-0' }}</td>
<td class="num col-in">₹{{ r.amount | number: '1.0-0' }}</td> <td class="num col-in">₹{{ r.amount | number: '1.0-0' }}</td>
</tr> </tr>
} @empty { } @empty {
<tr> <tr>
<td colspan="6" class="pg-empty">No recoveries recorded yet.</td> <td colspan="9" class="pg-empty">No recoveries recorded yet.</td>
</tr> </tr>
} }
</tbody> </tbody>
<tfoot> <tfoot>
<tr class="cctr-total-row"> <tr class="cctr-total-row">
<td colspan="5">Total Recovered</td> <td colspan="7">Total (Target once per account · Actual across all entries shown)</td>
<td class="num">₹{{ totalRecoveredAmount | number: '1.0-0' }}</td> <td class="num">₹{{ recoveryTargetTotal | number: '1.0-0' }}</td>
<td class="num">₹{{ recoveryActualTotal | number: '1.0-0' }}</td>
</tr> </tr>
</tfoot> </tfoot>
</table> </table>
@@ -14,6 +14,7 @@ import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import { AuthenticationService } from '../../core/authentication/authentication.service'; import { AuthenticationService } from '../../core/authentication/authentication.service';
import { ExportSheetSpec, exportWorkbook } from '../../cashier/cashier-export'; import { ExportSheetSpec, exportWorkbook } from '../../cashier/cashier-export';
import { SearchSelectComponent, SearchSelectOption } from '../search-select/search-select.component';
import { import {
LOAN_ACCOUNTS, LOAN_ACCOUNTS,
BRANCHES, BRANCHES,
@@ -79,9 +80,16 @@ interface RecoveryRow {
accountNo: string; accountNo: string;
customerName: string; customerName: string;
branchName: string; branchName: string;
productName: string;
npaCategory: string;
npaCategoryClass: string;
date: string; date: string;
amount: number; amount: number;
mode: string; mode: string;
/** What the account was contractually due to pay this month (EMI / interest-servicing /
* bullet), the same "Target" basis used by the Recovery Management Dashboard — shown
* alongside the actual recovered amount so a desk can see over/under-achievement per entry. */
targetRecovery: number;
} }
interface RepaymentTypeSummaryRow { interface RepaymentTypeSummaryRow {
@@ -119,7 +127,8 @@ interface AgeingBucketRow {
CommonModule, CommonModule,
FormsModule, FormsModule,
RouterModule, RouterModule,
FontAwesomeModule FontAwesomeModule,
SearchSelectComponent
] ]
}) })
export class NpaReportsComponent implements OnInit, OnDestroy { export class NpaReportsComponent implements OnInit, OnDestroy {
@@ -457,25 +466,75 @@ export class NpaReportsComponent implements OnInit, OnDestroy {
return this.npaAccounts.filter((a) => a.recoveries.length > 0); return this.npaAccounts.filter((a) => a.recoveries.length > 0);
} }
/** '' = All Account Types. One shared filter drives both the "NPA Account" picker above (so
* it's quicker to find the right account when adding a new entry) and the Recovery Register
* below — a single control instead of two that could drift out of sync. */
recoveryProductFilter = '';
get recoveryProductOptions(): string[] {
return [...new Set(this.npaAccounts.map((a) => a.productName))].sort();
}
setRecoveryProductFilter(productName: string): void {
this.recoveryProductFilter = productName;
this.currentPage = 1;
if (!this.recoveryOptions.some((a) => a.accountNo === this.newRecovery.accountNo)) {
this.newRecovery.accountNo = '';
}
}
/** NPA accounts selectable in "Record a Recovery", narrowed by the Account Type filter. */
get recoveryOptions(): LoanAccount[] { get recoveryOptions(): LoanAccount[] {
return this.npaAccounts; return this.npaAccounts.filter((a) => !this.recoveryProductFilter || a.productName === this.recoveryProductFilter);
}
/** recoveryOptions, reshaped for the searchable NPA Account picker — matches on account no,
* customer name and branch, not just the collapsed label text. */
get recoveryAccountSearchOptions(): SearchSelectOption[] {
return this.recoveryOptions.map((a) => ({
value: a.accountNo,
label: `${a.accountNo} — ${a.customerName} (${this.stageLabel(this.stage(a))})`,
searchText: `${a.accountNo} ${a.customerName} ${a.productName} ${a.branchName}`
}));
} }
get recoveryRows(): RecoveryRow[] { get recoveryRows(): RecoveryRow[] {
return this.recoveryAccounts return this.recoveryAccounts
.filter((a) => !this.recoveryProductFilter || a.productName === this.recoveryProductFilter)
.flatMap((a) => .flatMap((a) =>
a.recoveries.map((r) => ({ a.recoveries.map((r) => ({
accountNo: a.accountNo, accountNo: a.accountNo,
customerName: a.customerName, customerName: a.customerName,
branchName: a.branchName, branchName: a.branchName,
productName: a.productName,
npaCategory: this.stageLabel(this.stage(a)),
npaCategoryClass: this.badgeClass(this.stage(a)),
date: r.date, date: r.date,
amount: r.amount, amount: r.amount,
mode: r.mode mode: r.mode,
targetRecovery: expectedRecoveryThisMonth(a)
})) }))
) )
.sort((a, b) => b.amount - a.amount); .sort((a, b) => b.amount - a.amount);
} }
/** The accounts behind recoveryRows, honouring the Account Type filter — used to total the
* monthly Target once per account rather than once per recovery transaction (an account with
* two recovery entries must not have its target counted twice). */
get recoveryFilteredAccounts(): LoanAccount[] {
return this.recoveryAccounts.filter(
(a) => !this.recoveryProductFilter || a.productName === this.recoveryProductFilter
);
}
get recoveryTargetTotal(): number {
return this.recoveryFilteredAccounts.reduce((s, a) => s + expectedRecoveryThisMonth(a), 0);
}
get recoveryActualTotal(): number {
return this.recoveryRows.reduce((s, r) => s + r.amount, 0);
}
// -------------------- Portfolio-wide KPIs (shown on every screen) -------------------- // -------------------- Portfolio-wide KPIs (shown on every screen) --------------------
get totalAdvances(): number { get totalAdvances(): number {
return this.allAccounts.reduce((s, a) => s + a.outstandingBalance, 0); return this.allAccounts.reduce((s, a) => s + a.outstandingBalance, 0);
@@ -616,8 +675,11 @@ export class NpaReportsComponent implements OnInit, OnDestroy {
} }
// -------------------- NPA Summary Report: stage-wise + branch-wise cuts -------------------- // -------------------- NPA Summary Report: stage-wise + branch-wise cuts --------------------
/** SMA-0/1/2 are early-warning "special mention" buckets, not NPA stages — this report only
* shows Standard vs the actual NPA classification ladder (Sub-Standard through Loss). SMA
* accounts still have their own dedicated Day-wise Overdue report. */
get stageSummary(): StageSummaryRow[] { get stageSummary(): StageSummaryRow[] {
return STAGE_ORDER.map((stage) => { return STAGE_ORDER.filter((stage) => !isSmaStage(stage)).map((stage) => {
const accts = this.allAccounts.filter((a) => this.stage(a) === stage); const accts = this.allAccounts.filter((a) => this.stage(a) === stage);
return { return {
stage, stage,
@@ -649,15 +711,15 @@ export class NpaReportsComponent implements OnInit, OnDestroy {
} }
get grandTotalAccounts(): number { get grandTotalAccounts(): number {
return this.allAccounts.length; return this.stageSummary.reduce((s, row) => s + row.count, 0);
} }
get grandTotalOutstanding(): number { get grandTotalOutstanding(): number {
return this.allAccounts.reduce((s, a) => s + a.outstandingBalance, 0); return this.stageSummary.reduce((s, row) => s + row.outstanding, 0);
} }
get grandTotalProvision(): number { get grandTotalProvision(): number {
return this.allAccounts.reduce((s, a) => s + this.provisionReq(a), 0); return this.stageSummary.reduce((s, row) => s + row.provisionReq, 0);
} }
get bankWideTotalAccounts(): number { get bankWideTotalAccounts(): number {
@@ -754,8 +816,8 @@ export class NpaReportsComponent implements OnInit, OnDestroy {
case 'interest': case 'interest':
return [ return [
{ {
sheetName: 'NPA Interest', sheetName: 'NPA Report',
title: 'NPA Interest — Income Reversed on Non-Performing Accounts', title: 'NPA Report — Income Reversed on Non-Performing Accounts',
subtitle, subtitle,
headers: [ headers: [
'Account No', 'Account No',
@@ -865,39 +927,53 @@ export class NpaReportsComponent implements OnInit, OnDestroy {
{ {
sheetName: 'NPA Recovery', sheetName: 'NPA Recovery',
title: 'NPA Recovery Register', title: 'NPA Recovery Register',
subtitle, subtitle: this.recoveryProductFilter
? `${subtitle} · Account Type: ${this.recoveryProductFilter}`
: subtitle,
headers: [ headers: [
'Date', 'Date',
'Account Type',
'NPA Category',
'Account No', 'Account No',
'Customer', 'Customer',
'Branch', 'Branch',
'Mode', 'Mode',
'Recovered Amount' 'Target Recovery',
'Recovered Amount (Actual)'
], ],
widths: [ widths: [
12, 12,
18,
14,
16, 16,
22, 22,
16, 16,
18, 18,
18 16,
20
], ],
rows: [ rows: [
...this.recoveryRows.map((r) => [ ...this.recoveryRows.map((r) => [
r.date, r.date,
r.productName,
r.npaCategory,
r.accountNo, r.accountNo,
r.customerName, r.customerName,
r.branchName, r.branchName,
r.mode, r.mode,
r.targetRecovery,
r.amount r.amount
]), ]),
[ [
'Total Recovered', 'Total (Target once per account · Actual across all entries)',
'', '',
'', '',
'', '',
'', '',
this.totalRecoveredAmount '',
'',
this.recoveryTargetTotal,
this.recoveryActualTotal
] ]
] ]
} }
@@ -0,0 +1,237 @@
/**
* 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, ElementRef, forwardRef, HostListener, Input, ViewChild } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms';
export interface SearchSelectOption {
value: string;
label: string;
/** What typed text is matched against — defaults to `label` when omitted, but a caller can
* pass a richer string (e.g. account no + customer name + branch) so a search hits fields
* that aren't all visible in the collapsed label. */
searchText?: string;
}
/**
* Drop-in replacement for a plain `<select>` when the option list is long enough that scanning
* it is impractical (e.g. hundreds of loan accounts) — a text box that filters the option list
* as you type, instead of a native dropdown you can only scroll through. Selection only commits
* on click; typing without picking an option reverts the box to the last committed selection on
* blur, so the bound ngModel value never drifts from an actual, valid option.
*
* The option list is positioned `fixed` and anchored to the input's own on-screen coordinates
* (recomputed on open/scroll/resize) rather than `absolute` inside the component — most of this
* app's cards use `overflow: hidden` for rounded corners, which would otherwise clip a dropdown
* that extends past the card's edge.
*
* Usage: <mifosx-search-select [options]="opts" [(ngModel)]="selectedValue"></mifosx-search-select>
*/
@Component({
selector: 'mifosx-search-select',
standalone: true,
imports: [
CommonModule,
FormsModule
],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => SearchSelectComponent),
multi: true
}
],
template: `
<div class="inv-search-select">
<input
#inputEl
type="text"
class="inv-search-select__input"
[value]="query"
[placeholder]="placeholder"
[disabled]="disabled"
(input)="onInput($event)"
(focus)="onFocus()"
(blur)="onBlur()"
/>
@if (open) {
<ul
class="inv-search-select__list"
[style.top.px]="listTop"
[style.left.px]="listLeft"
[style.width.px]="listWidth"
(mousedown)="$event.preventDefault()"
>
@for (o of filteredOptions; track o.value) {
<li
class="inv-search-select__option"
[class.inv-search-select__option--active]="o.value === value"
(click)="selectOption(o)"
>
{{ o.label }}
</li>
} @empty {
<li class="inv-search-select__empty">No matches</li>
}
</ul>
}
</div>
`,
styles: [
`
/* Angular view encapsulation stops the host page's shared styles from reaching inside this
* component's own template, so its look is re-declared here rather than inherited. */
:host {
display: block;
width: 100%;
}
.inv-search-select {
position: relative;
width: 100%;
}
.inv-search-select__input {
width: 100%;
height: 36px;
padding: 0 10px;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 13px;
color: #0d1b2a;
font-family: inherit;
background: #fff;
box-sizing: border-box;
}
.inv-search-select__list {
position: fixed;
z-index: 2000;
max-height: 260px;
overflow-y: auto;
margin: 0;
padding: 4px;
list-style: none;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(13, 27, 42, 0.18);
}
.inv-search-select__option {
padding: 8px 10px;
border-radius: 6px;
font-size: 13px;
color: #0d1b2a;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.inv-search-select__option:hover,
.inv-search-select__option--active {
background: #eef2ff;
}
.inv-search-select__empty {
padding: 8px 10px;
font-size: 12px;
color: #94a3b8;
}
`
]
})
export class SearchSelectComponent implements ControlValueAccessor {
@Input() options: SearchSelectOption[] = [];
@Input() placeholder = 'Search…';
@ViewChild('inputEl') private inputEl?: ElementRef<HTMLInputElement>;
value = '';
query = '';
open = false;
disabled = false;
listTop = 0;
listLeft = 0;
listWidth = 0;
private onChangeFn: (v: string) => void = () => {};
onTouched: () => void = () => {};
get filteredOptions(): SearchSelectOption[] {
const q = this.query.trim().toLowerCase();
if (!q) {
return this.options;
}
return this.options.filter((o) => (o.searchText || o.label).toLowerCase().includes(q));
}
private labelFor(value: string): string {
return this.options.find((o) => o.value === value)?.label ?? '';
}
writeValue(v: string): void {
this.value = v ?? '';
this.query = this.labelFor(this.value);
}
registerOnChange(fn: (v: string) => void): void {
this.onChangeFn = fn;
}
registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
}
/** Anchors the fixed-position list to the input's current on-screen box. */
private updateListPosition(): void {
const el = this.inputEl?.nativeElement;
if (!el) {
return;
}
const rect = el.getBoundingClientRect();
this.listTop = rect.bottom + 4;
this.listLeft = rect.left;
this.listWidth = rect.width;
}
@HostListener('window:scroll')
@HostListener('window:resize')
onWindowChange(): void {
if (this.open) {
this.updateListPosition();
}
}
onInput(event: Event): void {
this.query = (event.target as HTMLInputElement).value;
this.open = true;
this.updateListPosition();
}
onFocus(): void {
this.open = true;
this.updateListPosition();
}
onBlur(): void {
this.open = false;
this.onTouched();
// Discard any uncommitted typed text — only a clicked option ever changes `value`.
this.query = this.labelFor(this.value);
}
selectOption(o: SearchSelectOption): void {
this.value = o.value;
this.query = o.label;
this.open = false;
this.onChangeFn(this.value);
this.onTouched();
}
}
@@ -53,6 +53,12 @@ const routes: Routes = [
(m) => m.FormDeclarationReportComponent (m) => m.FormDeclarationReportComponent
) )
}, },
{
path: 'risk-review',
data: { title: 'Risk Review Report', breadcrumb: 'Risk Review Report' },
loadComponent: () =>
import('../risk-review/risk-review-report.component').then((m) => m.RiskReviewReportComponent)
},
{ {
path: ':filter', path: ':filter',
data: { routeParamBreadcrumb: 'filter' }, data: { routeParamBreadcrumb: 'filter' },
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,527 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="rr-root">
<!-- HEADER -->
<div class="rr-head">
<div class="rr-head-left">
<fa-icon icon="user-shield" class="rr-head-ic"></fa-icon>
<div>
<h2 class="rr-head-t">Risk Review Report</h2>
<div class="rr-head-s">
Customers whose transactions tripped an AML rule and whose risk profile therefore needs a look. For each case
the reviewer decides one of four things — keep the rating, move it, call for a KYC update, or refer it to the
Principal Officer for an STR.
</div>
</div>
</div>
<div class="rr-head-actions">
<button class="rr-btn rr-btn--primary" [disabled]="generating || loading" (click)="exportExcel()">
<fa-icon icon="file-excel"></fa-icon> {{ generating ? 'Generating…' : 'Download Excel' }}
</button>
</div>
</div>
@if (sampleCount) {
<div class="rr-banner rr-banner--sample">
<fa-icon icon="exclamation-triangle"></fa-icon>
<span>
<strong>{{ sampleCount }} demonstration rows</strong> are included alongside
{{ hasLive ? 'the live alerts' : 'the live feed, which has raised nothing' }}. Sample rows carry a
<span class="rr-tag">Sample</span> tag in the table and must not be acted on as compliance data.
</span>
<button class="rr-btn rr-btn--slim" (click)="load()"><fa-icon icon="undo"></fa-icon> Reload live feed</button>
</div>
}
<!-- FILTERS -->
<div class="rr-filters">
<div class="rr-filter-row">
<div class="rr-modes">
@for (m of periodModes; track m.key) {
<button class="rr-chip" [class.active]="periodMode === m.key" (click)="periodMode = m.key">
{{ m.label }}
</button>
}
</div>
@switch (periodMode) {
@case ('day') {
<label class="rr-field">
<span>Date</span>
<input type="date" [(ngModel)]="filterDay" />
</label>
}
@case ('week') {
<label class="rr-field">
<span>Any date in week</span>
<input type="date" [(ngModel)]="filterWeek" />
</label>
<span class="rr-resolved">Week: {{ rangeLabel }}</span>
}
@case ('month') {
<label class="rr-field">
<span>Month</span>
<input type="month" [(ngModel)]="filterMonth" />
</label>
}
@default {
<label class="rr-field">
<span>Alerts from</span>
<input type="date" [(ngModel)]="filterFrom" />
</label>
<label class="rr-field">
<span>To</span>
<input type="date" [(ngModel)]="filterTo" />
</label>
}
}
</div>
<div class="rr-filter-row">
<label class="rr-field">
<span>Branch</span>
<select [(ngModel)]="branchCode" [disabled]="!canSelectBranch">
@if (canSelectBranch) {
<option value="">All Branches</option>
}
@for (b of branches; track b.code) {
<option [value]="b.code">{{ b.code }} — {{ b.name }}</option>
}
</select>
</label>
<label class="rr-field">
<span>Current risk</span>
<select [(ngModel)]="currentRisk">
<option value="">Any rating</option>
<option value="LOW">Low</option>
<option value="MEDIUM">Medium</option>
<option value="HIGH">High</option>
</select>
</label>
<label class="rr-field">
<span>AML rule triggered</span>
<select [(ngModel)]="ruleCode">
<option value="">All rules</option>
@for (r of rules; track r.code) {
<option [value]="r.code">{{ r.label }}</option>
}
</select>
</label>
<label class="rr-field">
<span>Review status</span>
<select [(ngModel)]="status">
<option value="">Any status</option>
@for (s of reviewStatuses; track s) {
<option [value]="s">{{ statusMeta[s].label }}</option>
}
</select>
</label>
<label class="rr-field">
<span>Rating outcome</span>
<select [(ngModel)]="direction">
<option value="">Any outcome</option>
<option value="up">Review upward</option>
<option value="down">Review downward</option>
<option value="same">Within current rating</option>
</select>
</label>
<label class="rr-field rr-field--grow">
<span>Search</span>
<input type="text" [(ngModel)]="search" placeholder="Customer name, customer ID, account number or alert ID…" />
</label>
<label class="rr-check">
<input type="checkbox" [(ngModel)]="openOnly" />
Only cases awaiting review
</label>
<button class="rr-btn" (click)="resetFilters()"><fa-icon icon="undo"></fa-icon> Reset</button>
</div>
@if (!canSelectBranch) {
<div class="rr-scope-note">
<fa-icon icon="lock"></fa-icon>
Showing {{ branchLabel }} only. Head Office logins can select any branch.
</div>
}
</div>
@if (loading) {
<div class="rr-loading"><fa-icon icon="shield-alt"></fa-icon> Loading AML alerts…</div>
} @else {
<!-- SUMMARY -->
<div class="rr-kpis">
<div class="rr-kpi">
<div class="rr-kpi-n">{{ summary.customers }}</div>
<div class="rr-kpi-l">Customers for review</div>
<div class="rr-kpi-sub">
{{ summary.byRisk.LOW }} Low · {{ summary.byRisk.MEDIUM }} Medium · {{ summary.byRisk.HIGH }} High
</div>
</div>
<div class="rr-kpi rr-kpi--blue">
<div class="rr-kpi-n">{{ summary.alerts }}</div>
<div class="rr-kpi-l">AML alerts triggered</div>
<div class="rr-kpi-sub">on {{ summary.txns }} transactions · {{ summary.openAlerts }} awaiting a decision</div>
</div>
<div
class="rr-kpi rr-kpi--amber"
[class.rr-kpi--dim]="summary.upgradeCandidates + summary.downgradeCandidates === 0"
>
<div class="rr-kpi-n">{{ summary.upgradeCandidates + summary.downgradeCandidates }}</div>
<div class="rr-kpi-l">Ratings to be moved</div>
<div class="rr-kpi-sub">
{{ summary.upgradeCandidates }} upward · {{ summary.downgradeCandidates }} downward
</div>
</div>
<div class="rr-kpi" [class.rr-kpi--red]="summary.escalatedCustomers > 0">
<div class="rr-kpi-n">{{ summary.escalatedCustomers }}</div>
<div class="rr-kpi-l">Escalated to STR</div>
<div class="rr-kpi-sub">₹{{ summary.totalAmount | number: '1.0-0' }} under review</div>
</div>
</div>
<!-- RULE MIX — doubles as a one-click filter -->
@if (summary.byRule.length) {
<div class="rr-mix">
<span class="rr-mix-t">Rules triggered</span>
@for (r of summary.byRule; track r.code) {
<button class="rr-mix-item" [class.active]="ruleCode === r.code" (click)="toggleRule(r.code)">
{{ r.label }} <strong>{{ r.count }}</strong>
</button>
}
</div>
}
<!-- VIEW SWITCH -->
<div class="rr-views">
<button class="rr-chip" [class.active]="view === 'customer'" (click)="view = 'customer'">
<fa-icon icon="users"></fa-icon> Customer wise
</button>
<button class="rr-chip" [class.active]="view === 'alert'" (click)="view = 'alert'">
<fa-icon icon="list"></fa-icon> Alert wise
</button>
<button class="rr-chip" [class.active]="view === 'txn'" (click)="view = 'txn'">
<fa-icon icon="filter"></fa-icon> Transaction wise
</button>
<span class="rr-spacer"></span>
<span class="rr-count">
{{
view === 'customer'
? customerRows.length + ' customers'
: view === 'alert'
? alertRows.length + ' alerts'
: txnRows.length + ' transactions'
}}
</span>
</div>
<!-- CUSTOMER WISE -->
@if (view === 'customer') {
<div class="rr-table-wrap">
<table class="rr-table">
<thead>
<tr>
<th></th>
<th>Customer ID</th>
<th>Customer Name</th>
<th>Account Number</th>
<th>Branch</th>
<th>Current Risk</th>
<th class="num">Alerts</th>
<th>AML Rules Triggered</th>
<th class="num">Amount under review</th>
<th>Last Alert</th>
<th>Position</th>
</tr>
</thead>
<tbody>
@for (c of customerRows; track c.clientId) {
<tr
class="rr-cust"
[class.open]="expanded === c.clientId"
[class.rr-row--escalated]="c.escalated"
(click)="toggle(c.clientId)"
>
<td>
<fa-icon [icon]="expanded === c.clientId ? 'caret-down' : 'caret-right'"></fa-icon>
</td>
<td class="mono">{{ c.clientId }}</td>
<td>
<strong>{{ c.customerName }}</strong>
@if (c.openCount > 0) {
<span class="rr-tag">{{ c.openCount }} open</span>
}
</td>
<td class="mono">{{ c.accountNo || '—' }}</td>
<td>{{ c.branchCode || '—' }}</td>
<td>
<span [class]="riskClass(c.currentRisk)">{{ riskText(c.currentRisk) }}</span>
</td>
<td class="num">{{ c.alertCount }}</td>
<td class="rr-rules-cell">
@for (label of c.ruleLabels; track label) {
<span class="rr-rule">{{ label }}</span>
}
</td>
<td class="num strong">{{ c.totalAmount | number: '1.0-0' }}</td>
<td class="mono">{{ c.lastAlertDate }}</td>
<td class="rr-verdict-cell">
<span [class]="verdictClass(c)">{{ verdictText(c) }}</span>
@if (c.directionReason) {
<div class="rr-verdict-why">{{ c.directionReason }}</div>
}
</td>
</tr>
@if (expanded === c.clientId) {
<tr class="rr-detail">
<td colspan="11">
<!-- One block per alert: what the rule is, then the entries it fired on. -->
@for (a of c.alerts; track a.alertId) {
<div class="rr-case">
<div class="rr-case-head">
<span class="rr-rule">{{ a.rule.label }}</span>
<span class="rr-rule-cat">{{ a.rule.category }}</span>
<span class="rr-case-id mono">{{ a.alertId }} · raised {{ a.alertDate }}</span>
<span class="rr-spacer"></span>
<span class="rr-case-amt mono">₹{{ a.amount | number: '1.0-0' }}</span>
<span [class]="statusClass(a.status)">{{ statusMeta[a.status].label }}</span>
</div>
<div class="rr-case-why">
<fa-icon icon="exclamation-triangle"></fa-icon>
<span><strong>Why it triggered:</strong> {{ a.rule.description }} — {{ txnDetail(a) }}</span>
</div>
<table class="rr-subtable">
<thead>
<tr>
<th>Txn Date</th>
<th>Txn ID</th>
<th>Channel</th>
<th>Dr/Cr</th>
<th class="num">Amount</th>
<th class="num">Balance After</th>
<th>Narration</th>
<th>Counterparty</th>
<th>AML Rule Triggered</th>
</tr>
</thead>
<tbody>
@for (t of a.txns; track t.txnId) {
<tr [class.rr-leg--ctr]="t.ctrReportable">
<td class="mono">{{ t.date }}</td>
<td class="mono">{{ t.txnId }}</td>
<td>
<span class="rr-chan">{{ channelLabel(t.channel) }}</span>
</td>
<td [class.cr]="t.drCr === 'CR'" [class.dr]="t.drCr === 'DR'">{{ t.drCr }}</td>
<td class="num strong">
{{ t.amount | number: '1.0-0' }}
@if (t.ctrReportable) {
<span class="rr-tag rr-tag--ctr">CTR</span>
}
</td>
<td class="num">{{ t.balanceAfter ? (t.balanceAfter | number: '1.0-0') : '—' }}</td>
<td>{{ t.narration || '—' }}</td>
<td>{{ t.counterparty || '—' }}</td>
<td>
<span class="rr-rule">{{ a.rule.label }}</span>
@if (a.txns.length > 1) {
<span class="rr-leg">leg {{ $index + 1 }} of {{ a.txns.length }}</span>
}
</td>
</tr>
}
</tbody>
</table>
<div class="rr-case-foot">
<div class="rr-suggest">
<fa-icon icon="clipboard-check"></fa-icon> {{ a.rule.suggestedAction }}
</div>
<div class="rr-case-remark">
<strong>Reviewer:</strong> {{ a.reviewedBy || 'not yet assigned' }} —
{{ a.remarks || 'no remarks recorded' }}
</div>
</div>
</div>
}
</td>
</tr>
}
} @empty {
<tr>
<td colspan="11" class="rr-empty">
No customer tripped an AML rule for {{ rangeLabel }} at {{ branchLabel }}.
</td>
</tr>
}
</tbody>
</table>
</div>
}
<!-- ALERT WISE -->
@if (view === 'alert') {
<div class="rr-table-wrap">
<table class="rr-table">
<thead>
<tr>
<th>Alert ID</th>
<th>Alert Date</th>
<th>Customer ID</th>
<th>Customer Name</th>
<th>Account Number</th>
<th>Branch</th>
<th>Current Risk</th>
<th>Transaction Details</th>
<th>AML Rule Triggered</th>
<th class="num">Amount</th>
<th>Review Status</th>
<th>Reviewer Remarks</th>
</tr>
</thead>
<tbody>
@for (a of alertRows; track a.alertId) {
<tr [class.rr-row--escalated]="a.status === 'ESCALATED_STR' || a.status === 'STR_FILED'">
<td class="mono">{{ a.alertId }}</td>
<td class="mono">{{ a.alertDate }}</td>
<td class="mono">{{ a.clientId }}</td>
<td>
<strong>{{ a.customerName }}</strong>
@if (!a.live) {
<span class="rr-tag">Sample</span>
}
</td>
<td class="mono">{{ a.accountNo || '—' }}</td>
<td>{{ a.branchCode || '—' }}</td>
<td>
<span [class]="riskClass(a.currentRisk)">{{ riskText(a.currentRisk) }}</span>
</td>
<td>{{ txnDetail(a) }}</td>
<td class="rr-rules-cell">
<span class="rr-rule">{{ a.rule.label }}</span>
<span class="rr-rule-cat">{{ a.rule.category }}</span>
<div class="rr-rule-desc">{{ a.rule.description }}</div>
</td>
<td class="num strong">{{ a.amount | number: '1.0-0' }}</td>
<td>
<span [class]="statusClass(a.status)">{{ statusMeta[a.status].label }}</span>
@if (a.reviewedBy) {
<div class="rr-by">by {{ a.reviewedBy }}</div>
}
</td>
<td>
{{ a.remarks || '—' }}
<div class="rr-suggest"><fa-icon icon="clipboard-check"></fa-icon> {{ a.rule.suggestedAction }}</div>
</td>
</tr>
} @empty {
<tr>
<td colspan="12" class="rr-empty">
No AML rule was triggered for {{ rangeLabel }} at {{ branchLabel }}.
</td>
</tr>
}
</tbody>
</table>
</div>
}
<!-- TRANSACTION WISE — every entry, and the rule it tripped -->
@if (view === 'txn') {
<div class="rr-table-wrap">
<table class="rr-table">
<thead>
<tr>
<th>Txn Date</th>
<th>Txn ID</th>
<th>Customer ID</th>
<th>Customer Name</th>
<th>Account Number</th>
<th>Branch</th>
<th>Current Risk</th>
<th>Channel</th>
<th>Dr/Cr</th>
<th class="num">Amount</th>
<th class="num">Balance After</th>
<th>Narration</th>
<th>Counterparty</th>
<th>AML Rule Triggered</th>
<th>Alert</th>
<th>Review Status</th>
</tr>
</thead>
<tbody>
@for (t of txnRows; track t.txnId) {
<tr [class.rr-row--escalated]="t.status === 'ESCALATED_STR' || t.status === 'STR_FILED'">
<td class="mono">{{ t.date }}</td>
<td class="mono">{{ t.txnId }}</td>
<td class="mono">{{ t.clientId }}</td>
<td>
<strong>{{ t.customerName }}</strong>
</td>
<td class="mono">{{ t.accountNo || '—' }}</td>
<td>{{ t.branchCode || '—' }}</td>
<td>
<span [class]="riskClass(t.currentRisk)">{{ riskText(t.currentRisk) }}</span>
</td>
<td>
<span class="rr-chan">{{ channelLabel(t.channel) }}</span>
</td>
<td [class.cr]="t.drCr === 'CR'" [class.dr]="t.drCr === 'DR'">{{ t.drCr }}</td>
<td class="num strong">
{{ t.amount | number: '1.0-0' }}
@if (t.ctrReportable) {
<span class="rr-tag rr-tag--ctr">CTR</span>
}
</td>
<td class="num">{{ t.balanceAfter ? (t.balanceAfter | number: '1.0-0') : '—' }}</td>
<td>{{ t.narration || '—' }}</td>
<td>{{ t.counterparty || '—' }}</td>
<td class="rr-rules-cell">
<span class="rr-rule">{{ t.rule.label }}</span>
<span class="rr-rule-cat">{{ t.rule.category }}</span>
@if (t.legCount > 1) {
<span class="rr-leg">leg {{ t.legIndex }} of {{ t.legCount }}</span>
}
<div class="rr-rule-desc">{{ t.rule.description }}</div>
</td>
<td class="mono">{{ t.alertId }}</td>
<td>
<span [class]="statusClass(t.status)">{{ statusMeta[t.status].label }}</span>
</td>
</tr>
} @empty {
<tr>
<td colspan="16" class="rr-empty">
No transaction tripped an AML rule for {{ rangeLabel }} at {{ branchLabel }}.
</td>
</tr>
}
</tbody>
</table>
</div>
}
<p class="rr-legend">
<span class="rr-status rr-status--open">Pending review</span> awaiting a reviewer ·
<span class="rr-status rr-status--ok">Risk retained</span> explained, rating unchanged ·
<span class="rr-status rr-status--warn">Risk upgraded / KYC update</span> profile changed ·
<span class="rr-status rr-status--danger">Escalated / STR filed</span> with the Principal Officer
</p>
}
<div class="rr-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
</div>
@@ -0,0 +1,820 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
:host {
--r-navy: #1a237e;
--r-blue: #1d4ed8;
--r-blue-l: #eff6ff;
--r-green: #15803d;
--r-green-l: #dcfce7;
--r-amber: #b45309;
--r-amber-l: #fef3c7;
--r-red: #c0392b;
--r-red-l: #fdecea;
--r-violet: #6d28d9;
--r-violet-l: #f3e8ff;
--r-g50: #f8fafc;
--r-g100: #f1f5f9;
--r-g200: #e2e8f0;
--r-g300: #cbd5e1;
--r-g500: #64748b;
--r-g700: #334155;
--r-g900: #0f172a;
display: block;
font-family: 'DM Sans', system-ui, sans-serif;
background: var(--r-g50);
color: var(--r-g900);
font-size: 14px;
line-height: 1.5;
}
.rr-root {
padding: 18px 22px;
max-width: 1800px;
margin: 0 auto;
}
/* HEADER */
.rr-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 14px;
}
.rr-head-left {
display: flex;
align-items: flex-start;
gap: 14px;
}
.rr-head-ic {
font-size: 26px;
color: var(--r-navy);
margin-top: 3px;
}
.rr-head-t {
margin: 0;
font-size: 18px;
font-weight: 700;
}
.rr-head-s {
font-size: 12px;
color: var(--r-g500);
max-width: 900px;
}
/* BUTTONS */
.rr-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 15px;
border: 1px solid var(--r-g300);
background: #fff;
border-radius: 8px;
font-size: 12.5px;
font-weight: 600;
color: var(--r-g700);
cursor: pointer;
font-family: inherit;
transition: all 0.15s;
&:hover:not(:disabled) {
background: var(--r-g50);
border-color: var(--r-g500);
}
&:disabled {
opacity: 0.55;
cursor: not-allowed;
}
&--primary {
background: var(--r-green);
border-color: var(--r-green);
color: #fff;
&:hover:not(:disabled) {
background: #12692f;
border-color: #12692f;
color: #fff;
}
}
&--slim {
padding: 5px 11px;
font-size: 11.5px;
white-space: nowrap;
}
}
.rr-head-actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
/* BANNER */
.rr-banner {
display: flex;
align-items: center;
gap: 11px;
border-radius: 10px;
padding: 10px 14px;
font-size: 12.5px;
margin-bottom: 14px;
span {
flex: 1;
}
&--sample {
background: var(--r-amber-l);
border: 1px solid #fcd34d;
color: #78350f;
}
}
/* FILTERS */
.rr-filters {
background: #fff;
border: 1px solid var(--r-g200);
border-radius: 11px;
padding: 12px 16px;
margin-bottom: 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
.rr-filter-row {
display: flex;
align-items: flex-end;
gap: 12px;
flex-wrap: wrap;
}
.rr-modes {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.rr-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border: 1px solid var(--r-g300);
background: #fff;
border-radius: 999px;
font-size: 12.5px;
font-weight: 600;
color: var(--r-g700);
cursor: pointer;
font-family: inherit;
&:hover {
border-color: var(--r-navy);
}
&.active {
background: var(--r-navy);
border-color: var(--r-navy);
color: #fff;
}
}
.rr-field {
display: flex;
flex-direction: column;
gap: 3px;
span {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--r-g500);
}
input,
select {
padding: 7px 10px;
border: 1px solid var(--r-g300);
border-radius: 7px;
font-size: 13px;
font-family: inherit;
background: #fff;
color: var(--r-g900);
&:focus {
outline: none;
border-color: var(--r-navy);
}
&:disabled {
background: var(--r-g100);
color: var(--r-g500);
}
}
&--grow {
flex: 1;
min-width: 260px;
input {
width: 100%;
}
}
}
.rr-resolved {
font-size: 12px;
color: var(--r-g500);
padding-bottom: 8px;
}
.rr-check {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12.5px;
font-weight: 600;
color: var(--r-g700);
padding-bottom: 8px;
cursor: pointer;
}
.rr-scope-note {
display: flex;
align-items: center;
gap: 7px;
font-size: 12px;
color: var(--r-amber);
background: var(--r-amber-l);
border: 1px solid #fcd34d;
border-radius: 7px;
padding: 6px 11px;
}
/* LOADING */
.rr-loading {
display: flex;
align-items: center;
justify-content: center;
gap: 9px;
padding: 60px;
color: var(--r-g500);
font-size: 13px;
background: #fff;
border: 1px solid var(--r-g200);
border-radius: 11px;
}
/* KPIs */
.rr-kpis {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 14px;
}
.rr-kpi {
background: #fff;
border: 1px solid var(--r-g200);
border-left: 4px solid var(--r-navy);
border-radius: 11px;
padding: 12px 16px;
&--blue {
border-left-color: var(--r-blue);
}
&--amber {
border-left-color: var(--r-amber);
}
&--red {
border-left-color: var(--r-red);
}
&--dim {
border-left-color: var(--r-g300);
}
}
.rr-kpi-n {
font-size: 22px;
font-weight: 700;
font-family: 'DM Mono', monospace;
}
.rr-kpi-l {
font-size: 12px;
font-weight: 600;
color: var(--r-g700);
}
.rr-kpi-sub {
font-size: 11px;
color: var(--r-g500);
}
/* RULE MIX */
.rr-mix {
display: flex;
align-items: center;
gap: 7px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.rr-mix-t {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--r-g500);
margin-right: 2px;
}
.rr-mix-item {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 11px;
border: 1px solid var(--r-g200);
background: #fff;
border-radius: 7px;
font-size: 11.5px;
font-weight: 600;
color: var(--r-g700);
cursor: pointer;
font-family: inherit;
strong {
font-family: 'DM Mono', monospace;
color: var(--r-navy);
}
&:hover {
border-color: var(--r-navy);
}
&.active {
background: var(--r-navy);
border-color: var(--r-navy);
color: #fff;
strong {
color: #fff;
}
}
}
/* VIEW SWITCH */
.rr-views {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.rr-spacer {
flex: 1;
}
.rr-count {
font-size: 12px;
color: var(--r-g500);
font-weight: 600;
}
/* TABLE */
.rr-table-wrap {
background: #fff;
border: 1px solid var(--r-g200);
border-radius: 11px;
overflow-x: auto;
}
.rr-table {
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
th {
text-align: left;
padding: 9px 10px;
background: var(--r-g100);
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--r-g700);
white-space: nowrap;
border-bottom: 1px solid var(--r-g200);
}
td {
padding: 8px 10px;
border-bottom: 1px solid var(--r-g100);
vertical-align: top;
}
.num {
text-align: right;
font-family: 'DM Mono', monospace;
}
.strong {
font-weight: 700;
}
tbody tr:hover:not(.rr-detail) {
background: var(--r-g50);
}
}
.rr-cust {
cursor: pointer;
&.open {
background: var(--r-blue-l);
}
}
.rr-row--escalated {
background: var(--r-red-l);
}
.rr-detail {
background: #fcfcfe;
td {
padding: 12px 16px 16px;
}
}
.rr-subtable {
width: 100%;
border-collapse: collapse;
font-size: 12px;
background: #fff;
border: 1px solid var(--r-g200);
border-radius: 8px;
overflow: hidden;
th {
text-align: left;
padding: 7px 10px;
background: var(--r-g50);
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--r-g500);
white-space: nowrap;
}
td {
padding: 7px 10px;
border-top: 1px solid var(--r-g100);
vertical-align: top;
}
.num {
text-align: right;
font-family: 'DM Mono', monospace;
}
.strong {
font-weight: 700;
}
}
/* One alert inside an expanded customer: rule, why, entries, outcome. */
.rr-case {
background: #fff;
border: 1px solid var(--r-g200);
border-radius: 9px;
padding: 10px 12px;
margin-bottom: 10px;
&:last-child {
margin-bottom: 0;
}
}
.rr-case-head {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 6px;
}
.rr-case-id {
font-size: 11px;
color: var(--r-g500);
}
.rr-case-amt {
font-size: 13px;
font-weight: 700;
}
.rr-case-why {
display: flex;
align-items: baseline;
gap: 7px;
font-size: 11.5px;
color: #78350f;
background: var(--r-amber-l);
border-radius: 6px;
padding: 5px 10px;
margin-bottom: 8px;
}
.rr-case-foot {
display: flex;
align-items: baseline;
gap: 18px;
flex-wrap: wrap;
margin-top: 7px;
padding-top: 7px;
border-top: 1px dashed var(--r-g200);
}
.rr-case-remark {
font-size: 11.5px;
color: var(--r-g700);
}
/* Channel of a single entry */
.rr-chan {
display: inline-block;
padding: 1px 7px;
border-radius: 5px;
font-size: 10.5px;
font-weight: 700;
background: var(--r-g100);
color: var(--r-g700);
white-space: nowrap;
}
/* Position of an entry within a multi-leg pattern alert */
.rr-leg {
display: inline-block;
margin-left: 5px;
padding: 1px 7px;
border-radius: 5px;
font-size: 10px;
font-weight: 700;
background: var(--r-blue-l);
color: var(--r-blue);
white-space: nowrap;
}
.rr-leg--ctr {
background: var(--r-red-l);
}
.cr {
color: var(--r-green);
font-weight: 700;
}
.dr {
color: var(--r-red);
font-weight: 700;
}
/* Rule cell */
.rr-rules-cell {
min-width: 230px;
max-width: 340px;
}
.rr-rule {
display: inline-block;
margin: 0 5px 3px 0;
padding: 1px 8px;
border-radius: 999px;
font-size: 10.5px;
font-weight: 700;
background: var(--r-violet-l);
color: var(--r-violet);
}
.rr-rule-cat {
display: inline-block;
padding: 1px 7px;
border-radius: 5px;
font-size: 10px;
font-weight: 700;
background: var(--r-g100);
color: var(--r-g700);
}
.rr-rule-desc {
font-size: 11px;
color: var(--r-g500);
margin-top: 2px;
}
.rr-suggest {
font-size: 11px;
color: var(--r-blue);
margin-top: 3px;
display: flex;
align-items: baseline;
gap: 5px;
}
.rr-by {
font-size: 10.5px;
color: var(--r-g500);
margin-top: 2px;
}
/* Risk rating badge */
.rr-risk {
display: inline-block;
padding: 1px 9px;
border-radius: 999px;
font-size: 10.5px;
font-weight: 700;
white-space: nowrap;
&--low {
background: var(--r-green-l);
color: var(--r-green);
}
&--medium {
background: var(--r-amber-l);
color: var(--r-amber);
}
&--high {
background: var(--r-red-l);
color: var(--r-red);
}
&--none {
background: var(--r-g100);
color: var(--r-g500);
}
}
/* Review status badge */
.rr-status {
display: inline-block;
padding: 1px 9px;
border-radius: 999px;
font-size: 10.5px;
font-weight: 700;
white-space: nowrap;
&--open {
background: var(--r-g100);
color: var(--r-g700);
}
&--progress {
background: var(--r-blue-l);
color: var(--r-blue);
}
&--ok {
background: var(--r-green-l);
color: var(--r-green);
}
&--warn {
background: var(--r-amber-l);
color: var(--r-amber);
}
&--danger {
background: var(--r-red-l);
color: var(--r-red);
}
}
/* What the report says should happen to the rating */
.rr-verdict-cell {
min-width: 210px;
max-width: 300px;
}
.rr-verdict {
display: inline-block;
font-size: 11px;
font-weight: 600;
color: var(--r-g500);
white-space: nowrap;
&--up {
color: var(--r-amber);
}
/* A rating coming down is good news, not a warning. */
&--down {
color: var(--r-green);
}
&--str {
color: var(--r-red);
font-weight: 700;
}
}
.rr-verdict-why {
font-size: 10.5px;
color: var(--r-g500);
margin-top: 2px;
white-space: normal;
}
.rr-tag {
display: inline-block;
margin-left: 6px;
padding: 1px 7px;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
background: var(--r-g100);
color: var(--r-g700);
white-space: nowrap;
&--ctr {
background: var(--r-red-l);
color: var(--r-red);
}
}
.mono {
font-family: 'DM Mono', monospace;
font-size: 11.5px;
}
.rr-empty {
padding: 44px;
text-align: center;
color: var(--r-g500);
font-size: 13px;
}
.rr-legend {
font-size: 11.5px;
color: var(--r-g500);
margin: 10px 2px 0;
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
/* TOAST */
.rr-toast {
position: fixed;
bottom: 32px;
left: 50%;
transform: translateX(-50%) translateY(20px);
background: var(--r-navy);
color: #fff;
padding: 12px 22px;
border-radius: 10px;
font-size: 13px;
box-shadow: 0 6px 20px rgb(0 0 0 / 25%);
opacity: 0;
pointer-events: none;
transition: all 0.25s;
z-index: 1500;
&.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
@media (width <= 1200px) {
.rr-kpis {
grid-template-columns: 1fr 1fr;
}
}
@@ -0,0 +1,726 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component, OnInit, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { forkJoin, of } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
/** Custom Modules */
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
/** Custom Services */
import { AmlService } from '../aml/aml.service';
import { ClientsService } from '../clients/clients.service';
import { BranchScopeService } from '../core/branch-scope.service';
/** Shared report helpers */
import { exportWorkbook } from '../cashier/cashier-export';
import { GL_BRANCHES, GL_PERIOD, glBranchName } from '../gl-reports/gl-reports-data';
/** Report data + computation */
import {
AML_RULE_CATALOGUE,
LiveAmlAlert,
REVIEW_STATUSES,
REVIEW_STATUS_META,
RISK_LABEL,
ReviewStatus,
RiskAlertRow,
RiskLevel,
channelLabel,
mapLiveAlert,
riskFromCategory,
sampleRiskAlerts
} from './risk-review-data';
import {
DateRange,
RatingDirection,
RiskAlertView,
RiskCustomerRow,
RiskReviewFilter,
RiskReviewModel,
RiskReviewSummary,
RiskTxnView,
buildRiskReview,
monthRange,
weekRange
} from './risk-review.compute';
type PeriodMode = 'day' | 'week' | 'month' | 'range';
type ViewMode = 'customer' | 'alert' | 'txn';
/** Starting value for the memo, used before the first filter pass runs. */
const EMPTY_SUMMARY: RiskReviewSummary = {
customers: 0,
alerts: 0,
txns: 0,
ctrReportable: 0,
openAlerts: 0,
escalatedCustomers: 0,
upgradeCandidates: 0,
downgradeCandidates: 0,
totalAmount: 0,
byRisk: { LOW: 0, MEDIUM: 0, HIGH: 0, UNRATED: 0 },
byRule: [],
byStatus: []
};
/**
* Risk Review Report — the AML/compliance worklist.
*
* Lists the customers who tripped an AML rule in the period, the transaction
* that tripped it, the rating currently on their KYC record and where the
* review has got to. The reviewer's decision on each case is one of: keep the
* rating, move it, refresh KYC, or escalate to the Principal Officer for an
* STR — which is the vocabulary in `REVIEW_STATUS_META`.
*
* Rows come from the live rule engine. If the engine has raised nothing (or is
* unreachable) the report falls back to a deterministic sample set and says so
* on screen and in the workbook, so sample rows are never taken for live data.
*/
@Component({
selector: 'mifosx-risk-review-report',
templateUrl: './risk-review-report.component.html',
styleUrls: ['./risk-review-report.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent,
FormsModule
]
})
export class RiskReviewReportComponent implements OnInit {
private aml = inject(AmlService);
private clients = inject(ClientsService);
private scope = inject(BranchScopeService);
readonly period = GL_PERIOD;
readonly branches = GL_BRANCHES;
readonly rules = AML_RULE_CATALOGUE;
readonly reviewStatuses = REVIEW_STATUSES;
readonly statusMeta = REVIEW_STATUS_META;
readonly riskLabel = RISK_LABEL;
readonly channelLabel = channelLabel;
readonly periodModes: { key: PeriodMode; label: string }[] = [
{ key: 'day', label: 'Day wise' },
{ key: 'week', label: 'Week wise' },
{ key: 'month', label: 'Month wise' },
{ key: 'range', label: 'Between dates' }
];
// ── Source state ───────────────────────────────────────────────────────────
/** Rows returned by the monitoring engine, mapped onto the report shape. */
private liveAlerts: RiskAlertRow[] = [];
loading = true;
/**
* Live alerts and the demonstration set, in one worklist.
*
* Every row keeps its `live` flag and sample rows are tagged in the table, so
* a reviewer can still tell them apart at a glance.
*/
private alerts: RiskAlertRow[] = [];
get hasLive(): boolean {
return this.liveAlerts.length > 0;
}
get sampleCount(): number {
return this.alerts.filter((a) => !a.live).length;
}
// ── Filter state ───────────────────────────────────────────────────────────
/**
* Alerts raised today must be in view, so the default window runs to whichever
* is later — the seeded period end or the real current date.
*/
private static readonly TODAY = new Date().toISOString().slice(0, 10);
private static readonly WINDOW_END =
RiskReviewReportComponent.TODAY > GL_PERIOD.fyEnd ? RiskReviewReportComponent.TODAY : GL_PERIOD.fyEnd;
periodMode: PeriodMode = 'range';
filterDay = RiskReviewReportComponent.WINDOW_END;
/** Any date inside the wanted week; resolved to Mon–Sat. */
filterWeek = '2026-07-27';
filterMonth = '2026-07';
filterFrom = GL_PERIOD.fyStart;
filterTo = RiskReviewReportComponent.WINDOW_END;
branchCode = '';
currentRisk: RiskLevel | '' = '';
ruleCode = '';
status: ReviewStatus | '' = '';
/** Which way the review says the rating should move. '' shows every customer. */
direction: RatingDirection | '' = '';
search = '';
openOnly = false;
view: ViewMode = 'customer';
/** Client ID of the customer row currently expanded. */
expanded: number | null = null;
generating = false;
toastMsg = '';
toastVisible = false;
private toastTimer: any;
ngOnInit(): void {
// A branch login opens on its own book and cannot move off it.
this.branchCode = this.scope.defaultBranchCode;
this.load();
}
// ── Loading ────────────────────────────────────────────────────────────────
load(): void {
this.loading = true;
this.aml
.getAlerts()
.pipe(catchError(() => of([] as LiveAmlAlert[])))
.subscribe((live) => {
const rows = (live || []).map((a) => mapLiveAlert(a as unknown as LiveAmlAlert));
this.liveAlerts = rows;
this.compose();
if (!rows.length) {
this.loading = false;
return;
}
this.enrichCurrentRisk(rows);
});
}
/** Live alerts first, then the demonstration set behind them. */
private compose(): void {
this.alerts = [
...this.liveAlerts,
...sampleRiskAlerts()
];
this.memoKey = '';
}
/**
* The alert payload carries no rating, so the customer's current risk is read
* off their KYC record — one call per distinct customer, not per alert. A
* client whose KYC cannot be read stays unrated rather than blocking the
* report, so the table renders either way.
*/
private enrichCurrentRisk(rows: RiskAlertRow[]): void {
const clientIds = [...new Set(rows.map((r) => r.clientId))].filter((id) => id != null);
if (!clientIds.length) {
this.loading = false;
return;
}
forkJoin(
clientIds.map((id) =>
this.clients.getClientKyc(String(id), true).pipe(
map((kyc: any) => ({ id, risk: riskFromCategory(kyc?.riskCategory) })),
catchError(() => of({ id, risk: null as RiskLevel | null }))
)
)
).subscribe((results) => {
const byClient = new Map(
results.map((r) => [
r.id,
r.risk
])
);
this.liveAlerts = rows.map((r) => ({ ...r, currentRisk: byClient.get(r.clientId) ?? null }));
this.compose();
this.loading = false;
});
}
// ── Filter resolution ──────────────────────────────────────────────────────
get range(): DateRange {
if (this.periodMode === 'day') {
return { from: this.filterDay, to: this.filterDay };
}
if (this.periodMode === 'week') {
return weekRange(this.filterWeek);
}
if (this.periodMode === 'month') {
return monthRange(this.filterMonth);
}
return { from: this.filterFrom, to: this.filterTo };
}
get rangeLabel(): string {
const r = this.range;
return r.from === r.to ? r.from : `${r.from} to ${r.to}`;
}
get canSelectBranch(): boolean {
return this.scope.canSelectBranch;
}
get branchLabel(): string {
return this.scope.scopeLabel(this.branchCode);
}
/**
* Applied at the data boundary rather than only in the UI, so a branch login
* cannot widen its own scope by tampering with component state.
*/
private get effectiveBranch(): string {
return this.scope.enforce(this.branchCode);
}
private get filter(): RiskReviewFilter {
return {
range: this.range,
branchCode: this.effectiveBranch,
currentRisk: this.currentRisk,
ruleCode: this.ruleCode,
status: this.status,
direction: this.direction,
search: this.search,
openOnly: this.openOnly
};
}
resetFilters(): void {
this.periodMode = 'range';
this.filterDay = RiskReviewReportComponent.WINDOW_END;
this.filterWeek = '2026-07-27';
this.filterMonth = '2026-07';
this.filterFrom = GL_PERIOD.fyStart;
this.filterTo = RiskReviewReportComponent.WINDOW_END;
this.currentRisk = '';
this.ruleCode = '';
this.status = '';
this.direction = '';
this.search = '';
this.openOnly = false;
this.expanded = null;
// Reset returns a branch login to its own branch, never to "all".
this.branchCode = this.scope.defaultBranchCode;
}
// ── Data ───────────────────────────────────────────────────────────────────
/**
* The whole screen reads from one computed model. Change detection hits these
* getters many times per cycle, so the result is memoised against the filter
* rather than re-filtering thousands of rows on every binding.
*/
private memoKey = '';
private memo: RiskReviewModel = { alerts: [], customers: [], txns: [], summary: EMPTY_SUMMARY };
private get model(): RiskReviewModel {
const key = JSON.stringify(this.filter) + `|${this.alerts.length}`;
if (key !== this.memoKey) {
this.memo = buildRiskReview(this.alerts, this.filter);
this.memoKey = key;
}
return this.memo;
}
get alertRows(): RiskAlertView[] {
return this.model.alerts;
}
get customerRows(): RiskCustomerRow[] {
return this.model.customers;
}
get txnRows(): RiskTxnView[] {
return this.model.txns;
}
get summary(): RiskReviewSummary {
return this.model.summary;
}
toggle(clientId: number): void {
this.expanded = this.expanded === clientId ? null : clientId;
}
/** One-click drill from the rule mix strip into that rule. */
toggleRule(code: string): void {
this.ruleCode = this.ruleCode === code ? '' : code;
this.expanded = null;
}
// ── Presentation helpers ───────────────────────────────────────────────────
riskClass(level: RiskLevel | null): string {
return level ? `rr-risk rr-risk--${level.toLowerCase()}` : 'rr-risk rr-risk--none';
}
riskText(level: RiskLevel | null): string {
return level ? RISK_LABEL[level] : 'Unrated';
}
statusClass(status: ReviewStatus): string {
return `rr-status rr-status--${REVIEW_STATUS_META[status].tone}`;
}
/** What the review concludes should happen to the rating, in one phrase. */
verdictText(c: RiskCustomerRow): string {
if (c.escalated) {
return 'Escalated to STR';
}
if (c.direction === 'up') {
return `Review upward → ${RISK_LABEL[c.recommendedRisk]}`;
}
if (c.direction === 'down') {
return `Review downward → ${RISK_LABEL[c.recommendedRisk]}`;
}
return 'Within current rating';
}
verdictClass(c: RiskCustomerRow): string {
if (c.escalated) {
return 'rr-verdict rr-verdict--str';
}
if (c.direction === 'up') {
return 'rr-verdict rr-verdict--up';
}
if (c.direction === 'down') {
return 'rr-verdict rr-verdict--down';
}
return 'rr-verdict';
}
/** Transaction detail as the reviewer would read it in one line. */
txnDetail(a: RiskAlertView): string {
const amount = `₹${a.amount.toLocaleString('en-IN')}`;
if (a.txnCount > 1) {
return `${a.txnCount} × ${channelLabel(a.channel)} aggregating ${amount}`;
}
return `${channelLabel(a.channel)} of ${amount}`;
}
// ── Excel ──────────────────────────────────────────────────────────────────
async exportExcel(): Promise<void> {
this.generating = true;
try {
const s = this.summary;
const subtitle =
`Period: ${this.rangeLabel} · Branch: ${this.branchLabel} · ` +
`Source: live AML monitoring engine` +
(this.sampleCount ? ` + ${this.sampleCount} DEMONSTRATION rows (not compliance data)` : '');
await exportWorkbook(
[
{
sheetName: 'Summary',
title: 'Risk Review Report — Summary',
subtitle,
headers: [
'Particulars',
'Value'
],
widths: [
56,
22
],
rows: [
[
'Customers flagged for risk review',
String(s.customers)
],
[
'AML alerts triggered',
String(s.alerts)
],
[
'Transactions behind those alerts',
String(s.txns)
],
[
' of which cash entries at or above the ₹10,00,000 CTR limit',
String(s.ctrReportable)
],
[
'Alerts still awaiting a reviewer decision',
String(s.openAlerts)
],
[
'Customers rated below what the triggered rules point at (review upward)',
String(s.upgradeCandidates)
],
[
'Customers rated above what the period’s activity warrants (review downward)',
String(s.downgradeCandidates)
],
[
'Customers escalated to the Principal Officer (STR)',
String(s.escalatedCustomers)
],
[
'Total transaction value under review',
s.totalAmount
],
[
'',
''
],
[
'Customers currently rated Low',
String(s.byRisk.LOW)
],
[
'Customers currently rated Medium',
String(s.byRisk.MEDIUM)
],
[
'Customers currently rated High',
String(s.byRisk.HIGH)
],
[
'Customers with no rating on record',
String(s.byRisk.UNRATED)
]
]
},
{
sheetName: 'Rules triggered',
title: 'Risk Review Report — AML Rules Triggered',
subtitle,
headers: [
'AML Rule Triggered',
'Category',
'Alerts',
'What the rule looks for',
'Action expected of the reviewer'
],
widths: [
34,
14,
10,
62,
62
],
rows: s.byRule.map((r) => {
const meta = AML_RULE_CATALOGUE.find((m) => m.code === r.code);
return [
r.label,
meta?.category ?? '—',
r.count,
meta?.description ?? '—',
meta?.suggestedAction ?? '—'
];
})
},
{
sheetName: 'Customer wise',
title: 'Risk Review Report — Customer wise',
subtitle,
headers: [
'Customer ID',
'Customer Name',
'Account Number',
'Branch',
'Current Risk',
'Alerts',
'AML Rules Triggered',
'Total Amount',
'Largest Alert',
'First Alert',
'Last Alert',
'Rules Point At',
'Recommended Risk',
'Open Cases',
'Position',
'Basis'
],
widths: [
13,
28,
20,
16,
13,
8,
46,
16,
16,
12,
12,
14,
16,
11,
24,
70
],
rows: this.customerRows.map((c) => [
String(c.clientId),
c.customerName,
c.accountNo || '—',
c.branchCode ? glBranchName(c.branchCode) : '—',
this.riskText(c.currentRisk),
c.alertCount,
c.ruleLabels.join('; '),
c.totalAmount,
c.largestAmount,
c.firstAlertDate,
c.lastAlertDate,
RISK_LABEL[c.proposedRisk],
RISK_LABEL[c.recommendedRisk],
c.openCount,
this.verdictText(c),
c.directionReason || '—'
])
},
{
sheetName: 'Alert wise',
title: 'Risk Review Report — Alert wise',
subtitle,
headers: [
'Alert ID',
'Alert Date',
'Customer ID',
'Customer Name',
'Account Number',
'Branch',
'Current Risk',
'Transaction Details',
'AML Rule Triggered',
'Rule Category',
'Amount',
'Review Status',
'Reviewed By',
'Reviewer Remarks',
'Action Expected'
],
widths: [
11,
12,
13,
28,
20,
16,
13,
38,
32,
14,
16,
22,
16,
60,
60
],
rows: this.alertRows.map((a) => [
a.alertId,
a.alertDate,
String(a.clientId),
a.customerName,
a.accountNo || '—',
a.branchCode ? glBranchName(a.branchCode) : '—',
this.riskText(a.currentRisk),
this.txnDetail(a),
a.rule.label,
a.rule.category,
a.amount,
REVIEW_STATUS_META[a.status].label,
a.reviewedBy || '—',
a.remarks || '—',
a.rule.suggestedAction
])
},
{
sheetName: 'Transaction wise',
title: 'Risk Review Report — Transaction wise (rule triggered by each entry)',
subtitle,
headers: [
'Txn Date',
'Txn ID',
'Customer ID',
'Customer Name',
'Account Number',
'Branch',
'Current Risk',
'Channel',
'Dr/Cr',
'Amount',
'Balance After',
'Narration',
'Counterparty',
'AML Rule Triggered',
'Rule Category',
'Leg',
'Alert ID',
'Alert Date',
'Review Status',
'CTR'
],
widths: [
12,
12,
13,
28,
20,
16,
13,
18,
7,
16,
16,
32,
38,
32,
14,
9,
11,
12,
22,
7
],
rows: this.txnRows.map((t) => [
t.date,
t.txnId,
String(t.clientId),
t.customerName,
t.accountNo || '—',
t.branchCode ? glBranchName(t.branchCode) : '—',
this.riskText(t.currentRisk),
channelLabel(t.channel),
t.drCr,
t.amount,
t.balanceAfter || null,
t.narration || '—',
t.counterparty || '—',
t.rule.label,
t.rule.category,
t.legCount > 1 ? `${t.legIndex} of ${t.legCount}` : '—',
t.alertId,
t.alertDate,
REVIEW_STATUS_META[t.status].label,
t.ctrReportable ? 'Yes' : ''
])
}
],
`Risk-Review-Report-${this.range.from}-to-${this.range.to}`
);
this.toast(
`Exported ${s.txns} transactions across ${s.alerts} alerts and ${s.customers} customers for ${this.rangeLabel}`
);
} catch {
this.toast('Could not generate the workbook — please retry');
} finally {
this.generating = false;
}
}
private toast(msg: string): void {
this.toastMsg = msg;
this.toastVisible = true;
if (this.toastTimer) {
clearTimeout(this.toastTimer);
}
this.toastTimer = setTimeout(() => (this.toastVisible = false), 3200);
}
}
+489
View File
@@ -0,0 +1,489 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import {
AmlRuleMeta,
CTR_THRESHOLD,
MATERIALITY,
NON_MITIGABLE_RULES,
REVIEW_STATUS_META,
RISK_LABEL,
RiskAlertRow,
RiskLevel,
RiskTxn,
ReviewStatus,
amlRuleMeta,
riskRank
} from './risk-review-data';
/** Which way the review says the rating should move. */
export type RatingDirection = 'up' | 'down' | 'same';
const RANK_TO_LEVEL: Record<number, RiskLevel> = { 1: 'LOW', 2: 'MEDIUM', 3: 'HIGH' };
export interface DateRange {
from: string;
to: string;
}
export interface RiskReviewFilter {
range: DateRange;
/** '' means every branch. */
branchCode: string;
/** '' means every rating. */
currentRisk: RiskLevel | '';
/** '' means every rule. */
ruleCode: string;
/** '' means every review status. */
status: ReviewStatus | '';
/** '' means every customer, whichever way their rating should move. */
direction: RatingDirection | '';
/** Free text over customer name, customer ID, account number and alert ID. */
search: string;
/** Only cases a reviewer still has to act on. */
openOnly: boolean;
}
/** An alert joined to its rule catalogue entry, ready to render. */
export interface RiskAlertView extends RiskAlertRow {
rule: AmlRuleMeta;
}
/**
* One transaction carrying the alert and rule it belongs to.
*
* This is the row that answers "which AML rule did this transaction trip" —
* the alert is flattened onto each of its entries so the same transaction can
* be read on its own, without expanding a customer first.
*/
export interface RiskTxnView extends RiskTxn {
alertId: string;
alertDate: string;
clientId: number;
customerName: string;
accountNo: string;
branchCode: string;
currentRisk: RiskLevel | null;
status: ReviewStatus;
remarks: string;
rule: AmlRuleMeta;
/** Position of this entry within its alert, e.g. "2 of 7" for a series. */
legIndex: number;
legCount: number;
}
/** All of one customer's alerts in the period, rolled up. */
export interface RiskCustomerRow {
clientId: number;
customerName: string;
accountNo: string;
branchCode: string;
currentRisk: RiskLevel | null;
alerts: RiskAlertView[];
alertCount: number;
/** Sum of the amounts behind the alerts — the exposure under review. */
totalAmount: number;
largestAmount: number;
/** Highest risk any of the triggered rules points at, before materiality. */
proposedRisk: RiskLevel;
/** What the review concludes the rating should be, after materiality. */
recommendedRisk: RiskLevel;
/** Whether that is above, below or the same as the rating on record. */
direction: RatingDirection;
/** One line explaining the recommendation, shown under the verdict. */
directionReason: string;
/** Distinct rule labels, for the "AML Rule Triggered" cell. */
ruleLabels: string[];
/** Earliest and latest alert in the period. */
firstAlertDate: string;
lastAlertDate: string;
openCount: number;
/** True when any alert on the customer has gone to the Principal Officer. */
escalated: boolean;
/** True when the rules point above the rating currently on the KYC record. */
needsUpgrade: boolean;
/** True when the rating on record is higher than the activity warrants. */
needsDowngrade: boolean;
}
export interface RiskReviewSummary {
customers: number;
alerts: number;
/** Individual entries behind those alerts. */
txns: number;
/** Cash entries at or above the CTR limit on their own. */
ctrReportable: number;
openAlerts: number;
escalatedCustomers: number;
upgradeCandidates: number;
downgradeCandidates: number;
totalAmount: number;
byRisk: Record<'LOW' | 'MEDIUM' | 'HIGH' | 'UNRATED', number>;
/** Rule mix across the filtered set, most-triggered first. */
byRule: { code: string; label: string; count: number }[];
byStatus: { status: ReviewStatus; label: string; count: number }[];
}
function inRange(date: string, range: DateRange): boolean {
return date >= range.from && date <= range.to;
}
function inr(n: number): string {
return `₹${Math.round(n).toLocaleString('en-IN')}`;
}
/**
* Works out what the rating ought to be once the size of the activity is taken
* into account, and why.
*
* The rules give an upper bound — what the behaviour could mean at worst. The
* amounts then pull that bound down: a customer sitting at High who has only put
* a handful of small transfers through has not behaved like a High-risk customer
* this period, and the review should say so. Two things stop the pull-down: a
* case already referred for an STR, and rules whose seriousness has nothing to
* do with value — a sanctions match or a structuring pattern stays grave at any
* amount.
*/
function recommendRating(
impliedRank: number,
currentRisk: RiskLevel | null,
largestAmount: number,
totalAmount: number,
escalated: boolean,
ruleCodes: string[]
): { risk: RiskLevel; reason: string } {
const implied = RANK_TO_LEVEL[impliedRank] ?? 'LOW';
const grave = ruleCodes.find((c) => NON_MITIGABLE_RULES.has(c));
const material = largestAmount >= CTR_THRESHOLD || totalAmount >= MATERIALITY.highTotal;
const small = largestAmount < MATERIALITY.smallTxn && totalAmount < MATERIALITY.smallTotal;
// What the rating should be. Amounts only ever pull the rules' bound down, and
// only where nothing weightier holds it up.
let risk: RiskLevel;
let basis: 'escalated' | 'grave' | 'material' | 'small' | 'moderate';
if (escalated) {
risk = 'HIGH';
basis = 'escalated';
} else if (grave) {
risk = implied;
basis = 'grave';
} else if (material) {
risk = implied;
basis = 'material';
} else {
risk = RANK_TO_LEVEL[Math.min(impliedRank, small ? 1 : 2)] ?? 'LOW';
basis = small ? 'small' : 'moderate';
}
// The wording is worked out from the conclusion, not the branch, so a customer
// being brought down is never told the activity supports where they are.
const sizes = `largest entry ${inr(largestAmount)}, ${inr(totalAmount)} in total`;
const rated = currentRisk ? `Rated ${RISK_LABEL[currentRisk]} on record` : 'No rating on record';
const delta = currentRisk ? riskRank(risk) - riskRank(currentRisk) : 0;
if (basis === 'escalated') {
return { risk, reason: 'Case referred to the Principal Officer — rating held at High pending the outcome.' };
}
if (delta < 0) {
if (basis === 'small') {
return { risk, reason: `Only small-value activity — ${sizes}. ${rated}; not supported by this period.` };
}
if (basis === 'moderate') {
return { risk, reason: `Moderate activity — ${sizes}. ${rated}; one step down is proportionate.` };
}
// Material or grave, yet still below where the customer sits: the size of
// the activity is not the reason, the rules that fired are.
return {
risk,
reason:
`Activity of ${inr(totalAmount)} is material, but the rules triggered point only at ${RISK_LABEL[risk]}. ` +
`${rated}.`
};
}
if (delta > 0) {
return { risk, reason: `Triggered rules point at ${RISK_LABEL[risk]}, above the rating on record.` };
}
if (basis === 'grave') {
return {
risk,
reason: `${amlRuleMeta(grave as string).label} carries its own weight — the rating does not come down on amount alone.`
};
}
return { risk, reason: '' };
}
/** Applies every filter and joins each alert to its rule. */
export function riskAlertViews(alerts: RiskAlertRow[], filter: RiskReviewFilter): RiskAlertView[] {
const needle = filter.search.trim().toLowerCase();
return alerts
.filter((a) => {
if (!inRange(a.alertDate, filter.range)) {
return false;
}
// A live alert carries no branch; hiding it under a branch filter would
// silently drop real compliance data, so it stays visible.
if (filter.branchCode && a.branchCode && a.branchCode !== filter.branchCode) {
return false;
}
if (filter.currentRisk && a.currentRisk !== filter.currentRisk) {
return false;
}
if (filter.ruleCode && a.ruleCode !== filter.ruleCode) {
return false;
}
if (filter.status && a.status !== filter.status) {
return false;
}
if (filter.openOnly && !REVIEW_STATUS_META[a.status].open) {
return false;
}
if (needle) {
const hay = `${a.customerName} ${a.clientId} ${a.accountNo} ${a.alertId} ${a.ruleCode}`.toLowerCase();
if (!hay.includes(needle)) {
return false;
}
}
return true;
})
.map((a) => ({ ...a, rule: amlRuleMeta(a.ruleCode, a.ruleDescription) }))
.sort((a, b) => b.alertDate.localeCompare(a.alertDate) || a.customerName.localeCompare(b.customerName));
}
/**
* Rolls the alerts up per customer. Sorted so the cases needing attention —
* escalated, then rated below what the rules point at, then most alerts —
* come to the top of the worklist.
*/
export function riskCustomerRows(views: RiskAlertView[]): RiskCustomerRow[] {
const byClient = new Map<number, RiskAlertView[]>();
for (const v of views) {
const list = byClient.get(v.clientId);
if (list) {
list.push(v);
} else {
byClient.set(v.clientId, [v]);
}
}
const rows: RiskCustomerRow[] = [];
for (const [
clientId,
alerts
] of byClient) {
const dates = alerts.map((a) => a.alertDate).sort();
const proposedRank = Math.max(...alerts.map((a) => riskRank(a.rule.impliedRisk)));
const proposedRisk: RiskLevel = RANK_TO_LEVEL[proposedRank] ?? 'LOW';
const head = alerts[0];
const currentRisk = alerts.find((a) => a.currentRisk)?.currentRisk ?? null;
const totalAmount = alerts.reduce((s, a) => s + a.amount, 0);
const largestAmount = Math.max(...alerts.map((a) => a.amount));
const escalated = alerts.some((a) => a.status === 'ESCALATED_STR' || a.status === 'STR_FILED');
const recommendation = recommendRating(
proposedRank,
currentRisk,
largestAmount,
totalAmount,
escalated,
alerts.map((a) => a.ruleCode)
);
// Where the customer carries no rating there is nothing to move, so an
// unrated customer is never counted as an upgrade or a downgrade.
const delta = currentRisk ? riskRank(recommendation.risk) - riskRank(currentRisk) : 0;
const direction: RatingDirection = delta > 0 ? 'up' : delta < 0 ? 'down' : 'same';
rows.push({
clientId,
customerName: head.customerName,
// Take the first non-blank — live alerts leave these empty.
accountNo: alerts.find((a) => a.accountNo)?.accountNo || '',
branchCode: alerts.find((a) => a.branchCode)?.branchCode || '',
currentRisk,
alerts,
alertCount: alerts.length,
totalAmount,
largestAmount,
proposedRisk,
recommendedRisk: recommendation.risk,
direction,
directionReason: recommendation.reason,
ruleLabels: [...new Set(alerts.map((a) => a.rule.label))],
firstAlertDate: dates[0],
lastAlertDate: dates[dates.length - 1],
openCount: alerts.filter((a) => REVIEW_STATUS_META[a.status].open).length,
escalated,
needsUpgrade: direction === 'up',
needsDowngrade: direction === 'down'
});
}
// Escalations first, then ratings that need moving up, then down, then volume.
return rows.sort(
(a, b) =>
Number(b.escalated) - Number(a.escalated) ||
Number(b.needsUpgrade) - Number(a.needsUpgrade) ||
Number(b.needsDowngrade) - Number(a.needsDowngrade) ||
b.alertCount - a.alertCount ||
b.totalAmount - a.totalAmount
);
}
/**
* Restricts the alerts to customers whose rating should move a given way.
*
* Direction is a property of the customer, not of a single alert, so it can only
* be applied once the rollup has run — the alert and transaction views are then
* narrowed to the same customers so all three views agree.
*/
export function applyDirectionFilter(
views: RiskAlertView[],
customers: RiskCustomerRow[],
direction: RatingDirection | ''
): { views: RiskAlertView[]; customers: RiskCustomerRow[] } {
if (!direction) {
return { views, customers };
}
const keep = customers.filter((c) => c.direction === direction);
const ids = new Set(keep.map((c) => c.clientId));
return { views: views.filter((v) => ids.has(v.clientId)), customers: keep };
}
/**
* Flattens every alert onto its transactions, newest entry first, so each row
* names the rule that transaction tripped.
*/
export function riskTxnRows(views: RiskAlertView[]): RiskTxnView[] {
const rows: RiskTxnView[] = [];
for (const v of views) {
v.txns.forEach((t, i) => {
rows.push({
...t,
alertId: v.alertId,
alertDate: v.alertDate,
clientId: v.clientId,
customerName: v.customerName,
accountNo: v.accountNo,
branchCode: v.branchCode,
currentRisk: v.currentRisk,
status: v.status,
remarks: v.remarks,
rule: v.rule,
legIndex: i + 1,
legCount: v.txns.length
});
});
}
return rows.sort((a, b) => b.date.localeCompare(a.date) || a.txnId.localeCompare(b.txnId));
}
export function riskReviewSummary(views: RiskAlertView[], customers: RiskCustomerRow[]): RiskReviewSummary {
const byRisk: RiskReviewSummary['byRisk'] = { LOW: 0, MEDIUM: 0, HIGH: 0, UNRATED: 0 };
for (const c of customers) {
byRisk[c.currentRisk ?? 'UNRATED']++;
}
const ruleCounts = new Map<string, { label: string; count: number }>();
for (const v of views) {
const entry = ruleCounts.get(v.ruleCode);
if (entry) {
entry.count++;
} else {
ruleCounts.set(v.ruleCode, { label: v.rule.label, count: 1 });
}
}
const statusCounts = new Map<ReviewStatus, number>();
for (const v of views) {
statusCounts.set(v.status, (statusCounts.get(v.status) || 0) + 1);
}
return {
customers: customers.length,
alerts: views.length,
txns: views.reduce((s, v) => s + v.txns.length, 0),
ctrReportable: views.reduce((s, v) => s + v.txns.filter((t) => t.ctrReportable).length, 0),
openAlerts: views.filter((v) => REVIEW_STATUS_META[v.status].open).length,
escalatedCustomers: customers.filter((c) => c.escalated).length,
upgradeCandidates: customers.filter((c) => c.needsUpgrade).length,
downgradeCandidates: customers.filter((c) => c.needsDowngrade).length,
totalAmount: views.reduce((s, v) => s + v.amount, 0),
byRisk,
byRule: [...ruleCounts.entries()]
.map(
([
code,
v
]) => ({ code, label: v.label, count: v.count })
)
.sort((a, b) => b.count - a.count),
byStatus: [...statusCounts.entries()]
.map(
([
status,
count
]) => ({ status, label: REVIEW_STATUS_META[status].label, count })
)
.sort((a, b) => b.count - a.count)
};
}
/** Everything the screen renders, computed in one pass. */
export interface RiskReviewModel {
alerts: RiskAlertView[];
customers: RiskCustomerRow[];
txns: RiskTxnView[];
summary: RiskReviewSummary;
}
/**
* Single entry point for the screen. Filtering, rollup, direction narrowing and
* the summary all run once per filter change rather than once per template
* binding, which matters because every view reads from the same result.
*/
export function buildRiskReview(alerts: RiskAlertRow[], filter: RiskReviewFilter): RiskReviewModel {
const base = riskAlertViews(alerts, filter);
const narrowed = applyDirectionFilter(base, riskCustomerRows(base), filter.direction);
return {
alerts: narrowed.views,
customers: narrowed.customers,
txns: riskTxnRows(narrowed.views),
summary: riskReviewSummary(narrowed.views, narrowed.customers)
};
}
// ── Period helpers (shared with the other period-filtered reports) ───────────
/** Monday to Saturday containing the given date. */
export function weekRange(anyDateInWeek: string): DateRange {
const d = new Date(`${anyDateInWeek}T00:00:00Z`);
const dow = d.getUTCDay();
const backToMonday = dow === 0 ? 6 : dow - 1;
const monday = new Date(d.getTime() - backToMonday * 86400000);
const saturday = new Date(monday.getTime() + 5 * 86400000);
return { from: monday.toISOString().slice(0, 10), to: saturday.toISOString().slice(0, 10) };
}
/** First to last day of the given yyyy-MM. */
export function monthRange(month: string): DateRange {
const [
y,
m
] = month.split('-').map(Number);
const last = new Date(Date.UTC(y, m, 0)).getUTCDate();
return { from: `${month}-01`, to: `${month}-${String(last).padStart(2, '0')}` };
}
+2
View File
@@ -53,6 +53,7 @@ import {
faFileUpload, faFileUpload,
faFileWord, faFileWord,
faFillDrip, faFillDrip,
faGavel,
faGlobe, faGlobe,
faHandHoldingUsd, faHandHoldingUsd,
faHome, faHome,
@@ -297,6 +298,7 @@ export class IconsModule {
faFileExcel, faFileExcel,
faFileExport, faFileExport,
faFillDrip, faFillDrip,
faGavel,
faGlobe, faGlobe,
faHandHoldingHand, faHandHoldingHand,
faHandHoldingHeart, faHandHoldingHeart,
@@ -20,195 +20,143 @@
</div> </div>
</div> </div>
<!-- Pill-style tab navigation --> <!-- Pill-style tab navigation — wraps onto multiple rows so every section stays visible and
<nav mat-tab-nav-bar class="ac-tabs" [tabPanel]="tabPanel"> clickable at once, instead of being hidden behind prev/next scroll arrows. -->
<nav class="ac-tabs">
<a <a
mat-tab-link
class="ac-tab" class="ac-tab"
[routerLink]="['./checker-inbox']" routerLink="./checker-inbox"
routerLinkActive routerLinkActive="ac-tab--active"
#checkerInbox="routerLinkActive"
[active]="checkerInbox.isActive"
*mifosxHasPermission="'ALL_FUNCTIONS_READ'" *mifosxHasPermission="'ALL_FUNCTIONS_READ'"
> >
<fa-icon icon="inbox" class="ac-tab__ic"></fa-icon> <fa-icon icon="inbox" class="ac-tab__ic"></fa-icon>
<span>Checker Inbox</span> <span>Checker Inbox</span>
</a> </a>
<a <a
mat-tab-link
class="ac-tab" class="ac-tab"
[routerLink]="['./client-approval']" routerLink="./client-approval"
routerLinkActive routerLinkActive="ac-tab--active"
#clientApproval="routerLinkActive"
[active]="clientApproval.isActive"
*mifosxHasPermission="'READ_CLIENT'" *mifosxHasPermission="'READ_CLIENT'"
> >
<fa-icon icon="user-check" class="ac-tab__ic"></fa-icon> <fa-icon icon="user-check" class="ac-tab__ic"></fa-icon>
<span>Client Approval</span> <span>Client Approval</span>
</a> </a>
<a <a class="ac-tab" routerLink="./loan-approval" routerLinkActive="ac-tab--active" *mifosxHasPermission="'READ_LOAN'">
mat-tab-link
class="ac-tab"
[routerLink]="['./loan-approval']"
routerLinkActive
#loanApproval="routerLinkActive"
[active]="loanApproval.isActive"
*mifosxHasPermission="'READ_LOAN'"
>
<fa-icon icon="hand-holding-usd" class="ac-tab__ic"></fa-icon> <fa-icon icon="hand-holding-usd" class="ac-tab__ic"></fa-icon>
<span>Loan Approval</span> <span>Loan Approval</span>
</a> </a>
<a <a
mat-tab-link
class="ac-tab" class="ac-tab"
[routerLink]="['./loan-disbursal']" routerLink="./loan-disbursal"
routerLinkActive routerLinkActive="ac-tab--active"
#loanDisbursal="routerLinkActive"
[active]="loanDisbursal.isActive"
*mifosxHasPermission="'READ_LOAN'" *mifosxHasPermission="'READ_LOAN'"
> >
<fa-icon icon="money-bill-wave" class="ac-tab__ic"></fa-icon> <fa-icon icon="money-bill-wave" class="ac-tab__ic"></fa-icon>
<span>Loan Disbursal</span> <span>Loan Disbursal</span>
</a> </a>
<a <a
mat-tab-link
class="ac-tab" class="ac-tab"
[routerLink]="['./reschedule-loan']" routerLink="./reschedule-loan"
routerLinkActive routerLinkActive="ac-tab--active"
#rescheduleLoan="routerLinkActive"
[active]="rescheduleLoan.isActive"
*mifosxHasPermission="'RESCHEDULE_LOAN'" *mifosxHasPermission="'RESCHEDULE_LOAN'"
> >
<fa-icon icon="calendar-alt" class="ac-tab__ic"></fa-icon> <fa-icon icon="calendar-alt" class="ac-tab__ic"></fa-icon>
<span>Reschedule Loan</span> <span>Reschedule Loan</span>
</a> </a>
<a <a
mat-tab-link
class="ac-tab" class="ac-tab"
[routerLink]="['./pending-account']" routerLink="./pending-account"
routerLinkActive routerLinkActive="ac-tab--active"
#pendingAccount="routerLinkActive"
[active]="pendingAccount.isActive"
*mifosxHasPermission="'ALL_FUNCTIONS_READ'" *mifosxHasPermission="'ALL_FUNCTIONS_READ'"
> >
<fa-icon icon="hourglass-half" class="ac-tab__ic"></fa-icon> <fa-icon icon="hourglass-half" class="ac-tab__ic"></fa-icon>
<span>Pending Account</span> <span>Pending Account</span>
</a> </a>
<a <a
mat-tab-link
class="ac-tab" class="ac-tab"
[routerLink]="['./pending-savings-products']" routerLink="./pending-savings-products"
routerLinkActive routerLinkActive="ac-tab--active"
#pendingSavingsProducts="routerLinkActive"
[active]="pendingSavingsProducts.isActive"
*mifosxHasPermission="'ALL_FUNCTIONS_READ'" *mifosxHasPermission="'ALL_FUNCTIONS_READ'"
> >
<fa-icon icon="piggy-bank" class="ac-tab__ic"></fa-icon> <fa-icon icon="piggy-bank" class="ac-tab__ic"></fa-icon>
<span>Pending Product Edits</span> <span>Pending Product Edits</span>
</a> </a>
<a <a
mat-tab-link
class="ac-tab" class="ac-tab"
[routerLink]="['./pending-savings-account-edits']" routerLink="./pending-savings-account-edits"
routerLinkActive routerLinkActive="ac-tab--active"
#pendingSavingsAccountEdits="routerLinkActive"
[active]="pendingSavingsAccountEdits.isActive"
*mifosxHasPermission="'ALL_FUNCTIONS_READ'" *mifosxHasPermission="'ALL_FUNCTIONS_READ'"
> >
<fa-icon icon="edit" class="ac-tab__ic"></fa-icon> <fa-icon icon="edit" class="ac-tab__ic"></fa-icon>
<span>Pending Account Edits</span> <span>Pending Account Edits</span>
</a> </a>
<a <a
mat-tab-link
class="ac-tab" class="ac-tab"
[routerLink]="['./pending-share-account-edits']" routerLink="./pending-share-account-edits"
routerLinkActive routerLinkActive="ac-tab--active"
#pendingShareAccountEdits="routerLinkActive"
[active]="pendingShareAccountEdits.isActive"
*mifosxHasPermission="'ALL_FUNCTIONS_READ'" *mifosxHasPermission="'ALL_FUNCTIONS_READ'"
> >
<fa-icon icon="edit" class="ac-tab__ic"></fa-icon> <fa-icon icon="edit" class="ac-tab__ic"></fa-icon>
<span>Pending Share Account Edits</span> <span>Pending Share Account Edits</span>
</a> </a>
<a <a
mat-tab-link
class="ac-tab" class="ac-tab"
[routerLink]="['./pending-client-edits']" routerLink="./pending-client-edits"
routerLinkActive routerLinkActive="ac-tab--active"
#pendingClientEdits="routerLinkActive"
[active]="pendingClientEdits.isActive"
*mifosxHasPermission="'ALL_FUNCTIONS_READ'" *mifosxHasPermission="'ALL_FUNCTIONS_READ'"
> >
<fa-icon icon="edit" class="ac-tab__ic"></fa-icon> <fa-icon icon="edit" class="ac-tab__ic"></fa-icon>
<span>Pending Client Edits</span> <span>Pending Client Edits</span>
</a> </a>
<a <a
mat-tab-link
class="ac-tab" class="ac-tab"
[routerLink]="['./rejected-account']" routerLink="./rejected-account"
routerLinkActive routerLinkActive="ac-tab--active"
#rejectedAccount="routerLinkActive"
[active]="rejectedAccount.isActive"
*mifosxHasPermission="'ALL_FUNCTIONS_READ'" *mifosxHasPermission="'ALL_FUNCTIONS_READ'"
> >
<fa-icon icon="ban" class="ac-tab__ic"></fa-icon> <fa-icon icon="ban" class="ac-tab__ic"></fa-icon>
<span>Reject Account</span> <span>Reject Account</span>
</a> </a>
<a <a
mat-tab-link
class="ac-tab" class="ac-tab"
[routerLink]="['./terminated-account']" routerLink="./terminated-account"
routerLinkActive routerLinkActive="ac-tab--active"
#terminatedAccount="routerLinkActive"
[active]="terminatedAccount.isActive"
*mifosxHasPermission="'ALL_FUNCTIONS_READ'" *mifosxHasPermission="'ALL_FUNCTIONS_READ'"
> >
<fa-icon icon="times-circle" class="ac-tab__ic"></fa-icon> <fa-icon icon="times-circle" class="ac-tab__ic"></fa-icon>
<span>Terminated Account</span> <span>Terminated Account</span>
</a> </a>
<a <a
mat-tab-link
class="ac-tab" class="ac-tab"
[routerLink]="['./withdrawal-requests']" routerLink="./withdrawal-requests"
routerLinkActive routerLinkActive="ac-tab--active"
#withdrawalRequests="routerLinkActive"
[active]="withdrawalRequests.isActive"
*mifosxHasPermission="'ALL_FUNCTIONS_READ'" *mifosxHasPermission="'ALL_FUNCTIONS_READ'"
> >
<fa-icon icon="money-bill-wave" class="ac-tab__ic"></fa-icon> <fa-icon icon="money-bill-wave" class="ac-tab__ic"></fa-icon>
<span>Withdrawal Requests</span> <span>Withdrawal Requests</span>
</a> </a>
<a <a
mat-tab-link
class="ac-tab" class="ac-tab"
[routerLink]="['./deposit-requests']" routerLink="./deposit-requests"
routerLinkActive routerLinkActive="ac-tab--active"
#depositRequests="routerLinkActive"
[active]="depositRequests.isActive"
*mifosxHasPermission="'ALL_FUNCTIONS_READ'" *mifosxHasPermission="'ALL_FUNCTIONS_READ'"
> >
<fa-icon icon="hand-holding-usd" class="ac-tab__ic"></fa-icon> <fa-icon icon="hand-holding-usd" class="ac-tab__ic"></fa-icon>
<span>Deposit Requests</span> <span>Deposit Requests</span>
</a> </a>
<a <a
mat-tab-link
class="ac-tab" class="ac-tab"
[routerLink]="['./disbursement-requests']" routerLink="./disbursement-requests"
routerLinkActive routerLinkActive="ac-tab--active"
#disbursementRequests="routerLinkActive"
[active]="disbursementRequests.isActive"
*mifosxHasPermission="'ALL_FUNCTIONS_READ'" *mifosxHasPermission="'ALL_FUNCTIONS_READ'"
> >
<fa-icon icon="money-bill-wave" class="ac-tab__ic"></fa-icon> <fa-icon icon="money-bill-wave" class="ac-tab__ic"></fa-icon>
<span>Disbursement & Transfers</span> <span>Disbursement & Transfers</span>
</a> </a>
<a <a
mat-tab-link
class="ac-tab" class="ac-tab"
[routerLink]="['./chequebook-requests']" routerLink="./chequebook-requests"
routerLinkActive routerLinkActive="ac-tab--active"
#chequebookRequests="routerLinkActive"
[active]="chequebookRequests.isActive"
*mifosxHasPermission="'READ_CHEQUEBOOKPENDINGREQUEST'" *mifosxHasPermission="'READ_CHEQUEBOOKPENDINGREQUEST'"
> >
<fa-icon icon="money-check" class="ac-tab__ic"></fa-icon> <fa-icon icon="money-check" class="ac-tab__ic"></fa-icon>
@@ -216,7 +164,7 @@
</a> </a>
</nav> </nav>
<mat-tab-nav-panel #tabPanel> <div class="ac-tab-panel">
<router-outlet></router-outlet> <router-outlet></router-outlet>
</mat-tab-nav-panel> </div>
</div> </div>
@@ -63,7 +63,10 @@ $amber: #f59e0b;
} }
} }
/* ───────── Pill-style tab navigation ───────── */ /* ───────── Pill-style tab navigation ─────────
A plain wrapping flex row, not mat-tab-nav-bar — with 16 sections, Material's single-row strip
hides most of them behind prev/next pagination arrows. Wrapping keeps every tab visible and one
click away instead of a scroll-and-hunt. */
.ac-tabs { .ac-tabs {
margin-bottom: 20px; margin-bottom: 20px;
background: #fff; background: #fff;
@@ -71,88 +74,51 @@ $amber: #f59e0b;
border-radius: 14px; border-radius: 14px;
padding: 6px; padding: 6px;
box-shadow: 0 1px 3px rgb(15 23 42 / 4%); box-shadow: 0 1px 3px rgb(15 23 42 / 4%);
overflow: hidden; display: flex;
flex-wrap: wrap;
::ng-deep {
.mat-mdc-tab-link-container {
border-bottom: none !important;
}
.mat-mdc-tab-links {
gap: 4px; gap: 4px;
}
/* Material draws the active-tab underline indicator with a 2px height —
hide it; our pill background already signals the active tab. */
.mdc-tab-indicator__content--underline,
.mat-mdc-tab-header-pagination-controls-enabled .mdc-tab-indicator__content--underline {
display: none !important;
}
}
} }
.ac-tab { .ac-tab {
display: inline-flex !important; display: inline-flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
height: 38px !important; height: 38px;
min-width: 0 !important; padding: 0 18px;
padding: 0 18px !important; border-radius: 10px;
border-radius: 10px !important; font-family: Inter, 'DM Sans', Roboto, sans-serif;
font-family: Inter, 'DM Sans', Roboto, sans-serif !important; font-size: 13px;
font-size: 13px !important; font-weight: 600;
font-weight: 600 !important; letter-spacing: 0.01em;
letter-spacing: 0.01em !important; color: #64748b;
color: #64748b !important; background: transparent;
background: transparent !important;
transition: all 0.15s ease; transition: all 0.15s ease;
white-space: nowrap; white-space: nowrap;
border: 1px solid transparent !important; border: 1px solid transparent;
opacity: 1 !important; text-decoration: none;
cursor: pointer;
&__ic { &__ic {
font-size: 13px; font-size: 13px;
color: inherit; color: inherit;
} }
&:hover:not(.mdc-tab--active) { &:hover:not(.ac-tab--active) {
background: #f1f5f9 !important; background: #f1f5f9;
color: $navy !important; color: $navy;
} }
/* Material 3 adds `.mdc-tab--active` (and `.mat-mdc-tab-link-active`) to the &.ac-tab--active {
selected link. Both target the same pill state — bold blue gradient. */ background: linear-gradient(135deg, $blue 0%, $blue-d 100%);
&.mdc-tab--active, color: #fff;
&.mat-mdc-tab-link-active {
background: linear-gradient(135deg, $blue 0%, $blue-d 100%) !important;
color: #fff !important;
box-shadow: 0 2px 6px -1px rgb(30 64 175 / 35%); box-shadow: 0 2px 6px -1px rgb(30 64 175 / 35%);
border-color: $blue-d !important; border-color: $blue-d;
/* Material 3 wraps the label in its own span with a color rule that
beats the parent. Force white on every descendant so the active-tab
label is always legible against the dark gradient pill. */
::ng-deep .mdc-tab__text-label,
::ng-deep .mat-mdc-tab-link-content,
::ng-deep span,
.ac-tab__ic {
color: #fff !important;
opacity: 1 !important;
}
} }
} }
@media (width <= 768px) { @media (width <= 768px) {
.ac-tabs {
overflow-x: auto;
::ng-deep .mat-mdc-tab-links {
flex-wrap: nowrap;
}
}
.ac-tab { .ac-tab {
padding: 0 14px !important; padding: 0 14px;
font-size: 12px !important; font-size: 12px;
} }
} }
@@ -8,13 +8,17 @@
/** Angular Imports */ /** Angular Imports */
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { MatTabNav, MatTabLink, MatTabNavPanel } from '@angular/material/tabs'; import { RouterLinkActive, RouterOutlet } from '@angular/router';
import { RouterLinkActive, RouterLink, RouterOutlet } from '@angular/router';
import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** /**
* Approval Center — checker inbox + client/loan approval workflows. * Approval Center — checker inbox + client/loan approval workflows.
*
* The tab strip is a plain wrapping nav (not mat-tab-nav-bar) on purpose: with 16 sections,
* Material's single-row tab strip hides most of them behind prev/next pagination arrows, forcing
* a click-and-scan hunt for whichever one you need. Wrapping onto multiple rows keeps every tab
* visible and one click away.
*/ */
@Component({ @Component({
selector: 'mifosx-checker-inbox-and-tasks', selector: 'mifosx-checker-inbox-and-tasks',
@@ -22,10 +26,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
styleUrls: ['./checker-inbox-and-tasks.component.scss'], styleUrls: ['./checker-inbox-and-tasks.component.scss'],
imports: [ imports: [
...STANDALONE_SHARED_IMPORTS, ...STANDALONE_SHARED_IMPORTS,
MatTabNav,
MatTabLink,
RouterLinkActive, RouterLinkActive,
MatTabNavPanel,
RouterOutlet, RouterOutlet,
FaIconComponent FaIconComponent
] ]
+2 -2
View File
@@ -9,8 +9,8 @@
/* tslint:disable */ /* tslint:disable */
export default { export default {
'mifos_x': { 'mifos_x': {
'version': '260801', 'version': '260803',
'hash': '2a9df828' 'hash': 'b43ed877'
}, },
'allow_switching_backend_instance': true 'allow_switching_backend_instance': true
}; };