Compare commits
24
Commits
be6103e0b3
..
demo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14c5f5070d | ||
|
|
fcf6665772 | ||
|
|
f72abb0921 | ||
|
|
a1a50ab8a3 | ||
|
|
858c277ae3 | ||
|
|
a5124ca3e8 | ||
|
|
1a33f9d6ce | ||
|
|
983c613b8e | ||
|
|
ddb215decf | ||
|
|
2cc8d979ba | ||
|
|
569f17ef46 | ||
|
|
cc5cd4c6ab | ||
|
|
8c7d8287f9 | ||
|
|
504d7ee7f6 | ||
|
|
26091c68b0 | ||
|
|
f8c532f74a | ||
|
|
44b41e504a | ||
|
|
e8d91c8f60 | ||
|
|
8dd9767bdf | ||
|
|
c868beb344 | ||
|
|
b43ed87771 | ||
|
|
ebe67cc410 | ||
|
|
d7f5bfe5f5 | ||
|
|
a982083b14 |
@@ -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">₹{{ 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 = ''">×</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() {
|
||||||
|
|||||||
@@ -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[]> {
|
||||||
|
|||||||
@@ -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 & Books</div>
|
<div class="deposit-group-title">Core & 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&L Impact Reconciliation
|
P&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 & Clearing</div>
|
<div class="deposit-group-title">Bank & 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 & Payments</div>
|
<div class="deposit-group-title">Digital & 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 & Members</div>
|
<div class="deposit-group-title">Instruments & 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 & Financials</div>
|
<div class="deposit-group-title">Compliance & 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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+155
-288
@@ -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 }} · {{ bank.branch }} · Reporting Month <strong>{{ bank.reportingMonth }}</strong>
|
{{ bank.name }} · {{ scopeName }} · Reporting Month <strong>{{ bank.reportingMonth }}</strong>
|
||||||
· PMLA 2002 / FINnet 2.0
|
· 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,301 +195,106 @@
|
|||||||
<!-- 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="fiu-form-meta">
|
<div class="seq-block">
|
||||||
<div class="fiu-form-title">
|
<div class="fiu-form-meta">
|
||||||
<h3>Live CTR Threshold Alerts</h3>
|
<div class="fiu-form-title">
|
||||||
<p>
|
<h3>CTR Submission Files — FINnet 2.0</h3>
|
||||||
Mechanical cash threshold breaches. CTR is filed regardless of outcome — where the source of funds cannot be
|
<p>
|
||||||
evidenced, send the transaction to STR for the Principal Officer to investigate.
|
The CTR is built in this order: the account is described first, then who is behind it, then the two
|
||||||
</p>
|
transaction files. Every grid below is the exact content of its CSV. The customer cards (KC1 / KC2) travel
|
||||||
</div>
|
with the same bundle and are worked on the Customer Cards tab.
|
||||||
<select class="fiu-inp" [(ngModel)]="amlAlertStatusFilter" (ngModelChange)="loadAmlAlerts()">
|
</p>
|
||||||
<option value="">All statuses</option>
|
</div>
|
||||||
<option value="RAISED">Raised</option>
|
<button class="fiu-btn fiu-btn-xls" (click)="exportAll()">
|
||||||
<option value="UNDER_REVIEW">Under Review</option>
|
<fa-icon icon="file-csv"></fa-icon> Download full bundle
|
||||||
<option value="WHITELISTED">Whitelisted</option>
|
|
||||||
<option value="ESCALATED">Escalated</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="fiu-tbl-wrap">
|
|
||||||
<table class="fiu-tbl">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>ID</th>
|
|
||||||
<th>Client</th>
|
|
||||||
<th>Type</th>
|
|
||||||
<th>Rule</th>
|
|
||||||
<th class="ta-r">Amount</th>
|
|
||||||
<th>Raised On</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th class="ta-c">Routes To</th>
|
|
||||||
<th>Notes</th>
|
|
||||||
<th>Action</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (alert of ctrAlerts; track alert.id) {
|
|
||||||
<tr>
|
|
||||||
<td>{{ alert.id }}</td>
|
|
||||||
<td>{{ alert.clientDisplayName }}</td>
|
|
||||||
<td>{{ alert.transactionType }}</td>
|
|
||||||
<td>{{ alert.ruleCode }} — {{ alert.ruleDescription }}</td>
|
|
||||||
<td class="ta-r mono">₹{{ alert.amount | number: '1.0-0' }}</td>
|
|
||||||
<td>{{ alert.raisedOn | date: 'medium' }}</td>
|
|
||||||
<td>
|
|
||||||
<span [class]="amlStatusClass(alert.status)">{{ alert.status }}</span>
|
|
||||||
</td>
|
|
||||||
<td class="ta-c">
|
|
||||||
<span [class]="alertRouteClass(alert.status)">{{ alertRouteLabel(alert.status) }}</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
@if (alert.status === 'RAISED' || alert.status === 'UNDER_REVIEW') {
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="fiu-inp"
|
|
||||||
placeholder="Review notes"
|
|
||||||
[(ngModel)]="amlReviewNotes[alert.id]"
|
|
||||||
/>
|
|
||||||
} @else {
|
|
||||||
<span class="muted">{{ alert.reviewNotes }}</span>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
@if (alert.status === 'RAISED' || alert.status === 'UNDER_REVIEW') {
|
|
||||||
<div class="fiu-action-group" *mifosxHasPermission="'REVIEW_AML_ALERT'">
|
|
||||||
<button class="fiu-btn fiu-btn-xml" (click)="escalateAlert(alert)">Send to STR</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
} @empty {
|
|
||||||
<tr>
|
|
||||||
<td colspan="10" class="fiu-empty">No live alerts.</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<p class="fiu-tbl-legend">
|
|
||||||
<span class="report-pill report-pending">Pending</span> cash breach awaiting review ·
|
|
||||||
<span class="report-pill report-str">Referred to STR</span> source of funds not evidenced — sent to the
|
|
||||||
Principal Officer, who decides there whether to whitelist it or file an STR
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- LIVE: monthly CTR reports (mechanical, threshold-based) -->
|
|
||||||
<div class="fiu-form-meta">
|
|
||||||
<div class="fiu-form-title">
|
|
||||||
<h3>Monthly CTR Reports</h3>
|
|
||||||
<p>
|
|
||||||
Threshold: <strong>₹{{ bank.cashThreshold | number }}</strong> per single txn / connected aggregate
|
|
||||||
per month
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="fiu-action-group">
|
|
||||||
<input class="fiu-inp" type="month" [(ngModel)]="ctrGenerateMonth" />
|
|
||||||
<button class="fiu-btn fiu-btn-g" *mifosxHasPermission="'REVIEW_AML_ALERT'" (click)="generateCtrReport()">
|
|
||||||
Generate
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="fiu-tbl-wrap">
|
<!-- Scope: these controls decide which transactions the six files carry. -->
|
||||||
<table class="fiu-tbl">
|
<div class="fiu-filters">
|
||||||
<thead>
|
<div class="fiu-filter">
|
||||||
<tr>
|
<label>Type</label>
|
||||||
<th>Month</th>
|
<select class="fiu-inp" [(ngModel)]="ctrFilterMode">
|
||||||
<th>Generated On</th>
|
<option value="">All</option>
|
||||||
<th class="ta-c">Records</th>
|
<option value="Deposit">Deposit</option>
|
||||||
<th>Status</th>
|
<option value="Withdrawal">Withdrawal</option>
|
||||||
<th>Submitted On</th>
|
</select>
|
||||||
</tr>
|
</div>
|
||||||
</thead>
|
<div class="fiu-filter">
|
||||||
<tbody>
|
<label>Account contains</label>
|
||||||
@for (r of amlCtrReports; track r.id) {
|
<input class="fiu-inp" type="text" [(ngModel)]="ctrFilterAccount" placeholder="A/c partial" />
|
||||||
<tr>
|
</div>
|
||||||
<td>{{ r.reportMonth }}</td>
|
<div class="fiu-filter">
|
||||||
<td>{{ r.generatedOn | date: 'medium' }}</td>
|
<label>Min Amount (₹)</label>
|
||||||
<td class="ta-c">{{ r.recordCount }}</td>
|
<input class="fiu-inp" type="number" [(ngModel)]="ctrFilterMinAmount" placeholder="0" />
|
||||||
<td>
|
</div>
|
||||||
<span [class]="amlStatusClass(r.status)">{{ r.status }}</span>
|
<button class="fiu-btn fiu-btn-g" (click)="resetCtrFilters()">Reset</button>
|
||||||
</td>
|
</div>
|
||||||
<td>{{ r.submittedOn ? (r.submittedOn | date: 'medium') : '—' }}</td>
|
|
||||||
</tr>
|
<!-- Step rail -->
|
||||||
} @empty {
|
<div class="seq-rail">
|
||||||
<tr>
|
@for (f of finnetSequence; track f.key) {
|
||||||
<td colspan="5" class="fiu-empty">No CTR reports generated yet.</td>
|
<button class="seq-step" [class.active]="activeFinnetFile === f.key" (click)="showFinnetFile(f.key)">
|
||||||
</tr>
|
<span class="seq-n">{{ f.step }}</span>
|
||||||
|
<span class="seq-txt">
|
||||||
|
<strong>{{ f.name }}</strong>
|
||||||
|
<em>{{ f.table.rows.length }} {{ f.table.rows.length === 1 ? 'row' : 'rows' }}</em>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
@if (!$last) {
|
||||||
|
<fa-icon icon="chevron-right" class="seq-arrow"></fa-icon>
|
||||||
}
|
}
|
||||||
</tbody>
|
}
|
||||||
</table>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- FINnet export template (separate concern — TC1/TC2 file-format generation) -->
|
<!-- Open file -->
|
||||||
<div class="fiu-form-meta">
|
<div class="seq-panel">
|
||||||
<div class="fiu-form-title">
|
<div class="seq-panel-h">
|
||||||
<h3>FINnet TC2 Export Template</h3>
|
<div>
|
||||||
<p>
|
<h4>Step {{ openFinnetFile.step }} — {{ openFinnetFile.title }}</h4>
|
||||||
All cash transactions ≥ ₹{{ bank.cashThreshold | number }} (single or aggregate per month per account)
|
<p>{{ openFinnetFile.purpose }}</p>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
<button class="fiu-btn fiu-btn-csv" (click)="downloadOpenFinnetFile()">
|
||||||
<button class="fiu-btn fiu-btn-csv" (click)="exportTc2()">
|
<fa-icon icon="file-csv"></fa-icon> {{ openFinnetFile.name }}.csv
|
||||||
<fa-icon icon="file-csv"></fa-icon> Download TC2.csv
|
</button>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="fiu-filters">
|
<div class="seq-tbl-wrap">
|
||||||
<div class="fiu-filter">
|
<table class="seq-tbl">
|
||||||
<label>Type</label>
|
<thead>
|
||||||
<select class="fiu-inp" [(ngModel)]="ctrFilterMode">
|
<tr>
|
||||||
<option value="">All</option>
|
<th class="seq-rownum">#</th>
|
||||||
<option value="Deposit">Deposit</option>
|
@for (h of openFinnetFile.table.headers; track $index) {
|
||||||
<option value="Withdrawal">Withdrawal</option>
|
<th>{{ h }}</th>
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="fiu-filter">
|
|
||||||
<label>Account contains</label>
|
|
||||||
<input class="fiu-inp" type="text" [(ngModel)]="ctrFilterAccount" placeholder="A/c partial" />
|
|
||||||
</div>
|
|
||||||
<div class="fiu-filter">
|
|
||||||
<label>Min Amount (₹)</label>
|
|
||||||
<input class="fiu-inp" type="number" [(ngModel)]="ctrFilterMinAmount" placeholder="0" />
|
|
||||||
</div>
|
|
||||||
<button class="fiu-btn fiu-btn-g" (click)="resetCtrFilters()">Reset</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="fiu-tbl-wrap">
|
|
||||||
<table class="fiu-tbl">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th class="ta-c">Ref</th>
|
|
||||||
<th>Account</th>
|
|
||||||
<th>Customer</th>
|
|
||||||
<th class="ta-c">Type</th>
|
|
||||||
<th>Date / Time</th>
|
|
||||||
<th class="ta-r">Amount</th>
|
|
||||||
<th>Instrument</th>
|
|
||||||
<th>Branch</th>
|
|
||||||
<th class="ta-c">Relationship</th>
|
|
||||||
<th class="ta-c">Filed Under</th>
|
|
||||||
<th>Compliance Action</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (t of filteredCtr; track t.txnId) {
|
|
||||||
<tr>
|
|
||||||
<td class="ta-c">{{ t.reportRefNo }}</td>
|
|
||||||
<td class="mono">{{ t.accountNumber }}</td>
|
|
||||||
<td>{{ t.customerName }}</td>
|
|
||||||
<td class="ta-c">
|
|
||||||
<span
|
|
||||||
class="dir-pill"
|
|
||||||
[class.deposit]="t.depositOrWithdrawal === 'Deposit'"
|
|
||||||
[class.withdrawal]="t.depositOrWithdrawal === 'Withdrawal'"
|
|
||||||
>
|
|
||||||
{{ t.depositOrWithdrawal }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{{ t.txnDate }} <span class="muted">{{ t.txnTime }}</span>
|
|
||||||
</td>
|
|
||||||
<td class="ta-r mono">
|
|
||||||
<strong>₹{{ t.txnAmount | number: '1.0-0' }}</strong>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{{ t.instrumentType }} <span class="muted">{{ t.instrumentId }}</span>
|
|
||||||
</td>
|
|
||||||
<td class="mono">{{ t.branchCode }}</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 →</button>
|
|
||||||
} @else if (t.complianceStatus === 'EVIDENCED') {
|
|
||||||
<span class="status-pill status-whitelisted">EVIDENCE ON FILE</span>
|
|
||||||
<div class="muted" style="margin-top: 3px">
|
|
||||||
{{ t.evidenceDocType }}
|
|
||||||
@if (t.evidenceReference) {
|
|
||||||
<span> · {{ t.evidenceReference }}</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<div class="fiu-action-group" style="gap: 4px" *mifosxHasPermission="'REVIEW_AML_ALERT'">
|
|
||||||
<button class="fiu-btn fiu-btn-evidence" (click)="startCtrEvidence(t)">Record evidence</button>
|
|
||||||
<button class="fiu-btn fiu-btn-refer" (click)="referCtrTxnToStr(t)">Send to STR</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<!-- Inline evidence capture for this transaction -->
|
|
||||||
@if (evidencingTxn?.txnId === t.txnId) {
|
|
||||||
<tr class="evidence-row">
|
|
||||||
<td colspan="12">
|
|
||||||
<div class="evidence-panel">
|
|
||||||
<div class="evidence-t">
|
|
||||||
<fa-icon icon="check-circle"></fa-icon>
|
|
||||||
Record source-of-funds evidence for {{ t.customerName }} — ₹{{
|
|
||||||
t.txnAmount | number: '1.0-0'
|
|
||||||
}}
|
|
||||||
</div>
|
|
||||||
<div class="evidence-grid">
|
|
||||||
<div class="form-row">
|
|
||||||
<label for="ctrDoc{{ t.txnId }}">Document produced</label>
|
|
||||||
<select id="ctrDoc{{ t.txnId }}" class="fiu-inp" [(ngModel)]="ctrEvidenceDocType">
|
|
||||||
<option value="">Select document…</option>
|
|
||||||
@for (d of evidenceDocTypes; track d) {
|
|
||||||
<option [value]="d">{{ d }}</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-row">
|
|
||||||
<label for="ctrRef{{ t.txnId }}">Document / reference number</label>
|
|
||||||
<input id="ctrRef{{ t.txnId }}" class="fiu-inp" type="text" [(ngModel)]="ctrEvidenceRef" />
|
|
||||||
</div>
|
|
||||||
<div class="fiu-action-group">
|
|
||||||
<button class="fiu-btn fiu-btn-evidence" (click)="confirmCtrEvidence()">
|
|
||||||
Confirm — CTR only
|
|
||||||
</button>
|
|
||||||
<button class="fiu-btn" (click)="cancelCtrEvidence()">Cancel</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="evidence-note">
|
|
||||||
Recording evidence is a documentation step, not a suspicion verdict. The transaction is reported
|
|
||||||
in the CTR either way — this simply confirms no STR referral is needed.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
}
|
</thead>
|
||||||
} @empty {
|
<tbody>
|
||||||
<tr>
|
@for (r of openFinnetFile.table.rows; track $index) {
|
||||||
<td colspan="11" class="fiu-empty">No transactions match the filters.</td>
|
<tr>
|
||||||
</tr>
|
<td class="seq-rownum">{{ $index + 1 }}</td>
|
||||||
}
|
@for (c of r; track $index) {
|
||||||
</tbody>
|
<td>{{ c }}</td>
|
||||||
</table>
|
}
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td [attr.colspan]="openFinnetFile.table.headers.length + 1" class="seq-empty">
|
||||||
|
@if (openFinnetFile.key === 'tc1') {
|
||||||
|
No ATM or card transactions match the current scope. When the bank books none at all, TC1 is
|
||||||
|
still submitted as a header-only file — that is what FIU-IND expects.
|
||||||
|
} @else {
|
||||||
|
No rows for the current filters.
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="fiu-tbl-legend">
|
|
||||||
<span class="report-pill report-ctr">CTR</span> every cash txn ≥ threshold is reported here regardless of
|
|
||||||
outcome. <strong>Record evidence</strong> when the customer produces valid documents — CTR only, no STR ·
|
|
||||||
<span class="report-pill report-str">+ STR</span> <strong>Send to STR</strong> when the source of funds cannot
|
|
||||||
be evidenced; the Principal Officer investigates and decides whether to file with FIU-IND. A referred
|
|
||||||
transaction stays in the CTR file as well.
|
|
||||||
</p>
|
|
||||||
</div>
|
</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>
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
certNo: h.certNo,
|
const fromDate = this.intCalcFromDate || toDate;
|
||||||
instrumentType: h.instrumentType,
|
const glCode = this.intCalcGlCode;
|
||||||
txnType: 'INTEREST',
|
const glName = this.glName(glCode);
|
||||||
voucherNo: `INV/INT/${h.certNo}-${this.txns.length + i + 1}`,
|
const newTxns: InvestmentTxn[] = targets.map((h, i) => {
|
||||||
txnDate: this.intCalcDate || this.asOnDate,
|
const amt = this.interestAccrued(h);
|
||||||
amount: this.interestAccrued(h),
|
h.interestPosted = (h.interestPosted ?? 0) + amt;
|
||||||
particulars: `Interest calculated - ${h.scriptName}`
|
h.interestPostedTillDate = toDate;
|
||||||
}));
|
return {
|
||||||
|
certNo: h.certNo,
|
||||||
|
instrumentType: h.instrumentType,
|
||||||
|
txnType: 'INTEREST',
|
||||||
|
voucherNo: `INV/INT/${h.certNo}-${this.txns.length + i + 1}`,
|
||||||
|
txnDate: toDate,
|
||||||
|
amount: amt,
|
||||||
|
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,
|
||||||
|
|||||||
@@ -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 & Post</div>
|
<div class="pos-section-label">Investment - FD Interest Calculation & 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 & Post</div>
|
<div class="pos-section-label">Investment - Govt./Bond Interest Calculation & 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,72 +561,78 @@ 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',
|
||||||
'Cert No',
|
subtitle: `Period ${this.fdCalcFromDate || '—'} to ${this.fdCalcToDate || this.asOnDate} · Post to ${this.fdCalcGlCode} - ${this.glName(this.fdCalcGlCode)}`,
|
||||||
'Party',
|
headers: [
|
||||||
'Face Value',
|
'Cert No',
|
||||||
'Rate',
|
'Party',
|
||||||
'Frequency',
|
'Face Value',
|
||||||
'Interest'
|
'Rate',
|
||||||
],
|
'Frequency',
|
||||||
[
|
'Interest'
|
||||||
14,
|
],
|
||||||
30,
|
widths: [
|
||||||
16,
|
14,
|
||||||
10,
|
30,
|
||||||
14,
|
16,
|
||||||
16
|
10,
|
||||||
],
|
14,
|
||||||
this.fdCalcTargets.map((h) => [
|
16
|
||||||
h.certNo,
|
],
|
||||||
h.party,
|
rows: this.fdCalcTargets.map((h) => [
|
||||||
h.faceValue,
|
h.certNo,
|
||||||
h.interestRate,
|
h.party,
|
||||||
h.interestFreq,
|
h.faceValue,
|
||||||
this.fdInterest(h)
|
h.interestRate,
|
||||||
])
|
h.interestFreq,
|
||||||
);
|
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',
|
||||||
'Cert No',
|
subtitle: `Period ${this.bondCalcFromDate || '—'} to ${this.bondCalcToDate || this.asOnDate} · Post to ${this.bondCalcGlCode} - ${this.glName(this.bondCalcGlCode)}`,
|
||||||
'Script',
|
headers: [
|
||||||
'Type',
|
'Cert No',
|
||||||
'Face Value',
|
'Script',
|
||||||
'Rate',
|
'Type',
|
||||||
'Frequency',
|
'Face Value',
|
||||||
'Interest'
|
'Rate',
|
||||||
],
|
'Frequency',
|
||||||
[
|
'Interest'
|
||||||
14,
|
],
|
||||||
30,
|
widths: [
|
||||||
16,
|
14,
|
||||||
16,
|
30,
|
||||||
10,
|
16,
|
||||||
14,
|
16,
|
||||||
16
|
10,
|
||||||
],
|
14,
|
||||||
this.bondCalcTargets.map((h) => [
|
16
|
||||||
h.certNo,
|
],
|
||||||
h.scriptName,
|
rows: this.bondCalcTargets.map((h) => [
|
||||||
this.instrumentLabel(h.instrumentType),
|
h.certNo,
|
||||||
h.faceValue,
|
h.scriptName,
|
||||||
h.interestRate,
|
this.instrumentLabel(h.instrumentType),
|
||||||
h.interestFreq,
|
h.faceValue,
|
||||||
this.bondInterest(h)
|
h.interestRate,
|
||||||
])
|
h.interestFreq,
|
||||||
);
|
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)),
|
||||||
|
|||||||
@@ -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
|
||||||
NPA Recovery Register — ₹{{ totalRecoveredAmount | number: '1.0-0' }} recovered against ₹{{
|
class="pos-section-label"
|
||||||
gnpaAmount | number: '1.0-0'
|
style="display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap"
|
||||||
}}
|
>
|
||||||
GNPA outstanding
|
<span>
|
||||||
|
NPA Recovery Register — ₹{{ totalRecoveredAmount | number: '1.0-0' }} recovered against ₹{{
|
||||||
|
gnpaAmount | number: '1.0-0'
|
||||||
|
}}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,489 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
AmlRuleMeta,
|
||||||
|
CTR_THRESHOLD,
|
||||||
|
MATERIALITY,
|
||||||
|
NON_MITIGABLE_RULES,
|
||||||
|
REVIEW_STATUS_META,
|
||||||
|
RISK_LABEL,
|
||||||
|
RiskAlertRow,
|
||||||
|
RiskLevel,
|
||||||
|
RiskTxn,
|
||||||
|
ReviewStatus,
|
||||||
|
amlRuleMeta,
|
||||||
|
riskRank
|
||||||
|
} from './risk-review-data';
|
||||||
|
|
||||||
|
/** Which way the review says the rating should move. */
|
||||||
|
export type RatingDirection = 'up' | 'down' | 'same';
|
||||||
|
|
||||||
|
const RANK_TO_LEVEL: Record<number, RiskLevel> = { 1: 'LOW', 2: 'MEDIUM', 3: 'HIGH' };
|
||||||
|
|
||||||
|
export interface DateRange {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RiskReviewFilter {
|
||||||
|
range: DateRange;
|
||||||
|
/** '' means every branch. */
|
||||||
|
branchCode: string;
|
||||||
|
/** '' means every rating. */
|
||||||
|
currentRisk: RiskLevel | '';
|
||||||
|
/** '' means every rule. */
|
||||||
|
ruleCode: string;
|
||||||
|
/** '' means every review status. */
|
||||||
|
status: ReviewStatus | '';
|
||||||
|
/** '' means every customer, whichever way their rating should move. */
|
||||||
|
direction: RatingDirection | '';
|
||||||
|
/** Free text over customer name, customer ID, account number and alert ID. */
|
||||||
|
search: string;
|
||||||
|
/** Only cases a reviewer still has to act on. */
|
||||||
|
openOnly: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An alert joined to its rule catalogue entry, ready to render. */
|
||||||
|
export interface RiskAlertView extends RiskAlertRow {
|
||||||
|
rule: AmlRuleMeta;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One transaction carrying the alert and rule it belongs to.
|
||||||
|
*
|
||||||
|
* This is the row that answers "which AML rule did this transaction trip" —
|
||||||
|
* the alert is flattened onto each of its entries so the same transaction can
|
||||||
|
* be read on its own, without expanding a customer first.
|
||||||
|
*/
|
||||||
|
export interface RiskTxnView extends RiskTxn {
|
||||||
|
alertId: string;
|
||||||
|
alertDate: string;
|
||||||
|
clientId: number;
|
||||||
|
customerName: string;
|
||||||
|
accountNo: string;
|
||||||
|
branchCode: string;
|
||||||
|
currentRisk: RiskLevel | null;
|
||||||
|
status: ReviewStatus;
|
||||||
|
remarks: string;
|
||||||
|
rule: AmlRuleMeta;
|
||||||
|
/** Position of this entry within its alert, e.g. "2 of 7" for a series. */
|
||||||
|
legIndex: number;
|
||||||
|
legCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All of one customer's alerts in the period, rolled up. */
|
||||||
|
export interface RiskCustomerRow {
|
||||||
|
clientId: number;
|
||||||
|
customerName: string;
|
||||||
|
accountNo: string;
|
||||||
|
branchCode: string;
|
||||||
|
currentRisk: RiskLevel | null;
|
||||||
|
alerts: RiskAlertView[];
|
||||||
|
alertCount: number;
|
||||||
|
/** Sum of the amounts behind the alerts — the exposure under review. */
|
||||||
|
totalAmount: number;
|
||||||
|
largestAmount: number;
|
||||||
|
/** Highest risk any of the triggered rules points at, before materiality. */
|
||||||
|
proposedRisk: RiskLevel;
|
||||||
|
/** What the review concludes the rating should be, after materiality. */
|
||||||
|
recommendedRisk: RiskLevel;
|
||||||
|
/** Whether that is above, below or the same as the rating on record. */
|
||||||
|
direction: RatingDirection;
|
||||||
|
/** One line explaining the recommendation, shown under the verdict. */
|
||||||
|
directionReason: string;
|
||||||
|
/** Distinct rule labels, for the "AML Rule Triggered" cell. */
|
||||||
|
ruleLabels: string[];
|
||||||
|
/** Earliest and latest alert in the period. */
|
||||||
|
firstAlertDate: string;
|
||||||
|
lastAlertDate: string;
|
||||||
|
openCount: number;
|
||||||
|
/** True when any alert on the customer has gone to the Principal Officer. */
|
||||||
|
escalated: boolean;
|
||||||
|
/** True when the rules point above the rating currently on the KYC record. */
|
||||||
|
needsUpgrade: boolean;
|
||||||
|
/** True when the rating on record is higher than the activity warrants. */
|
||||||
|
needsDowngrade: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RiskReviewSummary {
|
||||||
|
customers: number;
|
||||||
|
alerts: number;
|
||||||
|
/** Individual entries behind those alerts. */
|
||||||
|
txns: number;
|
||||||
|
/** Cash entries at or above the CTR limit on their own. */
|
||||||
|
ctrReportable: number;
|
||||||
|
openAlerts: number;
|
||||||
|
escalatedCustomers: number;
|
||||||
|
upgradeCandidates: number;
|
||||||
|
downgradeCandidates: number;
|
||||||
|
totalAmount: number;
|
||||||
|
byRisk: Record<'LOW' | 'MEDIUM' | 'HIGH' | 'UNRATED', number>;
|
||||||
|
/** Rule mix across the filtered set, most-triggered first. */
|
||||||
|
byRule: { code: string; label: string; count: number }[];
|
||||||
|
byStatus: { status: ReviewStatus; label: string; count: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function inRange(date: string, range: DateRange): boolean {
|
||||||
|
return date >= range.from && date <= range.to;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inr(n: number): string {
|
||||||
|
return `₹${Math.round(n).toLocaleString('en-IN')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Works out what the rating ought to be once the size of the activity is taken
|
||||||
|
* into account, and why.
|
||||||
|
*
|
||||||
|
* The rules give an upper bound — what the behaviour could mean at worst. The
|
||||||
|
* amounts then pull that bound down: a customer sitting at High who has only put
|
||||||
|
* a handful of small transfers through has not behaved like a High-risk customer
|
||||||
|
* this period, and the review should say so. Two things stop the pull-down: a
|
||||||
|
* case already referred for an STR, and rules whose seriousness has nothing to
|
||||||
|
* do with value — a sanctions match or a structuring pattern stays grave at any
|
||||||
|
* amount.
|
||||||
|
*/
|
||||||
|
function recommendRating(
|
||||||
|
impliedRank: number,
|
||||||
|
currentRisk: RiskLevel | null,
|
||||||
|
largestAmount: number,
|
||||||
|
totalAmount: number,
|
||||||
|
escalated: boolean,
|
||||||
|
ruleCodes: string[]
|
||||||
|
): { risk: RiskLevel; reason: string } {
|
||||||
|
const implied = RANK_TO_LEVEL[impliedRank] ?? 'LOW';
|
||||||
|
const grave = ruleCodes.find((c) => NON_MITIGABLE_RULES.has(c));
|
||||||
|
const material = largestAmount >= CTR_THRESHOLD || totalAmount >= MATERIALITY.highTotal;
|
||||||
|
const small = largestAmount < MATERIALITY.smallTxn && totalAmount < MATERIALITY.smallTotal;
|
||||||
|
|
||||||
|
// What the rating should be. Amounts only ever pull the rules' bound down, and
|
||||||
|
// only where nothing weightier holds it up.
|
||||||
|
let risk: RiskLevel;
|
||||||
|
let basis: 'escalated' | 'grave' | 'material' | 'small' | 'moderate';
|
||||||
|
if (escalated) {
|
||||||
|
risk = 'HIGH';
|
||||||
|
basis = 'escalated';
|
||||||
|
} else if (grave) {
|
||||||
|
risk = implied;
|
||||||
|
basis = 'grave';
|
||||||
|
} else if (material) {
|
||||||
|
risk = implied;
|
||||||
|
basis = 'material';
|
||||||
|
} else {
|
||||||
|
risk = RANK_TO_LEVEL[Math.min(impliedRank, small ? 1 : 2)] ?? 'LOW';
|
||||||
|
basis = small ? 'small' : 'moderate';
|
||||||
|
}
|
||||||
|
|
||||||
|
// The wording is worked out from the conclusion, not the branch, so a customer
|
||||||
|
// being brought down is never told the activity supports where they are.
|
||||||
|
const sizes = `largest entry ${inr(largestAmount)}, ${inr(totalAmount)} in total`;
|
||||||
|
const rated = currentRisk ? `Rated ${RISK_LABEL[currentRisk]} on record` : 'No rating on record';
|
||||||
|
const delta = currentRisk ? riskRank(risk) - riskRank(currentRisk) : 0;
|
||||||
|
|
||||||
|
if (basis === 'escalated') {
|
||||||
|
return { risk, reason: 'Case referred to the Principal Officer — rating held at High pending the outcome.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (delta < 0) {
|
||||||
|
if (basis === 'small') {
|
||||||
|
return { risk, reason: `Only small-value activity — ${sizes}. ${rated}; not supported by this period.` };
|
||||||
|
}
|
||||||
|
if (basis === 'moderate') {
|
||||||
|
return { risk, reason: `Moderate activity — ${sizes}. ${rated}; one step down is proportionate.` };
|
||||||
|
}
|
||||||
|
// Material or grave, yet still below where the customer sits: the size of
|
||||||
|
// the activity is not the reason, the rules that fired are.
|
||||||
|
return {
|
||||||
|
risk,
|
||||||
|
reason:
|
||||||
|
`Activity of ${inr(totalAmount)} is material, but the rules triggered point only at ${RISK_LABEL[risk]}. ` +
|
||||||
|
`${rated}.`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (delta > 0) {
|
||||||
|
return { risk, reason: `Triggered rules point at ${RISK_LABEL[risk]}, above the rating on record.` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (basis === 'grave') {
|
||||||
|
return {
|
||||||
|
risk,
|
||||||
|
reason: `${amlRuleMeta(grave as string).label} carries its own weight — the rating does not come down on amount alone.`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { risk, reason: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Applies every filter and joins each alert to its rule. */
|
||||||
|
export function riskAlertViews(alerts: RiskAlertRow[], filter: RiskReviewFilter): RiskAlertView[] {
|
||||||
|
const needle = filter.search.trim().toLowerCase();
|
||||||
|
|
||||||
|
return alerts
|
||||||
|
.filter((a) => {
|
||||||
|
if (!inRange(a.alertDate, filter.range)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// A live alert carries no branch; hiding it under a branch filter would
|
||||||
|
// silently drop real compliance data, so it stays visible.
|
||||||
|
if (filter.branchCode && a.branchCode && a.branchCode !== filter.branchCode) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (filter.currentRisk && a.currentRisk !== filter.currentRisk) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (filter.ruleCode && a.ruleCode !== filter.ruleCode) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (filter.status && a.status !== filter.status) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (filter.openOnly && !REVIEW_STATUS_META[a.status].open) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (needle) {
|
||||||
|
const hay = `${a.customerName} ${a.clientId} ${a.accountNo} ${a.alertId} ${a.ruleCode}`.toLowerCase();
|
||||||
|
if (!hay.includes(needle)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.map((a) => ({ ...a, rule: amlRuleMeta(a.ruleCode, a.ruleDescription) }))
|
||||||
|
.sort((a, b) => b.alertDate.localeCompare(a.alertDate) || a.customerName.localeCompare(b.customerName));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rolls the alerts up per customer. Sorted so the cases needing attention —
|
||||||
|
* escalated, then rated below what the rules point at, then most alerts —
|
||||||
|
* come to the top of the worklist.
|
||||||
|
*/
|
||||||
|
export function riskCustomerRows(views: RiskAlertView[]): RiskCustomerRow[] {
|
||||||
|
const byClient = new Map<number, RiskAlertView[]>();
|
||||||
|
for (const v of views) {
|
||||||
|
const list = byClient.get(v.clientId);
|
||||||
|
if (list) {
|
||||||
|
list.push(v);
|
||||||
|
} else {
|
||||||
|
byClient.set(v.clientId, [v]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: RiskCustomerRow[] = [];
|
||||||
|
for (const [
|
||||||
|
clientId,
|
||||||
|
alerts
|
||||||
|
] of byClient) {
|
||||||
|
const dates = alerts.map((a) => a.alertDate).sort();
|
||||||
|
const proposedRank = Math.max(...alerts.map((a) => riskRank(a.rule.impliedRisk)));
|
||||||
|
const proposedRisk: RiskLevel = RANK_TO_LEVEL[proposedRank] ?? 'LOW';
|
||||||
|
const head = alerts[0];
|
||||||
|
|
||||||
|
const currentRisk = alerts.find((a) => a.currentRisk)?.currentRisk ?? null;
|
||||||
|
const totalAmount = alerts.reduce((s, a) => s + a.amount, 0);
|
||||||
|
const largestAmount = Math.max(...alerts.map((a) => a.amount));
|
||||||
|
const escalated = alerts.some((a) => a.status === 'ESCALATED_STR' || a.status === 'STR_FILED');
|
||||||
|
|
||||||
|
const recommendation = recommendRating(
|
||||||
|
proposedRank,
|
||||||
|
currentRisk,
|
||||||
|
largestAmount,
|
||||||
|
totalAmount,
|
||||||
|
escalated,
|
||||||
|
alerts.map((a) => a.ruleCode)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Where the customer carries no rating there is nothing to move, so an
|
||||||
|
// unrated customer is never counted as an upgrade or a downgrade.
|
||||||
|
const delta = currentRisk ? riskRank(recommendation.risk) - riskRank(currentRisk) : 0;
|
||||||
|
const direction: RatingDirection = delta > 0 ? 'up' : delta < 0 ? 'down' : 'same';
|
||||||
|
|
||||||
|
rows.push({
|
||||||
|
clientId,
|
||||||
|
customerName: head.customerName,
|
||||||
|
// Take the first non-blank — live alerts leave these empty.
|
||||||
|
accountNo: alerts.find((a) => a.accountNo)?.accountNo || '',
|
||||||
|
branchCode: alerts.find((a) => a.branchCode)?.branchCode || '',
|
||||||
|
currentRisk,
|
||||||
|
alerts,
|
||||||
|
alertCount: alerts.length,
|
||||||
|
totalAmount,
|
||||||
|
largestAmount,
|
||||||
|
proposedRisk,
|
||||||
|
recommendedRisk: recommendation.risk,
|
||||||
|
direction,
|
||||||
|
directionReason: recommendation.reason,
|
||||||
|
ruleLabels: [...new Set(alerts.map((a) => a.rule.label))],
|
||||||
|
firstAlertDate: dates[0],
|
||||||
|
lastAlertDate: dates[dates.length - 1],
|
||||||
|
openCount: alerts.filter((a) => REVIEW_STATUS_META[a.status].open).length,
|
||||||
|
escalated,
|
||||||
|
needsUpgrade: direction === 'up',
|
||||||
|
needsDowngrade: direction === 'down'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escalations first, then ratings that need moving up, then down, then volume.
|
||||||
|
return rows.sort(
|
||||||
|
(a, b) =>
|
||||||
|
Number(b.escalated) - Number(a.escalated) ||
|
||||||
|
Number(b.needsUpgrade) - Number(a.needsUpgrade) ||
|
||||||
|
Number(b.needsDowngrade) - Number(a.needsDowngrade) ||
|
||||||
|
b.alertCount - a.alertCount ||
|
||||||
|
b.totalAmount - a.totalAmount
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restricts the alerts to customers whose rating should move a given way.
|
||||||
|
*
|
||||||
|
* Direction is a property of the customer, not of a single alert, so it can only
|
||||||
|
* be applied once the rollup has run — the alert and transaction views are then
|
||||||
|
* narrowed to the same customers so all three views agree.
|
||||||
|
*/
|
||||||
|
export function applyDirectionFilter(
|
||||||
|
views: RiskAlertView[],
|
||||||
|
customers: RiskCustomerRow[],
|
||||||
|
direction: RatingDirection | ''
|
||||||
|
): { views: RiskAlertView[]; customers: RiskCustomerRow[] } {
|
||||||
|
if (!direction) {
|
||||||
|
return { views, customers };
|
||||||
|
}
|
||||||
|
const keep = customers.filter((c) => c.direction === direction);
|
||||||
|
const ids = new Set(keep.map((c) => c.clientId));
|
||||||
|
return { views: views.filter((v) => ids.has(v.clientId)), customers: keep };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flattens every alert onto its transactions, newest entry first, so each row
|
||||||
|
* names the rule that transaction tripped.
|
||||||
|
*/
|
||||||
|
export function riskTxnRows(views: RiskAlertView[]): RiskTxnView[] {
|
||||||
|
const rows: RiskTxnView[] = [];
|
||||||
|
|
||||||
|
for (const v of views) {
|
||||||
|
v.txns.forEach((t, i) => {
|
||||||
|
rows.push({
|
||||||
|
...t,
|
||||||
|
alertId: v.alertId,
|
||||||
|
alertDate: v.alertDate,
|
||||||
|
clientId: v.clientId,
|
||||||
|
customerName: v.customerName,
|
||||||
|
accountNo: v.accountNo,
|
||||||
|
branchCode: v.branchCode,
|
||||||
|
currentRisk: v.currentRisk,
|
||||||
|
status: v.status,
|
||||||
|
remarks: v.remarks,
|
||||||
|
rule: v.rule,
|
||||||
|
legIndex: i + 1,
|
||||||
|
legCount: v.txns.length
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows.sort((a, b) => b.date.localeCompare(a.date) || a.txnId.localeCompare(b.txnId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function riskReviewSummary(views: RiskAlertView[], customers: RiskCustomerRow[]): RiskReviewSummary {
|
||||||
|
const byRisk: RiskReviewSummary['byRisk'] = { LOW: 0, MEDIUM: 0, HIGH: 0, UNRATED: 0 };
|
||||||
|
for (const c of customers) {
|
||||||
|
byRisk[c.currentRisk ?? 'UNRATED']++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ruleCounts = new Map<string, { label: string; count: number }>();
|
||||||
|
for (const v of views) {
|
||||||
|
const entry = ruleCounts.get(v.ruleCode);
|
||||||
|
if (entry) {
|
||||||
|
entry.count++;
|
||||||
|
} else {
|
||||||
|
ruleCounts.set(v.ruleCode, { label: v.rule.label, count: 1 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusCounts = new Map<ReviewStatus, number>();
|
||||||
|
for (const v of views) {
|
||||||
|
statusCounts.set(v.status, (statusCounts.get(v.status) || 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
customers: customers.length,
|
||||||
|
alerts: views.length,
|
||||||
|
txns: views.reduce((s, v) => s + v.txns.length, 0),
|
||||||
|
ctrReportable: views.reduce((s, v) => s + v.txns.filter((t) => t.ctrReportable).length, 0),
|
||||||
|
openAlerts: views.filter((v) => REVIEW_STATUS_META[v.status].open).length,
|
||||||
|
escalatedCustomers: customers.filter((c) => c.escalated).length,
|
||||||
|
upgradeCandidates: customers.filter((c) => c.needsUpgrade).length,
|
||||||
|
downgradeCandidates: customers.filter((c) => c.needsDowngrade).length,
|
||||||
|
totalAmount: views.reduce((s, v) => s + v.amount, 0),
|
||||||
|
byRisk,
|
||||||
|
byRule: [...ruleCounts.entries()]
|
||||||
|
.map(
|
||||||
|
([
|
||||||
|
code,
|
||||||
|
v
|
||||||
|
]) => ({ code, label: v.label, count: v.count })
|
||||||
|
)
|
||||||
|
.sort((a, b) => b.count - a.count),
|
||||||
|
byStatus: [...statusCounts.entries()]
|
||||||
|
.map(
|
||||||
|
([
|
||||||
|
status,
|
||||||
|
count
|
||||||
|
]) => ({ status, label: REVIEW_STATUS_META[status].label, count })
|
||||||
|
)
|
||||||
|
.sort((a, b) => b.count - a.count)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything the screen renders, computed in one pass. */
|
||||||
|
export interface RiskReviewModel {
|
||||||
|
alerts: RiskAlertView[];
|
||||||
|
customers: RiskCustomerRow[];
|
||||||
|
txns: RiskTxnView[];
|
||||||
|
summary: RiskReviewSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single entry point for the screen. Filtering, rollup, direction narrowing and
|
||||||
|
* the summary all run once per filter change rather than once per template
|
||||||
|
* binding, which matters because every view reads from the same result.
|
||||||
|
*/
|
||||||
|
export function buildRiskReview(alerts: RiskAlertRow[], filter: RiskReviewFilter): RiskReviewModel {
|
||||||
|
const base = riskAlertViews(alerts, filter);
|
||||||
|
const narrowed = applyDirectionFilter(base, riskCustomerRows(base), filter.direction);
|
||||||
|
|
||||||
|
return {
|
||||||
|
alerts: narrowed.views,
|
||||||
|
customers: narrowed.customers,
|
||||||
|
txns: riskTxnRows(narrowed.views),
|
||||||
|
summary: riskReviewSummary(narrowed.views, narrowed.customers)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Period helpers (shared with the other period-filtered reports) ───────────
|
||||||
|
|
||||||
|
/** Monday to Saturday containing the given date. */
|
||||||
|
export function weekRange(anyDateInWeek: string): DateRange {
|
||||||
|
const d = new Date(`${anyDateInWeek}T00:00:00Z`);
|
||||||
|
const dow = d.getUTCDay();
|
||||||
|
const backToMonday = dow === 0 ? 6 : dow - 1;
|
||||||
|
const monday = new Date(d.getTime() - backToMonday * 86400000);
|
||||||
|
const saturday = new Date(monday.getTime() + 5 * 86400000);
|
||||||
|
return { from: monday.toISOString().slice(0, 10), to: saturday.toISOString().slice(0, 10) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First to last day of the given yyyy-MM. */
|
||||||
|
export function monthRange(month: string): DateRange {
|
||||||
|
const [
|
||||||
|
y,
|
||||||
|
m
|
||||||
|
] = month.split('-').map(Number);
|
||||||
|
const last = new Date(Date.UTC(y, m, 0)).getUTCDate();
|
||||||
|
return { from: `${month}-01`, to: `${month}-${String(last).padStart(2, '0')}` };
|
||||||
|
}
|
||||||
@@ -53,6 +53,7 @@ import {
|
|||||||
faFileUpload,
|
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 {
|
gap: 4px;
|
||||||
.mat-mdc-tab-link-container {
|
|
||||||
border-bottom: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mat-mdc-tab-links {
|
|
||||||
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
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user