Compare commits
36
Commits
2a9df828ca
..
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 | ||
|
|
be6103e0b3 | ||
|
|
5cd9998cfc | ||
|
|
0ca6842bde | ||
|
|
3030e2f34e | ||
|
|
b4608cbbe4 | ||
|
|
27cdaf7fc2 | ||
|
|
90beea64cb | ||
|
|
1317a93838 | ||
|
|
0832017508 | ||
|
|
8ac4010967 | ||
|
|
1c125d97c5 | ||
|
|
c935e2a62a | ||
|
|
a982083b14 |
+2
-2
@@ -69,8 +69,8 @@
|
|||||||
"budgets": [
|
"budgets": [
|
||||||
{
|
{
|
||||||
"type": "initial",
|
"type": "initial",
|
||||||
"maximumWarning": "13mb",
|
"maximumWarning": "15mb",
|
||||||
"maximumError": "14mb"
|
"maximumError": "17mb"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "anyComponentStyle",
|
"type": "anyComponentStyle",
|
||||||
|
|||||||
@@ -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[]> {
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ import { GlReportsModule } from './gl-reports/gl-reports.module';
|
|||||||
import { SftModule } from './sft/sft.module';
|
import { SftModule } from './sft/sft.module';
|
||||||
import { InvestmentModule } from './investment/investment.module';
|
import { InvestmentModule } from './investment/investment.module';
|
||||||
import { NpaModule } from './npa/npa.module';
|
import { NpaModule } from './npa/npa.module';
|
||||||
|
import { ReconciliationModule } from './reconciliation/reconciliation.module';
|
||||||
|
import { LoanOriginationModule } from './loan-origination/loan-origination.module';
|
||||||
import { DigitalModule } from './digital/digital.module';
|
import { DigitalModule } from './digital/digital.module';
|
||||||
import { LetterMgmtModule } from './letter-mgmt/letter-mgmt.module';
|
import { LetterMgmtModule } from './letter-mgmt/letter-mgmt.module';
|
||||||
import { AccountsModule } from './accounts/accounts.module';
|
import { AccountsModule } from './accounts/accounts.module';
|
||||||
@@ -178,6 +180,8 @@ export function HttpLoaderFactory(http: HttpClient) {
|
|||||||
SftModule,
|
SftModule,
|
||||||
InvestmentModule,
|
InvestmentModule,
|
||||||
NpaModule,
|
NpaModule,
|
||||||
|
ReconciliationModule,
|
||||||
|
LoanOriginationModule,
|
||||||
DigitalModule,
|
DigitalModule,
|
||||||
LetterMgmtModule,
|
LetterMgmtModule,
|
||||||
AccountsModule,
|
AccountsModule,
|
||||||
|
|||||||
+42
-16
@@ -31,6 +31,9 @@
|
|||||||
<button type="button" class="pg-btn" [class.pg-btn--active]="showDetails" (click)="toggleDetails()">
|
<button type="button" class="pg-btn" [class.pg-btn--active]="showDetails" (click)="toggleDetails()">
|
||||||
{{ showDetails ? 'Summary View' : 'Transaction Details' }}
|
{{ showDetails ? 'Summary View' : 'Transaction Details' }}
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="generating" (click)="exportExcel()">
|
||||||
|
{{ generating ? 'Exporting…' : 'Export to Excel' }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -209,43 +212,61 @@
|
|||||||
? 'Clearing'
|
? 'Clearing'
|
||||||
: 'Digital'
|
: 'Digital'
|
||||||
}}
|
}}
|
||||||
— Transaction Details
|
— Transaction Details (Full Double Entry)
|
||||||
@if (glFilter !== null) {
|
@if (glFilter !== null) {
|
||||||
· {{ glFilter }}
|
· {{ glFilter }}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<table class="pg-table">
|
<table class="pg-table cctr-journal">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Date</th>
|
<th>Date</th>
|
||||||
<th>Voucher / Ref No</th>
|
<th>Voucher / Ref No</th>
|
||||||
<th>Account No</th>
|
<th>Dr / Cr</th>
|
||||||
<th>Account Name / Reference</th>
|
<th>GL / Account</th>
|
||||||
<th>Particulars</th>
|
|
||||||
<th class="num col-out">Debit</th>
|
<th class="num col-out">Debit</th>
|
||||||
<th class="num col-in">Credit</th>
|
<th class="num col-in">Credit</th>
|
||||||
|
<th>Status</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@for (t of filteredTxns; track t.voucherNo) {
|
@for (t of filteredTxns; track t.voucherNo) {
|
||||||
<tr>
|
<tr class="cctr-journal-row cctr-journal-row--first">
|
||||||
<td>{{ asOnDate }}</td>
|
<td>{{ asOnDate }}</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="pg-chip">{{ display(t.voucherNo) }}</span>
|
<span class="pg-chip">{{ display(t.voucherNo) }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ display(t.accountNo) }}</td>
|
<td><span class="cctr-drcr cctr-drcr--dr">Dr</span></td>
|
||||||
<td>{{ t.accountName }}</td>
|
<td>
|
||||||
<td>{{ t.particulars }}</td>
|
|
||||||
<td class="num col-out">
|
|
||||||
@if (t.debit > 0) {
|
@if (t.debit > 0) {
|
||||||
₹{{ t.debit | number: '1.2-2' }}
|
<span class="pg-chip">{{ t.glCode }}</span> {{ glName(t.glCode) }} — {{ display(t.accountNo) }} ({{
|
||||||
|
t.accountName
|
||||||
|
}})
|
||||||
|
} @else {
|
||||||
|
<span class="cctr-contra">{{ contraLabel(t) }}</span>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td class="num col-in">
|
<td class="num col-out">₹{{ legAmount(t) | number: '1.2-2' }}</td>
|
||||||
|
<td class="num"></td>
|
||||||
|
<td rowspan="2">
|
||||||
|
<span class="cctr-balanced-chip">✓ Balanced</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="cctr-journal-row cctr-journal-row--second">
|
||||||
|
<td></td>
|
||||||
|
<td></td>
|
||||||
|
<td><span class="cctr-drcr cctr-drcr--cr">Cr</span></td>
|
||||||
|
<td>
|
||||||
@if (t.credit > 0) {
|
@if (t.credit > 0) {
|
||||||
₹{{ t.credit | number: '1.2-2' }}
|
<span class="pg-chip">{{ t.glCode }}</span> {{ glName(t.glCode) }} — {{ display(t.accountNo) }} ({{
|
||||||
|
t.accountName
|
||||||
|
}})
|
||||||
|
} @else {
|
||||||
|
<span class="cctr-contra">{{ contraLabel(t) }}</span>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
|
<td class="num"></td>
|
||||||
|
<td class="num col-in">₹{{ legAmount(t) | number: '1.2-2' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
} @empty {
|
} @empty {
|
||||||
<tr>
|
<tr>
|
||||||
@@ -255,9 +276,14 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
<tfoot>
|
<tfoot>
|
||||||
<tr class="cctr-total-row">
|
<tr class="cctr-total-row">
|
||||||
<td colspan="5">Total</td>
|
<td colspan="4">Total ({{ filteredTxns.length }} vouchers)</td>
|
||||||
<td class="num">₹{{ txnTotal(filteredTxns, 'debit') | number: '1.2-2' }}</td>
|
<td class="num">
|
||||||
<td class="num">₹{{ txnTotal(filteredTxns, 'credit') | number: '1.2-2' }}</td>
|
₹{{ txnTotal(filteredTxns, 'debit') + txnTotal(filteredTxns, 'credit') | number: '1.2-2' }}
|
||||||
|
</td>
|
||||||
|
<td class="num">
|
||||||
|
₹{{ txnTotal(filteredTxns, 'debit') + txnTotal(filteredTxns, 'credit') | number: '1.2-2' }}
|
||||||
|
</td>
|
||||||
|
<td></td>
|
||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
+65
@@ -71,3 +71,68 @@
|
|||||||
background: #eff6ff;
|
background: #eff6ff;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Both journal lines of one voucher share a tint and sit flush against each other (no dotted
|
||||||
|
// divider between them) so the pair reads as a single entry; a hairline separates one voucher's
|
||||||
|
// pair from the next.
|
||||||
|
.cctr-journal-row {
|
||||||
|
&--first td {
|
||||||
|
border-bottom: none;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--second td {
|
||||||
|
border-top: none;
|
||||||
|
padding-top: 4px;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--first,
|
||||||
|
&--second {
|
||||||
|
&:hover td {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cctr-drcr {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 28px;
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
|
||||||
|
&--dr {
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #1d4ed8;
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--cr {
|
||||||
|
background: #f0fdf4;
|
||||||
|
color: #15803d;
|
||||||
|
border: 1px solid #bbf7d0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cctr-contra {
|
||||||
|
color: #64748b;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cctr-balanced-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: #f0fdf4;
|
||||||
|
color: #15803d;
|
||||||
|
border: 1px solid #bbf7d0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|||||||
+233
@@ -10,6 +10,7 @@ import { Component, inject } from '@angular/core';
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { AuthenticationService } from '../../core/authentication/authentication.service';
|
import { AuthenticationService } from '../../core/authentication/authentication.service';
|
||||||
|
import { ExportSheetSpec, exportWorkbook } from '../cashier-export';
|
||||||
|
|
||||||
interface SummaryRow {
|
interface SummaryRow {
|
||||||
code: string;
|
code: string;
|
||||||
@@ -70,6 +71,15 @@ export class CashClearingTransferSummaryComponent {
|
|||||||
showDetails = false;
|
showDetails = false;
|
||||||
/** GL/channel filter for the Transaction Details view — null means "All". */
|
/** GL/channel filter for the Transaction Details view — null means "All". */
|
||||||
glFilter: string | null = null;
|
glFilter: string | null = null;
|
||||||
|
/** True while an Export to Excel workbook is being built — disables the button to prevent double-clicks. */
|
||||||
|
generating = false;
|
||||||
|
|
||||||
|
private readonly tabLabels: Record<TabKey, string> = {
|
||||||
|
cash: 'Cash',
|
||||||
|
transfer: 'Transfer',
|
||||||
|
clearing: 'Clearing',
|
||||||
|
digital: 'Digital'
|
||||||
|
};
|
||||||
|
|
||||||
/** True when the logged-in user belongs to the Head Office (sees both branches + consolidated view). */
|
/** True when the logged-in user belongs to the Head Office (sees both branches + consolidated view). */
|
||||||
isHeadOffice = false;
|
isHeadOffice = false;
|
||||||
@@ -1481,6 +1491,52 @@ export class CashClearingTransferSummaryComponent {
|
|||||||
return this.activeTab === 'digital' ? row.code : row.name;
|
return this.activeTab === 'digital' ? row.code : row.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** GL name for the code tracked on this txn row, so the Transaction Details journal can show
|
||||||
|
* "which GL" alongside the account, not just a bare code. */
|
||||||
|
glName(code: string): string {
|
||||||
|
return this.rowsForActiveTab.find((r) => r.code === code)?.name || code;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every transaction here is only ever recorded as ONE leg (whichever GL this Cash/Clearing/
|
||||||
|
* Transfer/Digital screen tracks) — the offsetting leg (the customer's own account, a
|
||||||
|
* settlement/clearing-house account, etc.) was never modelled as a separate GL row. Rather
|
||||||
|
* than leaving that side blank, this derives a sensible label for it so the Transaction
|
||||||
|
* Details view can show the FULL double entry (Dr leg + Cr leg, always equal amounts) —
|
||||||
|
* exactly what a real CBS "Day Book"/Journal Register shows, and what makes it possible to
|
||||||
|
* see at a glance that a transaction is complete and ready for reconciliation.
|
||||||
|
*
|
||||||
|
* For internal cash-movement postings (ATM cash-in-transit, currency chest, denomination
|
||||||
|
* shortages, CTS grid rounding) the account name already describes the SAME leg rather than a
|
||||||
|
* distinct counter-party, so those get a purpose-built contra label. Everything else already
|
||||||
|
* has a descriptive counter-party name (customer, vendor, bank branch, government department)
|
||||||
|
* that IS the natural contra account, so it's used as-is — WITHOUT repeating the account/
|
||||||
|
* reference number, since that number is already shown against the tracked GL leg on the
|
||||||
|
* other line; showing it a second time here would make both lines look like the same account,
|
||||||
|
* defeating the point of a "Balanced" two-sided entry.
|
||||||
|
*/
|
||||||
|
contraLabel(t: TxnRow): string {
|
||||||
|
const p = t.particulars.toLowerCase();
|
||||||
|
if (p.includes('atm') && (p.includes('vault') || p.includes('loading') || p.includes('surrendered'))) {
|
||||||
|
return 'ATM Cash-in-Transit Account';
|
||||||
|
}
|
||||||
|
if (p.includes('currency chest')) {
|
||||||
|
return 'Currency Chest Account (Sponsor Bank)';
|
||||||
|
}
|
||||||
|
if (p.includes('denomination') || p.includes('shortage adjustment')) {
|
||||||
|
return 'Cash Difference / Shortage Suspense Account';
|
||||||
|
}
|
||||||
|
if (p.includes('cts grid rounding')) {
|
||||||
|
return 'CTS Grid Rounding Suspense Account';
|
||||||
|
}
|
||||||
|
return t.accountName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The single non-zero amount on this leg — always what the derived contra leg mirrors. */
|
||||||
|
legAmount(t: TxnRow): number {
|
||||||
|
return t.debit > 0 ? t.debit : t.credit;
|
||||||
|
}
|
||||||
|
|
||||||
/** Credit total for a GL row — always summed from its own txn lines, never a separate hand-entered number. */
|
/** Credit total for a GL row — always summed from its own txn lines, never a separate hand-entered number. */
|
||||||
rowCredit(row: SummaryRow, txns: TxnRow[]): number {
|
rowCredit(row: SummaryRow, txns: TxnRow[]): number {
|
||||||
return txns.filter((t) => t.glCode === row.code).reduce((sum, t) => sum + t.credit, 0);
|
return txns.filter((t) => t.glCode === row.code).reduce((sum, t) => sum + t.credit, 0);
|
||||||
@@ -1566,4 +1622,181 @@ export class CashClearingTransferSummaryComponent {
|
|||||||
return { code: b.code, name: b.name, opening, credit, debit, closing: opening + credit - debit };
|
return { code: b.code, name: b.name, opening, credit, debit, closing: opening + credit - debit };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exports the full report for the currently viewed scope (own branch, a drilled-into branch,
|
||||||
|
* or "All Branches" consolidated) — every category (Cash/Transfer/Clearing/Digital), not just
|
||||||
|
* the tab on screen, so the workbook is a complete record. "Summary" mirrors the Opening/
|
||||||
|
* Credit/Debit/Closing view; "Transaction Details" mirrors the full double-entry journal view
|
||||||
|
* (same glName/contraLabel/legAmount derivations, so the export can never disagree with what's
|
||||||
|
* on screen); "Branch Breakup" is added only when Head Office is viewing the consolidated view.
|
||||||
|
*/
|
||||||
|
async exportExcel(): Promise<void> {
|
||||||
|
this.generating = true;
|
||||||
|
try {
|
||||||
|
const summaryRows: (string | number | null)[][] = [];
|
||||||
|
const journalRows: (string | number | null)[][] = [];
|
||||||
|
|
||||||
|
for (const tab of this.allTabs) {
|
||||||
|
const label = this.tabLabels[tab];
|
||||||
|
const rows = this.effectiveRows(tab);
|
||||||
|
const txns = this.effectiveTxns(tab);
|
||||||
|
|
||||||
|
rows.forEach((r) => {
|
||||||
|
summaryRows.push([
|
||||||
|
label,
|
||||||
|
r.code,
|
||||||
|
r.name,
|
||||||
|
r.opening,
|
||||||
|
this.rowCredit(r, txns),
|
||||||
|
this.rowDebit(r, txns),
|
||||||
|
this.closing(r, txns)
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
summaryRows.push([
|
||||||
|
`${label} Total`,
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
this.total(rows, txns, 'opening'),
|
||||||
|
this.total(rows, txns, 'credit'),
|
||||||
|
this.total(rows, txns, 'debit'),
|
||||||
|
this.totalClosing(rows, txns)
|
||||||
|
]);
|
||||||
|
|
||||||
|
txns.forEach((t) => {
|
||||||
|
const glText = rows.find((r) => r.code === t.glCode)?.name || t.glCode;
|
||||||
|
journalRows.push([
|
||||||
|
label,
|
||||||
|
this.asOnDate,
|
||||||
|
this.display(t.voucherNo),
|
||||||
|
'Dr',
|
||||||
|
t.debit > 0 ? t.glCode : '',
|
||||||
|
t.debit > 0 ? `${glText} — ${this.display(t.accountNo)} (${t.accountName})` : this.contraLabel(t),
|
||||||
|
this.legAmount(t),
|
||||||
|
null,
|
||||||
|
'Balanced'
|
||||||
|
]);
|
||||||
|
journalRows.push([
|
||||||
|
label,
|
||||||
|
this.asOnDate,
|
||||||
|
this.display(t.voucherNo),
|
||||||
|
'Cr',
|
||||||
|
t.credit > 0 ? t.glCode : '',
|
||||||
|
t.credit > 0 ? `${glText} — ${this.display(t.accountNo)} (${t.accountName})` : this.contraLabel(t),
|
||||||
|
null,
|
||||||
|
this.legAmount(t),
|
||||||
|
'Balanced'
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
summaryRows.push([
|
||||||
|
'Grand Total',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
this.grandOpening,
|
||||||
|
this.grandCredit,
|
||||||
|
this.grandDebit,
|
||||||
|
this.grandClosing
|
||||||
|
]);
|
||||||
|
|
||||||
|
const sheets: ExportSheetSpec[] = [
|
||||||
|
{
|
||||||
|
sheetName: 'Summary',
|
||||||
|
title: 'Cash / Clearing / Transfer - Summary Report',
|
||||||
|
subtitle: `Scope: ${this.scopeName} · As On: ${this.asOnDate}`,
|
||||||
|
headers: [
|
||||||
|
'Category',
|
||||||
|
'GL Code',
|
||||||
|
'Particulars',
|
||||||
|
'Opening',
|
||||||
|
'Credit',
|
||||||
|
'Debit',
|
||||||
|
'Closing'
|
||||||
|
],
|
||||||
|
widths: [
|
||||||
|
12,
|
||||||
|
12,
|
||||||
|
34,
|
||||||
|
16,
|
||||||
|
16,
|
||||||
|
16,
|
||||||
|
16
|
||||||
|
],
|
||||||
|
rows: summaryRows
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sheetName: 'Transaction Details',
|
||||||
|
title: 'Transaction Details — Full Double Entry',
|
||||||
|
subtitle: `Scope: ${this.scopeName} · As On: ${this.asOnDate}`,
|
||||||
|
headers: [
|
||||||
|
'Category',
|
||||||
|
'Date',
|
||||||
|
'Voucher / Ref No',
|
||||||
|
'Dr / Cr',
|
||||||
|
'GL Code',
|
||||||
|
'GL / Account',
|
||||||
|
'Debit',
|
||||||
|
'Credit',
|
||||||
|
'Status'
|
||||||
|
],
|
||||||
|
widths: [
|
||||||
|
12,
|
||||||
|
12,
|
||||||
|
20,
|
||||||
|
8,
|
||||||
|
10,
|
||||||
|
46,
|
||||||
|
16,
|
||||||
|
16,
|
||||||
|
12
|
||||||
|
],
|
||||||
|
rows: journalRows
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
if (this.isHeadOffice && this.selectedBranchCode === 'ALL') {
|
||||||
|
sheets.push({
|
||||||
|
sheetName: 'Branch Breakup',
|
||||||
|
title: 'Branch-wise Break-up',
|
||||||
|
subtitle: `As On: ${this.asOnDate}`,
|
||||||
|
headers: [
|
||||||
|
'Branch',
|
||||||
|
'Opening',
|
||||||
|
'Credit',
|
||||||
|
'Debit',
|
||||||
|
'Closing'
|
||||||
|
],
|
||||||
|
widths: [
|
||||||
|
22,
|
||||||
|
16,
|
||||||
|
16,
|
||||||
|
16,
|
||||||
|
16
|
||||||
|
],
|
||||||
|
rows: [
|
||||||
|
...this.branchBreakup.map((b) => [
|
||||||
|
b.name,
|
||||||
|
b.opening,
|
||||||
|
b.credit,
|
||||||
|
b.debit,
|
||||||
|
b.closing
|
||||||
|
]),
|
||||||
|
[
|
||||||
|
'All Branches',
|
||||||
|
this.grandOpening,
|
||||||
|
this.grandCredit,
|
||||||
|
this.grandDebit,
|
||||||
|
this.grandClosing
|
||||||
|
]
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await exportWorkbook(sheets, `Cash-Clearing-Transfer-Summary-${this.scopeName}-${this.asOnDate}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Cash/Clearing/Transfer export failed', err);
|
||||||
|
} finally {
|
||||||
|
this.generating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Shared "Export to Excel" builder for Cashier reports (mirrors ../investment/investment-export.ts)
|
||||||
|
// — centralised so the ExcelJS boilerplate (title/meta/header rows, number formatting, safe sheet
|
||||||
|
// names, Blob download) is written once and every cashier report's export behaves the same way.
|
||||||
|
|
||||||
|
import * as ExcelJS from 'exceljs';
|
||||||
|
|
||||||
|
export interface ExportSheetSpec {
|
||||||
|
/** Worksheet tab name — sanitised for Excel's forbidden characters (\ / ? * [ ]) and length. */
|
||||||
|
sheetName: string;
|
||||||
|
/** Bold title row, merged across all columns. */
|
||||||
|
title: string;
|
||||||
|
/** Optional italic subtitle row (e.g. scope/date), merged across all columns. */
|
||||||
|
subtitle?: string;
|
||||||
|
headers: string[];
|
||||||
|
rows: (string | number | null)[][];
|
||||||
|
widths?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Excel worksheet names can't contain \ / ? * [ ] or exceed 31 chars. */
|
||||||
|
function safeSheetName(name: string): string {
|
||||||
|
return name.replace(/[\\/?*[\]]/g, '-').slice(0, 31);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSheet(wb: ExcelJS.Workbook, spec: ExportSheetSpec): void {
|
||||||
|
const sheet = wb.addWorksheet(safeSheetName(spec.sheetName));
|
||||||
|
sheet.columns = (spec.widths || spec.headers.map(() => 18)).map((w) => ({ width: w }));
|
||||||
|
|
||||||
|
const title = sheet.addRow([spec.title]);
|
||||||
|
sheet.mergeCells(title.number, 1, title.number, spec.headers.length);
|
||||||
|
title.font = { bold: true, size: 14, color: { argb: 'FFFFFFFF' } };
|
||||||
|
title.alignment = { horizontal: 'center', vertical: 'middle' };
|
||||||
|
title.height = 24;
|
||||||
|
title.getCell(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF0F4F3C' } };
|
||||||
|
|
||||||
|
if (spec.subtitle) {
|
||||||
|
const meta = sheet.addRow([spec.subtitle]);
|
||||||
|
sheet.mergeCells(meta.number, 1, meta.number, spec.headers.length);
|
||||||
|
meta.font = { italic: true, size: 10 };
|
||||||
|
}
|
||||||
|
sheet.addRow([]);
|
||||||
|
|
||||||
|
const head = sheet.addRow(spec.headers);
|
||||||
|
head.font = { bold: true };
|
||||||
|
head.alignment = { wrapText: true, horizontal: 'center' };
|
||||||
|
head.eachCell((c) => {
|
||||||
|
c.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE2E8F0' } };
|
||||||
|
c.border = { bottom: { style: 'thin' } };
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const r of spec.rows) {
|
||||||
|
const row = sheet.addRow(r);
|
||||||
|
r.forEach((v, idx) => {
|
||||||
|
if (typeof v === 'number') {
|
||||||
|
row.getCell(idx + 1).numFmt = '#,##0.00';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
sheet.views = [{ state: 'frozen', ySplit: spec.subtitle ? 4 : 3 }];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds a (possibly multi-sheet) workbook and triggers a browser download. */
|
||||||
|
export async function exportWorkbook(sheets: ExportSheetSpec[], fileName: string): Promise<void> {
|
||||||
|
const wb = new ExcelJS.Workbook();
|
||||||
|
wb.creator = 'CBSNexora';
|
||||||
|
wb.created = new Date();
|
||||||
|
sheets.forEach((s) => buildSheet(wb, s));
|
||||||
|
|
||||||
|
const buffer = await wb.xlsx.writeBuffer();
|
||||||
|
const blob = new Blob([buffer], {
|
||||||
|
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||||
|
});
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${fileName.replace(/[^\w]+/g, '-')}.xlsx`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* 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 { Injectable, inject } from '@angular/core';
|
||||||
|
import { AuthenticationService } from './authentication/authentication.service';
|
||||||
|
import { GL_BRANCHES, GlBranch } from 'app/gl-reports/gl-reports-data';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves what branch data the logged-in user is allowed to see.
|
||||||
|
*
|
||||||
|
* Head Office sees every branch and can drill into any one of them; a branch login is
|
||||||
|
* locked to its own book. Previously this logic was copy-pasted into the Cash/Clearing
|
||||||
|
* Summary and the NPA report with slightly different branch code lists; this service is
|
||||||
|
* the single place it lives so new reports cannot drift.
|
||||||
|
*
|
||||||
|
* The logged-in office is matched to a branch by name, since session credentials carry
|
||||||
|
* only officeId and officeName. Office names are seeded to match the GL branch names
|
||||||
|
* exactly (see Liquibase 0442).
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class BranchScopeService {
|
||||||
|
private auth = inject(AuthenticationService);
|
||||||
|
|
||||||
|
/** Sentinel used when a branch login cannot be matched to a known branch. */
|
||||||
|
static readonly UNRESOLVED = '__unresolved__';
|
||||||
|
|
||||||
|
get isHeadOffice(): boolean {
|
||||||
|
return this.auth.isHeadOfficeUser();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The office name on the session, for labelling the scope in report headers. */
|
||||||
|
get officeName(): string {
|
||||||
|
return this.auth.getCredentials()?.officeName || 'Branch Office';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Branch code the user is confined to, or null for Head Office.
|
||||||
|
*
|
||||||
|
* Deliberately returns the UNRESOLVED sentinel rather than falling back to the first
|
||||||
|
* branch when the office cannot be matched: showing an unrecognised branch someone
|
||||||
|
* else's figures would be worse than showing them nothing.
|
||||||
|
*/
|
||||||
|
get ownBranchCode(): string | null {
|
||||||
|
if (this.isHeadOffice) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const name = this.officeName.toLowerCase().trim();
|
||||||
|
const match = GL_BRANCHES.find((b) => b.code !== 'HO' && name === b.name.toLowerCase());
|
||||||
|
// Fall back to a substring match for offices named e.g. "Andheri (E) Branch".
|
||||||
|
const loose = match ?? GL_BRANCHES.find((b) => b.code !== 'HO' && name.includes(b.name.toLowerCase()));
|
||||||
|
return loose?.code ?? BranchScopeService.UNRESOLVED;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the user may choose which branch to view. */
|
||||||
|
get canSelectBranch(): boolean {
|
||||||
|
return this.isHeadOffice;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Branches offered in a selector — everything for Head Office, nothing for a branch login. */
|
||||||
|
get selectableBranches(): GlBranch[] {
|
||||||
|
return this.isHeadOffice ? GL_BRANCHES : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Branch code a report should start on. '' means all branches consolidated. */
|
||||||
|
get defaultBranchCode(): string {
|
||||||
|
return this.isHeadOffice ? '' : (this.ownBranchCode ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forces a requested branch code into what the user is actually allowed to see.
|
||||||
|
* Call this wherever a branch code reaches a data query, so a stale or hand-edited
|
||||||
|
* selection cannot widen the user's scope.
|
||||||
|
*/
|
||||||
|
enforce(requested: string): string {
|
||||||
|
return this.isHeadOffice ? requested : (this.ownBranchCode ?? BranchScopeService.UNRESOLVED);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when a branch login's office matched no known branch, so it sees no data. */
|
||||||
|
get isUnresolved(): boolean {
|
||||||
|
return this.ownBranchCode === BranchScopeService.UNRESOLVED;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human label for the current scope, for report headers. */
|
||||||
|
scopeLabel(selectedCode: string): string {
|
||||||
|
if (!this.isHeadOffice) {
|
||||||
|
return this.officeName;
|
||||||
|
}
|
||||||
|
if (!selectedCode) {
|
||||||
|
return 'All Branches (Consolidated)';
|
||||||
|
}
|
||||||
|
return GL_BRANCHES.find((b) => b.code === selectedCode)?.name ?? selectedCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -350,6 +350,10 @@
|
|||||||
<fa-icon icon="exclamation-triangle" class="ni-ico"></fa-icon>NPA
|
<fa-icon icon="exclamation-triangle" class="ni-ico"></fa-icon>NPA
|
||||||
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
||||||
</a>
|
</a>
|
||||||
|
<a class="ni" tabindex="0" [matMenuTriggerFor]="loanOriginationMenu" *mifosxHasPermission="'MENU_ADVANCES'">
|
||||||
|
<fa-icon icon="clipboard-check" class="ni-ico"></fa-icon>Loan Origination
|
||||||
|
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
||||||
|
</a>
|
||||||
<a class="ni" tabindex="0" [matMenuTriggerFor]="advancesMenu" *mifosxHasPermission="'MENU_ADVANCES'">
|
<a class="ni" tabindex="0" [matMenuTriggerFor]="advancesMenu" *mifosxHasPermission="'MENU_ADVANCES'">
|
||||||
<fa-icon icon="hand-holding-usd" class="ni-ico"></fa-icon>Advances
|
<fa-icon icon="hand-holding-usd" class="ni-ico"></fa-icon>Advances
|
||||||
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
||||||
@@ -442,6 +446,10 @@
|
|||||||
<fa-icon icon="exclamation-triangle" class="ni-ico"></fa-icon>NPA
|
<fa-icon icon="exclamation-triangle" class="ni-ico"></fa-icon>NPA
|
||||||
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
||||||
</a>
|
</a>
|
||||||
|
<a class="ni" tabindex="0" [matMenuTriggerFor]="loanOriginationMenu" *mifosxHasPermission="'MENU_ADVANCES'">
|
||||||
|
<fa-icon icon="clipboard-check" class="ni-ico"></fa-icon>Loan Origination
|
||||||
|
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
||||||
|
</a>
|
||||||
<a
|
<a
|
||||||
class="ni"
|
class="ni"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
@@ -579,6 +587,13 @@
|
|||||||
<button mat-menu-item [routerLink]="['/reports', 'Accounting']" tabindex="0">
|
<button mat-menu-item [routerLink]="['/reports', 'Accounting']" tabindex="0">
|
||||||
<i class="fa fa-calculator menu-item-icon"></i> Accounting
|
<i class="fa fa-calculator menu-item-icon"></i> Accounting
|
||||||
</button>
|
</button>
|
||||||
|
<mat-divider></mat-divider>
|
||||||
|
<button mat-menu-item routerLink="/reports/form-97-98" tabindex="0">
|
||||||
|
<i class="fa fa-file-invoice menu-item-icon"></i> Form 97/98 Transaction Report
|
||||||
|
</button>
|
||||||
|
<button mat-menu-item routerLink="/reports/risk-review" tabindex="0">
|
||||||
|
<i class="fa fa-user-shield menu-item-icon"></i> Risk Review Report
|
||||||
|
</button>
|
||||||
</mat-menu>
|
</mat-menu>
|
||||||
|
|
||||||
<!-- Admin Mega Menu — 3-column rectangular panel grouping Setup,
|
<!-- Admin Mega Menu — 3-column rectangular panel grouping Setup,
|
||||||
@@ -857,20 +872,34 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="gl-group">
|
<div class="gl-group">
|
||||||
<div class="gl-group-title">GL Reports</div>
|
<div class="gl-group-title">GL Reports</div>
|
||||||
<a class="gl-link" [class.hidden]="!matchesGlSearch('GL Average Balance')" (click)="glMenuOpen = false"
|
<a
|
||||||
><fa-icon icon="calculator" class="gl-icon"></fa-icon> GL Average Balance</a
|
class="gl-link"
|
||||||
|
[class.hidden]="!matchesGlSearch('GL Average Balance')"
|
||||||
|
routerLink="/gl/reports/gl-average-balance"
|
||||||
|
(click)="glMenuOpen = false"
|
||||||
|
><fa-icon icon="calculator" class="gl-icon" style="color: #b45309"></fa-icon> GL Average Balance</a
|
||||||
>
|
>
|
||||||
<a class="gl-link" [class.hidden]="!matchesGlSearch('GL Mapping for Report')" (click)="glMenuOpen = false"
|
<a
|
||||||
><fa-icon icon="project-diagram" class="gl-icon"></fa-icon> GL Mapping for Report</a
|
class="gl-link"
|
||||||
|
[class.hidden]="!matchesGlSearch('GL Mapping for Report')"
|
||||||
|
routerLink="/gl/reports/gl-mapping"
|
||||||
|
(click)="glMenuOpen = false"
|
||||||
|
><fa-icon icon="project-diagram" class="gl-icon" style="color: #b45309"></fa-icon> GL Mapping for
|
||||||
|
Report</a
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
class="gl-link"
|
class="gl-link"
|
||||||
[class.hidden]="!matchesGlSearch('Account wise Balance List')"
|
[class.hidden]="!matchesGlSearch('Account wise Balance List')"
|
||||||
|
routerLink="/gl/reports/account-wise-balance"
|
||||||
(click)="glMenuOpen = false"
|
(click)="glMenuOpen = false"
|
||||||
><fa-icon icon="list" class="gl-icon"></fa-icon> Account wise Balance List</a
|
><fa-icon icon="list" class="gl-icon" style="color: #b45309"></fa-icon> Account wise Balance List</a
|
||||||
>
|
>
|
||||||
<a class="gl-link" [class.hidden]="!matchesGlSearch('Distribution of Balance')" (click)="glMenuOpen = false"
|
<a
|
||||||
><fa-icon icon="chart-pie" class="gl-icon"></fa-icon> Distribution of Balance</a
|
class="gl-link"
|
||||||
|
[class.hidden]="!matchesGlSearch('Distribution of Balance')"
|
||||||
|
routerLink="/gl/reports/distribution-of-balance"
|
||||||
|
(click)="glMenuOpen = false"
|
||||||
|
><fa-icon icon="chart-pie" class="gl-icon" style="color: #b45309"></fa-icon> Distribution of Balance</a
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
class="gl-link"
|
class="gl-link"
|
||||||
@@ -886,8 +915,9 @@
|
|||||||
<a
|
<a
|
||||||
class="gl-link"
|
class="gl-link"
|
||||||
[class.hidden]="!matchesGlSearch('Client wise Balance Report')"
|
[class.hidden]="!matchesGlSearch('Client wise Balance Report')"
|
||||||
|
routerLink="/gl/reports/client-wise-balance"
|
||||||
(click)="glMenuOpen = false"
|
(click)="glMenuOpen = false"
|
||||||
><fa-icon icon="users" class="gl-icon"></fa-icon> Client wise Balance Report</a
|
><fa-icon icon="users" class="gl-icon" style="color: #b45309"></fa-icon> Client wise Balance Report</a
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
class="gl-link"
|
class="gl-link"
|
||||||
@@ -897,17 +927,29 @@
|
|||||||
><fa-icon icon="exclamation-circle" class="gl-icon" style="color: #b45309"></fa-icon> Contingent
|
><fa-icon icon="exclamation-circle" class="gl-icon" style="color: #b45309"></fa-icon> Contingent
|
||||||
Liabilities</a
|
Liabilities</a
|
||||||
>
|
>
|
||||||
<a class="gl-link" [class.hidden]="!matchesGlSearch('Banker Head Report')" (click)="glMenuOpen = false"
|
<a
|
||||||
><fa-icon icon="university" class="gl-icon"></fa-icon> Banker Head Report</a
|
class="gl-link"
|
||||||
|
[class.hidden]="!matchesGlSearch('Banker Head Report')"
|
||||||
|
routerLink="/gl/reports/banker-head"
|
||||||
|
(click)="glMenuOpen = false"
|
||||||
|
><fa-icon icon="university" class="gl-icon" style="color: #b45309"></fa-icon> Banker Head Report</a
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="gl-group">
|
<div class="gl-group">
|
||||||
<div class="gl-group-title">Registers</div>
|
<div class="gl-group-title">Registers</div>
|
||||||
<a class="gl-link" [class.hidden]="!matchesGlSearch('Sundry Register')" (click)="glMenuOpen = false"
|
<a
|
||||||
><fa-icon icon="clipboard-list" class="gl-icon"></fa-icon> Sundry Register</a
|
class="gl-link"
|
||||||
|
[class.hidden]="!matchesGlSearch('Sundry Register')"
|
||||||
|
routerLink="/gl/reports/sundry-register"
|
||||||
|
(click)="glMenuOpen = false"
|
||||||
|
><fa-icon icon="clipboard-list" class="gl-icon" style="color: #b45309"></fa-icon> Sundry Register</a
|
||||||
>
|
>
|
||||||
<a class="gl-link" [class.hidden]="!matchesGlSearch('Voucher Register')" (click)="glMenuOpen = false"
|
<a
|
||||||
><fa-icon icon="receipt" class="gl-icon"></fa-icon> Voucher Register</a
|
class="gl-link"
|
||||||
|
[class.hidden]="!matchesGlSearch('Voucher Register')"
|
||||||
|
routerLink="/gl/reports/voucher-register"
|
||||||
|
(click)="glMenuOpen = false"
|
||||||
|
><fa-icon icon="receipt" class="gl-icon" style="color: #b45309"></fa-icon> Voucher Register</a
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
class="gl-link"
|
class="gl-link"
|
||||||
@@ -1240,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
|
||||||
@@ -1264,6 +1306,9 @@
|
|||||||
<button mat-menu-item tabindex="0" routerLink="/npa/reports/provision">
|
<button mat-menu-item tabindex="0" routerLink="/npa/reports/provision">
|
||||||
<fa-icon icon="hand-holding-usd" class="menu-item-icon"></fa-icon> NPA with Provision and Required Provision
|
<fa-icon icon="hand-holding-usd" class="menu-item-icon"></fa-icon> NPA with Provision and Required Provision
|
||||||
</button>
|
</button>
|
||||||
|
<button mat-menu-item tabindex="0" routerLink="/npa/reports/undebited-interest">
|
||||||
|
<fa-icon icon="coins" class="menu-item-icon"></fa-icon> Undebited Interest Recovery Report
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- NPA Classification -->
|
<!-- NPA Classification -->
|
||||||
<div class="deposit-menu-group">
|
<div class="deposit-menu-group">
|
||||||
@@ -1275,6 +1320,58 @@
|
|||||||
<fa-icon icon="flag" class="menu-item-icon"></fa-icon> SMA Report
|
<fa-icon icon="flag" class="menu-item-icon"></fa-icon> SMA Report
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Recovery Management -->
|
||||||
|
<div class="deposit-menu-group">
|
||||||
|
<div class="deposit-group-title">Recovery Management</div>
|
||||||
|
<button mat-menu-item tabindex="0" routerLink="/npa/reports/recovery-dashboard">
|
||||||
|
<fa-icon icon="tachometer-alt" class="menu-item-icon"></fa-icon> Recovery Management Dashboard
|
||||||
|
</button>
|
||||||
|
<button mat-menu-item tabindex="0" routerLink="/npa/reports/daywise-overdue">
|
||||||
|
<fa-icon icon="calendar-alt" class="menu-item-icon"></fa-icon> Day-wise Overdue Report
|
||||||
|
</button>
|
||||||
|
<button mat-menu-item tabindex="0" routerLink="/npa/reports/daywise-overdrawn">
|
||||||
|
<fa-icon icon="credit-card" class="menu-item-icon"></fa-icon> Day-wise Overdrawn Report
|
||||||
|
</button>
|
||||||
|
<button mat-menu-item tabindex="0" routerLink="/npa/reports/agri-gold-value">
|
||||||
|
<fa-icon icon="crop" class="menu-item-icon"></fa-icon> Agriculture / Gold Market Value Report
|
||||||
|
</button>
|
||||||
|
<button mat-menu-item tabindex="0" routerLink="/npa/reports/exception-check">
|
||||||
|
<fa-icon icon="balance-scale" class="menu-item-icon"></fa-icon> Exception Check — Security Value vs Loan
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</mat-menu>
|
||||||
|
|
||||||
|
<mat-menu #loanOriginationMenu="matMenu" class="npa-mega-panel" [overlapTrigger]="false">
|
||||||
|
<div class="deposit-menu-grid npa-grid" (click)="$event.stopPropagation()">
|
||||||
|
<!-- Loan Origination -->
|
||||||
|
<div class="deposit-menu-group">
|
||||||
|
<div class="deposit-group-title">Loan Origination</div>
|
||||||
|
<button mat-menu-item tabindex="0" routerLink="/loan-origination/pipeline">
|
||||||
|
<fa-icon icon="stream" class="menu-item-icon"></fa-icon> Application Pipeline
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- Reports -->
|
||||||
|
<div class="deposit-menu-group">
|
||||||
|
<div class="deposit-group-title">Reports</div>
|
||||||
|
<button
|
||||||
|
mat-menu-item
|
||||||
|
tabindex="0"
|
||||||
|
routerLink="/loan-origination/pipeline"
|
||||||
|
[queryParams]="{ view: 'channel' }"
|
||||||
|
>
|
||||||
|
<fa-icon icon="chart-pie" class="menu-item-icon"></fa-icon> Channel-wise Report
|
||||||
|
</button>
|
||||||
|
<button mat-menu-item tabindex="0" routerLink="/loan-origination/pipeline" [queryParams]="{ view: 'bc' }">
|
||||||
|
<fa-icon icon="mobile-alt" class="menu-item-icon"></fa-icon> BC-wise Report
|
||||||
|
</button>
|
||||||
|
<button mat-menu-item tabindex="0" routerLink="/loan-origination/pipeline" [queryParams]="{ view: 'share' }">
|
||||||
|
<fa-icon icon="coins" class="menu-item-icon"></fa-icon> Share Member Report
|
||||||
|
</button>
|
||||||
|
<button mat-menu-item tabindex="0" routerLink="/loan-origination/pipeline" [queryParams]="{ view: 'target' }">
|
||||||
|
<fa-icon icon="chart-column" class="menu-item-icon"></fa-icon> Target vs Actual
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</mat-menu>
|
</mat-menu>
|
||||||
|
|
||||||
@@ -1306,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">
|
<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">
|
<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">
|
<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">
|
<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">
|
<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">
|
<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>
|
||||||
@@ -1335,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">
|
<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">
|
<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">
|
<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">
|
<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>
|
||||||
@@ -1356,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">
|
<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">
|
<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">
|
<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>
|
||||||
@@ -1377,7 +1474,7 @@
|
|||||||
<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">
|
<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>
|
||||||
@@ -1386,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">
|
<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">
|
<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>
|
||||||
@@ -1502,6 +1599,9 @@
|
|||||||
<button mat-menu-item tabindex="0">
|
<button mat-menu-item tabindex="0">
|
||||||
<fa-icon icon="file-signature" class="menu-item-icon" style="color: #15803d"></fa-icon> Form 98
|
<fa-icon icon="file-signature" class="menu-item-icon" style="color: #15803d"></fa-icon> Form 98
|
||||||
</button>
|
</button>
|
||||||
|
<button mat-menu-item routerLink="/reports/form-97-98" tabindex="0">
|
||||||
|
<fa-icon icon="file-invoice" class="menu-item-icon" style="color: #15803d"></fa-icon> Form 97/98 Txn Report
|
||||||
|
</button>
|
||||||
<button mat-menu-item routerLink="/letter-mgmt/form-15gh" tabindex="0">
|
<button mat-menu-item routerLink="/letter-mgmt/form-15gh" tabindex="0">
|
||||||
<fa-icon icon="file-signature" class="menu-item-icon" style="color: #15803d"></fa-icon> Form 121
|
<fa-icon icon="file-signature" class="menu-item-icon" style="color: #15803d"></fa-icon> Form 121
|
||||||
</button>
|
</button>
|
||||||
@@ -1706,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">
|
||||||
|
|||||||
@@ -677,6 +677,26 @@ $gray-700: #334155;
|
|||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Marks a `mat-menu-item` as actually wired to a working screen/dialog (vs a still-unwired
|
||||||
|
// placeholder) — same "green dot" convention as .gl-link--live, positioned absolutely since
|
||||||
|
// mat-menu-item's internal layout isn't a plain flex row we control directly.
|
||||||
|
[mat-menu-item].menu-item-live {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
right: 14px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #16a34a;
|
||||||
|
box-shadow: 0 0 0 2px rgb(22 163 74 / 15%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ===== Nav Dropdown Menus =====
|
// ===== Nav Dropdown Menus =====
|
||||||
::ng-deep .nav-dropdown {
|
::ng-deep .nav-dropdown {
|
||||||
border-radius: 8px !important;
|
border-radius: 8px !important;
|
||||||
@@ -729,6 +749,13 @@ $gray-700: #334155;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* NPA Mega Panel — 4 columns (Reports / Analysis / Classification / Recovery Management) */
|
||||||
|
.npa-grid {
|
||||||
|
grid-template-columns: repeat(4, minmax(200px, 1fr));
|
||||||
|
min-width: 880px;
|
||||||
|
max-width: 96vw;
|
||||||
|
}
|
||||||
|
|
||||||
/* Reconciliation Mega Panel — 5 columns, teal accent to match the icon palette */
|
/* Reconciliation Mega Panel — 5 columns, teal accent to match the icon palette */
|
||||||
.reconciliation-grid {
|
.reconciliation-grid {
|
||||||
grid-template-columns: repeat(5, minmax(190px, 1fr));
|
grid-template-columns: repeat(5, minmax(190px, 1fr));
|
||||||
|
|||||||
+357
-2
@@ -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).
|
||||||
@@ -20,7 +24,15 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export type ReportType = 'CTR' | 'STR' | 'CCR' | 'NTR' | 'CBWTR';
|
export type ReportType = 'CTR' | 'STR' | 'CCR' | 'NTR' | 'CBWTR';
|
||||||
export type StrStatus = 'draft' | 'po-review' | 'filed' | 'rejected';
|
/**
|
||||||
|
* Lifecycle of an STR case.
|
||||||
|
* draft — raised, not yet with the Principal Officer
|
||||||
|
* po-review — under Principal Officer investigation
|
||||||
|
* awaiting-filing — concluded suspicious; STR XML must still be submitted to FIU-IND
|
||||||
|
* filed — submitted and FIU-IND acknowledgement recorded
|
||||||
|
* rejected — concluded clean; nothing filed
|
||||||
|
*/
|
||||||
|
export type StrStatus = 'draft' | 'po-review' | 'awaiting-filing' | 'filed' | 'rejected';
|
||||||
|
|
||||||
/** TC2 — Branch / cash transaction record */
|
/** TC2 — Branch / cash transaction record */
|
||||||
export interface CtrTransaction {
|
export interface CtrTransaction {
|
||||||
@@ -42,7 +54,17 @@ export interface CtrTransaction {
|
|||||||
declaration: string;
|
declaration: string;
|
||||||
/** Customer name resolved from KC1 (UI convenience, not in actual TC2 schema) */
|
/** Customer name resolved from KC1 (UI convenience, not in actual TC2 schema) */
|
||||||
customerName?: string;
|
customerName?: string;
|
||||||
complianceStatus?: 'PENDING' | 'WHITELISTED' | 'SUSPICIOUS';
|
/**
|
||||||
|
* CTR does not judge suspicion. It records only whether the source of funds was
|
||||||
|
* evidenced at the counter (CTR filed, nothing further) or could not be, in which
|
||||||
|
* case the transaction is referred to STR. The suspicious/clean verdict itself
|
||||||
|
* belongs to the Principal Officer on the STR side.
|
||||||
|
*/
|
||||||
|
complianceStatus?: 'PENDING' | 'EVIDENCED' | 'REFERRED';
|
||||||
|
/** Document that evidenced the source of funds, when verified at the counter. */
|
||||||
|
evidenceDocType?: string;
|
||||||
|
evidenceReference?: string;
|
||||||
|
evidencedOn?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Outcome of the Principal Officer's investigation into an STR case */
|
/** Outcome of the Principal Officer's investigation into an STR case */
|
||||||
@@ -64,6 +86,10 @@ export interface StrTransaction extends CtrTransaction {
|
|||||||
poRemarks?: string;
|
poRemarks?: string;
|
||||||
/** Set once the Principal Officer concludes the investigation */
|
/** Set once the Principal Officer concludes the investigation */
|
||||||
poFinding?: PoFinding;
|
poFinding?: PoFinding;
|
||||||
|
/** FIU-IND acknowledgement number, recorded only after the STR is actually submitted */
|
||||||
|
fiuReferenceNo?: string;
|
||||||
|
/** Date the STR was submitted on the FINnet portal */
|
||||||
|
filedOn?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** KC1 — Individual customer card (subset of the 70+ fields, only what UI shows) */
|
/** KC1 — Individual customer card (subset of the 70+ fields, only what UI shows) */
|
||||||
@@ -133,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
|
||||||
@@ -275,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
|
||||||
|
|||||||
+357
-263
@@ -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,257 +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 transaction threshold breaches — review and whitelist or escalate to Principal Officer
|
<p>
|
||||||
</p>
|
The CTR is built in this order: the account is described first, then who is behind it, then the two
|
||||||
</div>
|
transaction files. Every grid below is the exact content of its CSV. The customer cards (KC1 / KC2) travel
|
||||||
<select class="fiu-inp" [(ngModel)]="amlAlertStatusFilter" (ngModelChange)="loadAmlAlerts()">
|
with the same bundle and are worked on the Customer Cards tab.
|
||||||
<option value="">All statuses</option>
|
</p>
|
||||||
<option value="RAISED">Raised</option>
|
</div>
|
||||||
<option value="UNDER_REVIEW">Under Review</option>
|
<button class="fiu-btn fiu-btn-xls" (click)="exportAll()">
|
||||||
<option value="WHITELISTED">Whitelisted</option>
|
<fa-icon icon="file-csv"></fa-icon> Download full bundle
|
||||||
<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-g" (click)="whitelistAlert(alert)">Whitelist</button>
|
|
||||||
<button class="fiu-btn fiu-btn-xml" (click)="escalateAlert(alert)">Escalate</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> awaiting Whitelist/Escalate decision ·
|
|
||||||
<span class="report-pill report-ctr">CTR only</span> whitelisted — cleared, no STR needed ·
|
|
||||||
<span class="report-pill report-str">→ STR</span> escalated to Principal Officer as an STR case
|
|
||||||
</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 === 'SUSPICIOUS') {
|
|
||||||
<span class="report-pill report-str" title="Also escalated to an STR case for Principal Officer review">+ STR</span>
|
|
||||||
}
|
}
|
||||||
</td>
|
</tr>
|
||||||
<td>
|
</thead>
|
||||||
@if (!t.complianceStatus || t.complianceStatus === 'PENDING') {
|
<tbody>
|
||||||
<div class="fiu-action-group" style="gap: 4px;">
|
@for (r of openFinnetFile.table.rows; track $index) {
|
||||||
<button class="fiu-btn" style="padding: 4px 8px; font-size: 11px; color: var(--f-green); border-color: var(--f-green); background: var(--f-green-l);" (click)="whitelistCtrTxn(t)">
|
<tr>
|
||||||
Whitelist
|
<td class="seq-rownum">{{ $index + 1 }}</td>
|
||||||
</button>
|
@for (c of r; track $index) {
|
||||||
<button class="fiu-btn" style="padding: 4px 8px; font-size: 11px; color: var(--f-red); border-color: var(--f-red); background: var(--f-red-l);" (click)="flagCtrTxnSuspicious(t)">
|
<td>{{ c }}</td>
|
||||||
Flag STR
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<span [class]="t.complianceStatus === 'WHITELISTED' ? 'status-pill status-whitelisted' : 'status-pill status-escalated'">
|
|
||||||
{{ t.complianceStatus }}
|
|
||||||
</span>
|
|
||||||
@if (t.complianceStatus === 'WHITELISTED') {
|
|
||||||
<div class="muted" style="margin-top: 3px;">Stays in CTR only</div>
|
|
||||||
} @else {
|
|
||||||
<button class="fiu-link-btn" (click)="openStrCase(t)">View in STR tab →</button>
|
|
||||||
}
|
}
|
||||||
}
|
</tr>
|
||||||
</td>
|
} @empty {
|
||||||
</tr>
|
<tr>
|
||||||
} @empty {
|
<td [attr.colspan]="openFinnetFile.table.headers.length + 1" class="seq-empty">
|
||||||
<tr>
|
@if (openFinnetFile.key === 'tc1') {
|
||||||
<td colspan="11" class="fiu-empty">No transactions match the filters.</td>
|
No ATM or card transactions match the current scope. When the bank books none at all, TC1 is
|
||||||
</tr>
|
still submitted as a header-only file — that is what FIU-IND expects.
|
||||||
}
|
} @else {
|
||||||
</tbody>
|
No rows for the current filters.
|
||||||
</table>
|
}
|
||||||
|
</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 stays in the monthly CTR / TC2
|
|
||||||
file ·
|
|
||||||
<span class="report-pill report-str">+ STR</span> also flagged as suspicious and escalated to a separate STR
|
|
||||||
case (see the STR tab)
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,9 +305,7 @@
|
|||||||
<div class="fiu-form-meta">
|
<div class="fiu-form-meta">
|
||||||
<div class="fiu-form-title">
|
<div class="fiu-form-title">
|
||||||
<h3>Live AML Alerts (Awaiting Triage)</h3>
|
<h3>Live AML Alerts (Awaiting Triage)</h3>
|
||||||
<p>
|
<p>Flagged by the monitoring engine for suspicious behavioral patterns — triage and whitelist or escalate</p>
|
||||||
Flagged by the monitoring engine for suspicious behavioral patterns — triage and whitelist or escalate
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="fiu-tbl-wrap">
|
<div class="fiu-tbl-wrap">
|
||||||
@@ -468,7 +374,7 @@
|
|||||||
<span class="report-pill report-str">→ STR</span> escalated to Principal Officer as an STR case
|
<span class="report-pill report-str">→ STR</span> escalated to Principal Officer as an STR case
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<hr style="border: 0; border-top: 1px solid var(--f-g200); margin: 8px 0;" />
|
<hr style="border: 0; border-top: 1px solid var(--f-g200); margin: 8px 0" />
|
||||||
|
|
||||||
<!-- LIVE: STR cases with full Principal Officer actions -->
|
<!-- LIVE: STR cases with full Principal Officer actions -->
|
||||||
<div class="fiu-form-meta">
|
<div class="fiu-form-meta">
|
||||||
@@ -627,23 +533,26 @@
|
|||||||
} @else if (derivedConclusion === 'NOT_SUSPICIOUS') {
|
} @else if (derivedConclusion === 'NOT_SUSPICIOUS') {
|
||||||
<fa-icon icon="check-circle"></fa-icon>
|
<fa-icon icon="check-circle"></fa-icon>
|
||||||
<span>
|
<span>
|
||||||
<strong>Not suspicious.</strong> All {{ strChecklist.length }} points found genuine — the case will
|
<strong>Not suspicious.</strong> All {{ strChecklist.length }} points found genuine — the case will be
|
||||||
be closed and nothing filed with FIU-IND.
|
closed and nothing filed with FIU-IND.
|
||||||
</span>
|
</span>
|
||||||
} @else {
|
} @else {
|
||||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||||
<span>
|
<span>
|
||||||
<strong>Suspicious — STR must be filed.</strong>
|
<strong>Suspicious — STR must be filed.</strong>
|
||||||
{{ failedFindings.length }} of {{ strChecklist.length }} points not satisfied:
|
{{ failedFindings.length }} of {{ strChecklist.length }} points not satisfied:
|
||||||
{{ failedFindings[0].label }}@if (failedFindings.length > 1) {
|
{{ failedFindings[0].label }}
|
||||||
|
@if (failedFindings.length > 1) {
|
||||||
<span> and {{ failedFindings.length - 1 }} more</span>
|
<span> and {{ failedFindings.length - 1 }} more</span>
|
||||||
}.
|
}
|
||||||
|
.
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="fiu-btn fiu-btn-xml" [disabled]="!allAnswered" (click)="concludeStrCase()">
|
<button class="fiu-btn fiu-btn-xml" [disabled]="!allAnswered" (click)="concludeStrCase()">
|
||||||
Conclude as {{ allAnswered ? (derivedConclusion === 'SUSPICIOUS' ? 'Suspicious' : 'Not Suspicious') : '…' }}
|
Conclude as
|
||||||
|
{{ allAnswered ? (derivedConclusion === 'SUSPICIOUS' ? 'Suspicious' : 'Not Suspicious') : '…' }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@if (selectedStrCase.status === 'CONCLUDED_SUSPICIOUS') {
|
@if (selectedStrCase.status === 'CONCLUDED_SUSPICIOUS') {
|
||||||
@@ -675,8 +584,9 @@
|
|||||||
<option value="">All</option>
|
<option value="">All</option>
|
||||||
<option value="draft">Draft</option>
|
<option value="draft">Draft</option>
|
||||||
<option value="po-review">PO Review</option>
|
<option value="po-review">PO Review</option>
|
||||||
|
<option value="awaiting-filing">Awaiting Filing</option>
|
||||||
<option value="filed">Filed</option>
|
<option value="filed">Filed</option>
|
||||||
<option value="rejected">Rejected</option>
|
<option value="rejected">Closed — Clean</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="fiu-filter">
|
<div class="fiu-filter">
|
||||||
@@ -733,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>
|
||||||
@@ -752,17 +665,198 @@
|
|||||||
</label>
|
</label>
|
||||||
<p>{{ poFindingLabel(s.poFinding) }}</p>
|
<p>{{ poFindingLabel(s.poFinding) }}</p>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
}
|
||||||
|
|
||||||
|
<!-- Concluded suspicious but not yet submitted to FIU-IND -->
|
||||||
|
@if (s.status === 'awaiting-filing') {
|
||||||
|
<div class="str-filing" *mifosxHasPermission="'CONCLUDE_AML_STR'">
|
||||||
|
<label>
|
||||||
|
<fa-icon icon="exclamation-triangle"></fa-icon> Awaiting submission to FIU-IND — due within 7 days
|
||||||
|
of {{ s.detectedOn }}
|
||||||
|
</label>
|
||||||
|
<p class="filing-hint">
|
||||||
|
Generate the FINnet STR XML, upload it on the FIU-IND portal under
|
||||||
|
{{ bank.principalOfficer }}'s digital signature, then record the acknowledgement number here.
|
||||||
|
</p>
|
||||||
|
<div class="filing-row">
|
||||||
|
<button class="fiu-btn" (click)="exportSingleStrXml(s)">
|
||||||
|
<fa-icon icon="file-alt"></fa-icon> Generate STR XML
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
class="fiu-inp"
|
||||||
|
type="text"
|
||||||
|
placeholder="FIU-IND acknowledgement no."
|
||||||
|
[(ngModel)]="localFileRefs[s.reportRefNo]"
|
||||||
|
/>
|
||||||
|
<button class="fiu-btn fiu-btn-xml" (click)="fileLocalStr(s)">Mark STR Filed</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (s.status === 'filed' && s.fiuReferenceNo) {
|
||||||
|
<div class="str-filed-ack">
|
||||||
|
<fa-icon icon="check-circle"></fa-icon>
|
||||||
|
<span>
|
||||||
|
Filed with FIU-IND on <strong>{{ s.filedOn }}</strong> · acknowledgement
|
||||||
|
<strong class="mono">{{ s.fiuReferenceNo }}</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!s.poFinding && investigatingStr?.reportRefNo !== s.reportRefNo) {
|
||||||
<div class="str-po-action" *mifosxHasPermission="'CONCLUDE_AML_STR'">
|
<div class="str-po-action" *mifosxHasPermission="'CONCLUDE_AML_STR'">
|
||||||
<label>Principal Officer investigation — is the source of funds legitimate?</label>
|
<label>Principal Officer investigation — is the source of funds legitimate?</label>
|
||||||
<div class="fiu-action-group">
|
<button class="fiu-btn fiu-btn-xml" (click)="investigateLocalStr(s)">
|
||||||
<button class="fiu-btn fiu-btn-clean" (click)="concludeLocalStr(s, 'CLEAN')">
|
<fa-icon icon="search"></fa-icon> Investigate this case
|
||||||
Clean — close case
|
</button>
|
||||||
</button>
|
</div>
|
||||||
<button class="fiu-btn fiu-btn-xml" (click)="concludeLocalStr(s, 'SUSPICIOUS')">
|
}
|
||||||
Suspicious — file STR with FIU-IND
|
|
||||||
|
<!-- Investigation workspace for this case -->
|
||||||
|
@if (investigatingStr?.reportRefNo === s.reportRefNo) {
|
||||||
|
<div class="str-investigation" *mifosxHasPermission="'CONCLUDE_AML_STR'">
|
||||||
|
<div class="inv-head">
|
||||||
|
<span>Principal Officer investigation — STR-{{ s.reportRefNo }}</span>
|
||||||
|
<button class="fiu-link-btn fiu-link-btn--muted" (click)="closeLocalStrInvestigation()">
|
||||||
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 1 — call for evidence -->
|
||||||
|
<div class="inv-step">
|
||||||
|
<div class="inv-step-t"><span class="inv-num">1</span> Documentary evidence called for</div>
|
||||||
|
<div class="inv-grid">
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="invDocType">Document requested from the customer</label>
|
||||||
|
<select id="invDocType" class="fiu-inp" [(ngModel)]="evidenceDocType">
|
||||||
|
<option value="">Select document…</option>
|
||||||
|
@for (d of evidenceDocTypes; track d) {
|
||||||
|
<option [value]="d">{{ d }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="invReceived">Was it produced?</label>
|
||||||
|
<select id="invReceived" class="fiu-inp" [(ngModel)]="evidenceReceived">
|
||||||
|
<option value="">Select…</option>
|
||||||
|
<option value="YES">Yes — complete</option>
|
||||||
|
<option value="PARTIAL">Partially — incomplete</option>
|
||||||
|
<option value="NO">No — not produced</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="invRef">Document / reference number</label>
|
||||||
|
<input id="invRef" class="fiu-inp" type="text" [(ngModel)]="evidenceReference" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 2 — customer's account of the funds -->
|
||||||
|
<div class="inv-step">
|
||||||
|
<div class="inv-step-t"><span class="inv-num">2</span> Customer’s explanation of the source</div>
|
||||||
|
<textarea
|
||||||
|
rows="2"
|
||||||
|
[(ngModel)]="customerExplanation"
|
||||||
|
placeholder="e.g. Proceeds of a flat sold in June; sale deed produced and matched to the deposit amount and date."
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 3 — verification checklist -->
|
||||||
|
<div class="inv-step">
|
||||||
|
<div class="inv-step-t">
|
||||||
|
<span class="inv-num">3</span> Verification checklist
|
||||||
|
<span class="checklist-progress" [class.done]="allAnswered">
|
||||||
|
{{ answeredCount }} / {{ strChecklist.length }} answered
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<table class="checklist-tbl">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Verification point</th>
|
||||||
|
<th class="ta-c col-yes">Genuine</th>
|
||||||
|
<th class="ta-c col-no">Not satisfied</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (item of strChecklist; track item.key) {
|
||||||
|
<tr
|
||||||
|
[class.row-yes]="strFindings[item.key] === true"
|
||||||
|
[class.row-no]="strFindings[item.key] === false"
|
||||||
|
[class.row-pending]="strFindings[item.key] === null"
|
||||||
|
>
|
||||||
|
<td>
|
||||||
|
<div class="chk-label">{{ item.label }}</div>
|
||||||
|
<div class="chk-hint">{{ item.hint }}</div>
|
||||||
|
</td>
|
||||||
|
<td class="ta-c">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="chk-box chk-box--yes"
|
||||||
|
[class.on]="strFindings[item.key] === true"
|
||||||
|
[attr.aria-label]="'Mark ' + item.label + ' as genuine'"
|
||||||
|
(click)="setFinding(item.key, true)"
|
||||||
|
>
|
||||||
|
<fa-icon icon="check-circle"></fa-icon>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td class="ta-c">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="chk-box chk-box--no"
|
||||||
|
[class.on]="strFindings[item.key] === false"
|
||||||
|
[attr.aria-label]="'Mark ' + item.label + ' as not satisfied'"
|
||||||
|
(click)="setFinding(item.key, false)"
|
||||||
|
>
|
||||||
|
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Derived outcome -->
|
||||||
|
<div
|
||||||
|
class="verdict"
|
||||||
|
[class.verdict--clear]="canConcludeLocal && derivedConclusion === 'NOT_SUSPICIOUS'"
|
||||||
|
[class.verdict--suspicious]="canConcludeLocal && derivedConclusion === 'SUSPICIOUS'"
|
||||||
|
>
|
||||||
|
@if (!canConcludeLocal) {
|
||||||
|
<fa-icon icon="info-circle"></fa-icon>
|
||||||
|
<span>
|
||||||
|
Complete all three steps to determine the outcome.
|
||||||
|
@if (!evidenceRecorded) {
|
||||||
|
<span> Evidence not yet recorded.</span>
|
||||||
|
}
|
||||||
|
@if (!allAnswered) {
|
||||||
|
<span> {{ strChecklist.length - answeredCount }} verification point(s) pending.</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
} @else if (derivedConclusion === 'NOT_SUSPICIOUS') {
|
||||||
|
<fa-icon icon="check-circle"></fa-icon>
|
||||||
|
<span>
|
||||||
|
<strong>Source of funds established.</strong> Case closes as clean — the transaction stays in
|
||||||
|
the CTR report and no STR is filed with FIU-IND.
|
||||||
|
</span>
|
||||||
|
} @else {
|
||||||
|
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||||
|
<span>
|
||||||
|
<strong>Source of funds not established.</strong> {{ failedFindings.length }} point(s)
|
||||||
|
unsatisfied — an STR will be filed with FIU-IND in addition to the CTR.
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="fiu-btn fiu-btn-xml" [disabled]="!canConcludeLocal" (click)="concludeLocalStr()">
|
||||||
|
{{
|
||||||
|
canConcludeLocal
|
||||||
|
? derivedConclusion === 'SUSPICIOUS'
|
||||||
|
? 'Conclude — file STR with FIU-IND'
|
||||||
|
: 'Conclude — close as clean (CTR only)'
|
||||||
|
: 'Conclude'
|
||||||
|
}}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -871,8 +965,8 @@
|
|||||||
<div class="fiu-form-title">
|
<div class="fiu-form-title">
|
||||||
<h3>FINnet 2.0 Submission Files</h3>
|
<h3>FINnet 2.0 Submission Files</h3>
|
||||||
<p>
|
<p>
|
||||||
6 CSV files in FIU-IND prescribed schema, plus STR XML. Counts reflect any filters set on the CTR and
|
6 CSV files in FIU-IND prescribed schema, plus STR XML. Counts reflect any filters set on the CTR and STR
|
||||||
STR tabs, so every file in the bundle covers the same transactions.
|
tabs, so every file in the bundle covers the same transactions.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="fiu-btn fiu-btn-xls" (click)="exportAll()">
|
<button class="fiu-btn fiu-btn-xls" (click)="exportAll()">
|
||||||
|
|||||||
@@ -386,6 +386,78 @@
|
|||||||
align-self: end;
|
align-self: end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* CTR outcome 1: source of funds evidenced at the counter — CTR only. */
|
||||||
|
.fiu-btn-evidence {
|
||||||
|
padding: 5px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--f-green);
|
||||||
|
border-color: var(--f-green);
|
||||||
|
background: var(--f-green-l);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--f-green);
|
||||||
|
border-color: var(--f-green);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inline evidence capture row inside the TC2 table. */
|
||||||
|
.evidence-row td {
|
||||||
|
background: var(--f-green-l) !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.evidence-panel {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-left: 3px solid var(--f-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.evidence-t {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #14532d;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.evidence-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2fr 1fr auto;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.evidence-note {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--f-g700);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 900px) {
|
||||||
|
.evidence-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CTR outcome 2: evidence unavailable — refer the transaction onward to STR. */
|
||||||
|
.fiu-btn-refer {
|
||||||
|
padding: 5px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--f-red);
|
||||||
|
border-color: var(--f-red);
|
||||||
|
background: var(--f-red-l);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--f-red);
|
||||||
|
border-color: var(--f-red);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.fiu-link-btn {
|
.fiu-link-btn {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-top: 3px;
|
margin-top: 3px;
|
||||||
@@ -708,6 +780,11 @@
|
|||||||
color: var(--f-amber);
|
color: var(--f-amber);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.st-awaiting-filing {
|
||||||
|
background: #ffedd5;
|
||||||
|
color: #9a3412;
|
||||||
|
}
|
||||||
|
|
||||||
&.st-filed {
|
&.st-filed {
|
||||||
background: var(--f-green-l);
|
background: var(--f-green-l);
|
||||||
color: var(--f-green);
|
color: var(--f-green);
|
||||||
@@ -1306,6 +1383,128 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Concluded suspicious, still to be submitted on the FINnet portal. */
|
||||||
|
.str-filing {
|
||||||
|
background: #fff7ed;
|
||||||
|
border: 1px solid #fdba74;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #9a3412;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.filing-hint {
|
||||||
|
margin: 4px 0 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--f-g700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filing-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
.fiu-inp {
|
||||||
|
max-width: 260px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.str-filed-ack {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: var(--f-green-l);
|
||||||
|
border: 1px solid #86efac;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 9px 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #14532d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── CTR-referred case: Principal Officer investigation workspace ───────── */
|
||||||
|
.str-investigation {
|
||||||
|
margin-top: 12px;
|
||||||
|
border: 1px solid var(--f-purple);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fdfcff;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--f-purple);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-step {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--f-g300);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-step-t {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--f-g900);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-num {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--f-purple);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2fr 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 900px) {
|
||||||
|
.inv-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Derived verdict banner */
|
/* Derived verdict banner */
|
||||||
.verdict {
|
.verdict {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1350,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;
|
||||||
|
}
|
||||||
|
|||||||
+588
-79
@@ -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 = '';
|
||||||
@@ -135,6 +241,54 @@ export class FiuComponent implements OnInit {
|
|||||||
/** null = not yet answered, true = genuine, false = not satisfied. */
|
/** null = not yet answered, true = genuine, false = not satisfied. */
|
||||||
strFindings: Record<string, boolean | null> = {};
|
strFindings: Record<string, boolean | null> = {};
|
||||||
|
|
||||||
|
// ── Investigation of a CTR-referred STR case ───────────────────────────────
|
||||||
|
/** The local STR card currently under investigation, if any. */
|
||||||
|
investigatingStr: StrTransaction | null = null;
|
||||||
|
|
||||||
|
/** Documents the Principal Officer can call for to evidence the source of funds. */
|
||||||
|
readonly evidenceDocTypes: string[] = [
|
||||||
|
'Sale deed / agreement for immovable property',
|
||||||
|
'Salary slips and Form 16',
|
||||||
|
'Audited financials / ITR of the business',
|
||||||
|
'Loan sanction or disbursement letter',
|
||||||
|
'Gift deed with donor PAN and relationship proof',
|
||||||
|
'Maturity advice of a deposit or insurance policy',
|
||||||
|
'Sale bill for gold, vehicle or other asset',
|
||||||
|
'Agricultural income certificate / mandi receipts',
|
||||||
|
'No document produced by the customer'
|
||||||
|
];
|
||||||
|
|
||||||
|
evidenceDocType = '';
|
||||||
|
evidenceReceived: 'YES' | 'PARTIAL' | 'NO' | '' = '';
|
||||||
|
evidenceReference = '';
|
||||||
|
customerExplanation = '';
|
||||||
|
|
||||||
|
/** Opens the investigation panel for a CTR-referred case with a clean slate. */
|
||||||
|
investigateLocalStr(rec: StrTransaction): void {
|
||||||
|
this.investigatingStr = rec;
|
||||||
|
this.selectedStrCase = null;
|
||||||
|
this.resetChecklist();
|
||||||
|
this.evidenceDocType = '';
|
||||||
|
this.evidenceReceived = '';
|
||||||
|
this.evidenceReference = '';
|
||||||
|
this.customerExplanation = '';
|
||||||
|
this.concludeInvestigationNotes = '';
|
||||||
|
this.concludeSourceOfFundsNotes = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
closeLocalStrInvestigation(): void {
|
||||||
|
this.investigatingStr = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Documentary evidence is a precondition for clearing a case, not an optional note. */
|
||||||
|
get evidenceRecorded(): boolean {
|
||||||
|
return !!this.evidenceDocType && !!this.evidenceReceived;
|
||||||
|
}
|
||||||
|
|
||||||
|
get canConcludeLocal(): boolean {
|
||||||
|
return this.evidenceRecorded && this.allAnswered && !!this.customerExplanation.trim();
|
||||||
|
}
|
||||||
|
|
||||||
private resetChecklist(): void {
|
private resetChecklist(): void {
|
||||||
this.strFindings = {};
|
this.strFindings = {};
|
||||||
for (const item of this.strChecklist) {
|
for (const item of this.strChecklist) {
|
||||||
@@ -175,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();
|
||||||
@@ -202,14 +368,51 @@ export class FiuComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
whitelistCtrTxn(txn: CtrTransaction): void {
|
// ── CTR-side evidence capture ──────────────────────────────────────────────
|
||||||
txn.complianceStatus = 'WHITELISTED';
|
/** Transaction whose source-of-funds evidence is being recorded, if any. */
|
||||||
localStorage.setItem('fiu_ctr_txns', JSON.stringify(this.ctrTxns));
|
evidencingTxn: CtrTransaction | null = null;
|
||||||
this.toast(`Transaction for ${txn.customerName} whitelisted — stays in the CTR report only`);
|
ctrEvidenceDocType = '';
|
||||||
|
ctrEvidenceRef = '';
|
||||||
|
|
||||||
|
startCtrEvidence(txn: CtrTransaction): void {
|
||||||
|
this.evidencingTxn = txn;
|
||||||
|
this.ctrEvidenceDocType = '';
|
||||||
|
this.ctrEvidenceRef = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
flagCtrTxnSuspicious(txn: CtrTransaction): void {
|
cancelCtrEvidence(): void {
|
||||||
txn.complianceStatus = 'SUSPICIOUS';
|
this.evidencingTxn = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records that the source of funds was evidenced at the counter. This is a
|
||||||
|
* documentation step, not a suspicion verdict — the transaction is reported in
|
||||||
|
* the CTR either way; recording evidence simply means no STR referral is needed.
|
||||||
|
*/
|
||||||
|
confirmCtrEvidence(): void {
|
||||||
|
const txn = this.evidencingTxn;
|
||||||
|
if (!txn) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.ctrEvidenceDocType) {
|
||||||
|
this.toast('Select the document that evidences the source of funds');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
txn.complianceStatus = 'EVIDENCED';
|
||||||
|
txn.evidenceDocType = this.ctrEvidenceDocType;
|
||||||
|
txn.evidenceReference = this.ctrEvidenceRef.trim();
|
||||||
|
txn.evidencedOn = new Date().toLocaleDateString('en-GB').replace(/\//g, '-');
|
||||||
|
localStorage.setItem('fiu_ctr_txns', JSON.stringify(this.ctrTxns));
|
||||||
|
this.evidencingTxn = null;
|
||||||
|
this.toast(`${txn.customerName} — evidence recorded. Reported in CTR only, no STR referral.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Referral to STR. Used when the source of funds could not be evidenced; the
|
||||||
|
* Principal Officer then investigates and decides whether to file with FIU-IND.
|
||||||
|
*/
|
||||||
|
referCtrTxnToStr(txn: CtrTransaction): void {
|
||||||
|
txn.complianceStatus = 'REFERRED';
|
||||||
localStorage.setItem('fiu_ctr_txns', JSON.stringify(this.ctrTxns));
|
localStorage.setItem('fiu_ctr_txns', JSON.stringify(this.ctrTxns));
|
||||||
|
|
||||||
const newRef = Math.max(100, ...this.strTxns.map((s) => s.reportRefNo)) + 1;
|
const newRef = Math.max(100, ...this.strTxns.map((s) => s.reportRefNo)) + 1;
|
||||||
@@ -222,34 +425,112 @@ export class FiuComponent implements OnInit {
|
|||||||
riskCategory: 'unusual-pattern',
|
riskCategory: 'unusual-pattern',
|
||||||
narrative:
|
narrative:
|
||||||
`Cash ${txn.depositOrWithdrawal.toLowerCase()} of ₹${txn.txnAmount.toLocaleString('en-IN')} breached the ` +
|
`Cash ${txn.depositOrWithdrawal.toLowerCase()} of ₹${txn.txnAmount.toLocaleString('en-IN')} breached the ` +
|
||||||
`₹${this.bank.cashThreshold.toLocaleString('en-IN')} CTR limit and was flagged as suspicious by the AML Officer. ` +
|
`₹${this.bank.cashThreshold.toLocaleString('en-IN')} CTR limit and genuine evidence of the source of funds ` +
|
||||||
`Referred to the Principal Officer to establish whether the source of funds is legitimate or linked to terror financing.`,
|
`could not be established at the branch. Referred to the Principal Officer for investigation.`,
|
||||||
status: 'po-review',
|
status: 'po-review',
|
||||||
poFinding: undefined
|
poFinding: undefined
|
||||||
};
|
};
|
||||||
|
|
||||||
this.strTxns.unshift(newStrCase);
|
this.strTxns.unshift(newStrCase);
|
||||||
localStorage.setItem('fiu_str_cases', JSON.stringify(this.strTxns));
|
localStorage.setItem('fiu_str_cases', JSON.stringify(this.strTxns));
|
||||||
this.toast(`STR-${newRef} opened for ${txn.customerName} — awaiting Principal Officer investigation`);
|
this.toast(`${txn.customerName} referred to STR-${newRef} — awaiting Principal Officer investigation`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Principal Officer closes an STR case as legitimate, or confirms it and files an STR. */
|
/**
|
||||||
concludeLocalStr(rec: StrTransaction, finding: PoFinding): void {
|
* Concludes a CTR-referred case. The verdict follows the evidence and the
|
||||||
|
* verification checklist rather than a free choice, and the full trail is written
|
||||||
|
* into the case remarks so the filing carries its own justification.
|
||||||
|
*/
|
||||||
|
concludeLocalStr(): void {
|
||||||
|
const rec = this.investigatingStr;
|
||||||
|
if (!rec) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.evidenceRecorded) {
|
||||||
|
this.toast('Record which document was called for and whether it was produced');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.customerExplanation.trim()) {
|
||||||
|
this.toast('Record the explanation the customer gave for the source of funds');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.allAnswered) {
|
||||||
|
this.toast(`Answer all verification points — ${this.strChecklist.length - this.answeredCount} still pending`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const finding: PoFinding = this.derivedConclusion === 'SUSPICIOUS' ? 'SUSPICIOUS' : 'CLEAN';
|
||||||
|
const failed = this.failedFindings;
|
||||||
|
const evidenceLine =
|
||||||
|
`Document called for: ${this.evidenceDocType}. Produced: ${this.evidenceReceived}` +
|
||||||
|
(this.evidenceReference.trim() ? ` (ref ${this.evidenceReference.trim()})` : '') +
|
||||||
|
'.';
|
||||||
|
const explanationLine = `Customer's explanation: ${this.customerExplanation.trim()}`;
|
||||||
|
|
||||||
rec.poFinding = finding;
|
rec.poFinding = finding;
|
||||||
if (finding === 'CLEAN') {
|
if (finding === 'CLEAN') {
|
||||||
rec.status = 'rejected';
|
rec.status = 'rejected';
|
||||||
rec.poRemarks =
|
rec.poRemarks =
|
||||||
'Source-of-funds documentation reviewed and verified by the Principal Officer. No link to terror financing or ' +
|
`${evidenceLine} ${explanationLine} All ${this.strChecklist.length} verification points found genuine, ` +
|
||||||
'any other predicate offence. Case closed — no STR filed with FIU-IND.';
|
`so the source of funds is established. Case closed — the transaction remains in the CTR report and no STR ` +
|
||||||
this.toast(`STR-${rec.reportRefNo} closed as clean — no STR filed`);
|
`is filed with FIU-IND.`;
|
||||||
|
// The originating CTR row must stop advertising a live STR once cleared.
|
||||||
|
const origin = this.ctrTxns.find((t) => t.txnId === rec.txnId);
|
||||||
|
if (origin) {
|
||||||
|
origin.complianceStatus = 'EVIDENCED';
|
||||||
|
origin.evidenceDocType = this.evidenceDocType;
|
||||||
|
origin.evidenceReference = this.evidenceReference.trim();
|
||||||
|
origin.evidencedOn = new Date().toLocaleDateString('en-GB').replace(/\//g, '-');
|
||||||
|
localStorage.setItem('fiu_ctr_txns', JSON.stringify(this.ctrTxns));
|
||||||
|
}
|
||||||
|
this.toast(`STR-${rec.reportRefNo} closed as clean — CTR only, no STR filed`);
|
||||||
} else {
|
} else {
|
||||||
rec.status = 'filed';
|
// Concluding suspicious does not file anything — the STR still has to be
|
||||||
|
// submitted on the FINnet portal and its acknowledgement recorded.
|
||||||
|
rec.status = 'awaiting-filing';
|
||||||
rec.poRemarks =
|
rec.poRemarks =
|
||||||
'Principal Officer investigation could not establish a legitimate source of funds. STR filed with FIU-IND under ' +
|
`${evidenceLine} ${explanationLine} ${failed.length} of ${this.strChecklist.length} verification points not ` +
|
||||||
'PMLA s.12 within the 7-day window; account placed under enhanced monitoring and reported to the Nodal Officer.';
|
`satisfied: ${failed.map((f) => f.label).join('; ')}. Source of funds not established — STR to be filed with ` +
|
||||||
this.toast(`STR-${rec.reportRefNo} confirmed suspicious — filed with FIU-IND`);
|
`FIU-IND under PMLA s.12 within 7 days and the account placed under enhanced monitoring.`;
|
||||||
|
this.toast(`STR-${rec.reportRefNo} concluded suspicious — now awaiting submission to FIU-IND`);
|
||||||
}
|
}
|
||||||
localStorage.setItem('fiu_str_cases', JSON.stringify(this.strTxns));
|
localStorage.setItem('fiu_str_cases', JSON.stringify(this.strTxns));
|
||||||
|
this.investigatingStr = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Acknowledgement number captured against a case awaiting submission. */
|
||||||
|
localFileRefs: Record<number, string> = {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records that the STR was actually submitted on the FINnet portal. Only this
|
||||||
|
* step moves a case to "filed" and counts it in the filed KPI.
|
||||||
|
*/
|
||||||
|
fileLocalStr(rec: StrTransaction): void {
|
||||||
|
const ref = (this.localFileRefs[rec.reportRefNo] || '').trim();
|
||||||
|
if (!ref) {
|
||||||
|
this.toast('Enter the FIU-IND acknowledgement number received on the FINnet portal');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rec.status = 'filed';
|
||||||
|
rec.fiuReferenceNo = ref;
|
||||||
|
rec.filedOn = new Date().toLocaleDateString('en-GB').replace(/\//g, '-');
|
||||||
|
rec.poRemarks = `${rec.poRemarks ?? ''} Submitted to FIU-IND on ${rec.filedOn}; acknowledgement ${ref}.`.trim();
|
||||||
|
localStorage.setItem('fiu_str_cases', JSON.stringify(this.strTxns));
|
||||||
|
this.toast(`STR-${rec.reportRefNo} filed with FIU-IND — acknowledgement ${ref}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generates the FINnet STR XML for one case, ready to upload to the portal. */
|
||||||
|
exportSingleStrXml(rec: StrTransaction): void {
|
||||||
|
const saved = this.strFilterStatus;
|
||||||
|
const savedRisk = this.strFilterRisk;
|
||||||
|
// Temporarily narrow the export to this case only, then restore the filters.
|
||||||
|
this.strFilterStatus = '';
|
||||||
|
this.strFilterRisk = '';
|
||||||
|
const only = [rec];
|
||||||
|
this.writeStrXml(only, `STR-${rec.reportRefNo}-${rec.customerName ?? 'case'}.xml`);
|
||||||
|
this.strFilterStatus = saved;
|
||||||
|
this.strFilterRisk = savedRisk;
|
||||||
|
this.toast(`STR-${rec.reportRefNo} XML generated — upload it on the FINnet portal`);
|
||||||
}
|
}
|
||||||
|
|
||||||
poFindingLabel(finding: PoFinding): string {
|
poFindingLabel(finding: PoFinding): string {
|
||||||
@@ -363,12 +644,7 @@ export class FiuComponent implements OnInit {
|
|||||||
: `${this.concludeInvestigationNotes.trim()}\n\nAll ${this.strChecklist.length} verification points found genuine.`;
|
: `${this.concludeInvestigationNotes.trim()}\n\nAll ${this.strChecklist.length} verification points found genuine.`;
|
||||||
|
|
||||||
this.amlService
|
this.amlService
|
||||||
.concludeStrCase(
|
.concludeStrCase(this.selectedStrCase.id, this.concludeConclusion, notes, this.concludeSourceOfFundsNotes)
|
||||||
this.selectedStrCase.id,
|
|
||||||
this.concludeConclusion,
|
|
||||||
notes,
|
|
||||||
this.concludeSourceOfFundsNotes
|
|
||||||
)
|
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (updated) => {
|
next: (updated) => {
|
||||||
this.toast(
|
this.toast(
|
||||||
@@ -406,16 +682,16 @@ export class FiuComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where a live AML alert will end up once triaged — drives the "Routes To" column.
|
* Where a live AML alert stands — drives the "Routes To" column.
|
||||||
* `isCtrRule` distinguishes mechanical CTR-threshold alerts (whitelisting just keeps them
|
* Whitelisting is an STR-side decision, so a CTR alert can only be pending or
|
||||||
* in the CTR file) from behavioral alerts (whitelisting clears them with no report at all).
|
* referred onward. Historic WHITELISTED rows are still rendered for the record.
|
||||||
*/
|
*/
|
||||||
alertRouteLabel(status: string, isCtrRule: boolean = true): string {
|
alertRouteLabel(status: string, isCtrRule: boolean = true): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'WHITELISTED':
|
case 'WHITELISTED':
|
||||||
return isCtrRule ? 'CTR only' : 'Cleared';
|
return isCtrRule ? 'CTR only' : 'Cleared';
|
||||||
case 'ESCALATED':
|
case 'ESCALATED':
|
||||||
return '→ STR';
|
return isCtrRule ? 'Referred to STR' : '→ STR';
|
||||||
default:
|
default:
|
||||||
return 'Pending';
|
return 'Pending';
|
||||||
}
|
}
|
||||||
@@ -450,27 +726,27 @@ export class FiuComponent implements OnInit {
|
|||||||
this.resetStrFilters();
|
this.resetStrFilters();
|
||||||
this.highlightedStrRef = match.reportRefNo;
|
this.highlightedStrRef = match.reportRefNo;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
document
|
document.getElementById(`str-card-${match.reportRefNo}`)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
.getElementById(`str-card-${match.reportRefNo}`)
|
|
||||||
?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------- 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((s) => s.status === 'draft' || s.status === 'po-review').length;
|
return this.scopedStrTxns.filter(
|
||||||
|
(s) => s.status === 'draft' || s.status === 'po-review' || s.status === 'awaiting-filing'
|
||||||
|
).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
|
||||||
@@ -496,19 +772,65 @@ export class FiuComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get ctrAlerts(): any[] {
|
get ctrAlerts(): any[] {
|
||||||
const ctrRuleCodes = ['CTR_THRESHOLD_10L', 'STRUCTURED_CTR_10L_MONTHLY', 'LARGE_CASH_TXN_25L'];
|
const ctrRuleCodes = [
|
||||||
|
'CTR_THRESHOLD_10L',
|
||||||
|
'STRUCTURED_CTR_10L_MONTHLY',
|
||||||
|
'LARGE_CASH_TXN_25L'
|
||||||
|
];
|
||||||
return this.amlAlerts.filter((a) => ctrRuleCodes.includes(a.ruleCode));
|
return this.amlAlerts.filter((a) => ctrRuleCodes.includes(a.ruleCode));
|
||||||
}
|
}
|
||||||
|
|
||||||
get amlSuspiciousAlerts(): any[] {
|
get amlSuspiciousAlerts(): any[] {
|
||||||
const ctrRuleCodes = ['CTR_THRESHOLD_10L', 'STRUCTURED_CTR_10L_MONTHLY', 'LARGE_CASH_TXN_25L'];
|
const ctrRuleCodes = [
|
||||||
|
'CTR_THRESHOLD_10L',
|
||||||
|
'STRUCTURED_CTR_10L_MONTHLY',
|
||||||
|
'LARGE_CASH_TXN_25L'
|
||||||
|
];
|
||||||
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;
|
||||||
@@ -516,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;
|
||||||
@@ -544,7 +877,14 @@ export class FiuComponent implements OnInit {
|
|||||||
|
|
||||||
// -------------------- Helpers --------------------
|
// -------------------- Helpers --------------------
|
||||||
statusLabel(s: StrStatus): string {
|
statusLabel(s: StrStatus): string {
|
||||||
return s === 'draft' ? 'Draft' : s === 'po-review' ? 'PO Review' : s === 'filed' ? 'Filed' : 'Rejected';
|
const map: Record<StrStatus, string> = {
|
||||||
|
draft: 'Draft',
|
||||||
|
'po-review': 'PO Review',
|
||||||
|
'awaiting-filing': 'Awaiting Filing',
|
||||||
|
filed: 'Filed',
|
||||||
|
rejected: 'Closed — Clean'
|
||||||
|
};
|
||||||
|
return map[s] ?? s;
|
||||||
}
|
}
|
||||||
riskLabel(c: StrTransaction['riskCategory']): string {
|
riskLabel(c: StrTransaction['riskCategory']): string {
|
||||||
const map: Record<StrTransaction['riskCategory'], string> = {
|
const map: Record<StrTransaction['riskCategory'], string> = {
|
||||||
@@ -558,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',
|
||||||
@@ -579,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,
|
||||||
@@ -600,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',
|
||||||
@@ -626,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',
|
||||||
@@ -678,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(),
|
||||||
@@ -727,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',
|
||||||
@@ -767,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(),
|
||||||
@@ -804,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',
|
||||||
@@ -828,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) {
|
||||||
@@ -866,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',
|
||||||
@@ -882,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}`;
|
||||||
@@ -901,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) */
|
||||||
@@ -912,6 +1412,12 @@ export class FiuComponent implements OnInit {
|
|||||||
this.toast('No suspect cases to export — nothing matches the current filters');
|
this.toast('No suspect cases to export — nothing matches the current filters');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
this.writeStrXml(suspects, `STR-${this.bank.reportingMonth.replace(/\s+/g, '-')}.xml`);
|
||||||
|
this.toast(`STR XML exported (${suspects.length} suspects)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serialises a set of suspect cases as FINnet 2.0 STR XML and downloads it. */
|
||||||
|
private writeStrXml(suspects: StrTransaction[], filename: string): void {
|
||||||
const xml: string[] = [];
|
const xml: string[] = [];
|
||||||
xml.push('<?xml version="1.0" encoding="UTF-8"?>');
|
xml.push('<?xml version="1.0" encoding="UTF-8"?>');
|
||||||
xml.push(`<STR xmlns="http://fiuind.gov.in/finnet2/str" reportingMonth="${this.bank.reportingMonth}">`);
|
xml.push(`<STR xmlns="http://fiuind.gov.in/finnet2/str" reportingMonth="${this.bank.reportingMonth}">`);
|
||||||
@@ -927,6 +1433,9 @@ export class FiuComponent implements OnInit {
|
|||||||
xml.push(` <Detection date="${s.detectedOn}" by="${this.escapeXml(s.flaggedBy)}" />`);
|
xml.push(` <Detection date="${s.detectedOn}" by="${this.escapeXml(s.flaggedBy)}" />`);
|
||||||
xml.push(` <Narrative>${this.escapeXml(s.narrative)}</Narrative>`);
|
xml.push(` <Narrative>${this.escapeXml(s.narrative)}</Narrative>`);
|
||||||
if (s.poRemarks) xml.push(` <PORemarks>${this.escapeXml(s.poRemarks)}</PORemarks>`);
|
if (s.poRemarks) xml.push(` <PORemarks>${this.escapeXml(s.poRemarks)}</PORemarks>`);
|
||||||
|
if (s.fiuReferenceNo) {
|
||||||
|
xml.push(` <Acknowledgement ref="${this.escapeXml(s.fiuReferenceNo)}" filedOn="${s.filedOn ?? ''}" />`);
|
||||||
|
}
|
||||||
xml.push(' </Suspect>');
|
xml.push(' </Suspect>');
|
||||||
}
|
}
|
||||||
xml.push('</STR>');
|
xml.push('</STR>');
|
||||||
@@ -934,21 +1443,21 @@ export class FiuComponent implements OnInit {
|
|||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = `STR-${this.bank.reportingMonth.replace(/\s+/g, '-')}.xml`;
|
a.download = filename;
|
||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
this.toast(`STR XML exported (${suspects.length} suspects)`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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 {
|
||||||
|
|||||||
@@ -0,0 +1,638 @@
|
|||||||
|
/**
|
||||||
|
* 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 { GL_BRANCHES, GL_PERIOD } from '../gl-reports/gl-reports-data';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transactions of customers who banked on a Form 97 / Form 98 declaration.
|
||||||
|
*
|
||||||
|
* Rule 114B of the Income-tax Rules lists transactions where quoting PAN is
|
||||||
|
* mandatory. A customer who has no PAN gives a declaration instead:
|
||||||
|
*
|
||||||
|
* Form 97 — person with no PAN (income may be taxable).
|
||||||
|
* Form 98 — person whose only income is agricultural and who has no PAN.
|
||||||
|
*
|
||||||
|
* The bank must be able to produce, for any period, every transaction put
|
||||||
|
* through on the strength of such a declaration. That is what this report is.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type FormType = 'FORM_60' | 'FORM_61';
|
||||||
|
|
||||||
|
/** Screen and workbook labels — this bank numbers the declarations 97 and 98. */
|
||||||
|
export const FORM_LABEL: Record<FormType, string> = {
|
||||||
|
FORM_60: 'Form 97',
|
||||||
|
FORM_61: 'Form 98'
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface FormDeclarant {
|
||||||
|
/** Declaration acknowledgement number, as issued by the branch. */
|
||||||
|
ackNo: string;
|
||||||
|
formType: FormType;
|
||||||
|
name: string;
|
||||||
|
fatherName: string;
|
||||||
|
dateOfBirth: string;
|
||||||
|
address: string;
|
||||||
|
branchCode: string;
|
||||||
|
accountNo: string;
|
||||||
|
/** Why the customer has no PAN — printed on the declaration. */
|
||||||
|
reasonForNoPan: string;
|
||||||
|
estimatedIncome: number;
|
||||||
|
declaredOn: string;
|
||||||
|
aadhaarLast4: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TxnMode = 'CASH' | 'TRANSFER' | 'CLEARING' | 'NEFT' | 'RTGS' | 'UPI' | 'ATM';
|
||||||
|
|
||||||
|
export interface FormDeclarantTxn {
|
||||||
|
txnId: string;
|
||||||
|
ackNo: string;
|
||||||
|
date: string;
|
||||||
|
branchCode: string;
|
||||||
|
accountNo: string;
|
||||||
|
mode: TxnMode;
|
||||||
|
drCr: 'DR' | 'CR';
|
||||||
|
amount: number;
|
||||||
|
/** What the transaction was — a Rule 114B category or ordinary activity. */
|
||||||
|
nature: string;
|
||||||
|
/** Coarse bucket the nature belongs to, e.g. Cash, Clearing, Loan. */
|
||||||
|
group: string;
|
||||||
|
/** True when the nature is one of the categories listed in Rule 114B. */
|
||||||
|
rule114B: boolean;
|
||||||
|
particulars: string;
|
||||||
|
voucherNo: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deterministic linear congruential generator — same figures on every load. */
|
||||||
|
function lcg(seed: number): () => number {
|
||||||
|
let s = seed >>> 0;
|
||||||
|
return () => {
|
||||||
|
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
|
||||||
|
return s / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick<T>(rand: () => number, list: T[]): T {
|
||||||
|
return list[Math.floor(rand() * list.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isoDate(d: Date): string {
|
||||||
|
const y = d.getUTCFullYear();
|
||||||
|
const m = `${d.getUTCMonth() + 1}`.padStart(2, '0');
|
||||||
|
const day = `${d.getUTCDate()}`.padStart(2, '0');
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thresholds that make a row worth a second look.
|
||||||
|
*
|
||||||
|
* `RULE_114B_CASH` is the cash-deposit limit above which a declaration is
|
||||||
|
* required at all; `CTR` is the aggregate cash figure that puts the customer
|
||||||
|
* into the monthly Cash Transaction Report.
|
||||||
|
*/
|
||||||
|
export const FORM_THRESHOLD = {
|
||||||
|
RULE_114B_CASH: 50_000,
|
||||||
|
CTR: 1_000_000
|
||||||
|
};
|
||||||
|
|
||||||
|
const FIRST_NAMES = [
|
||||||
|
'Ramesh',
|
||||||
|
'Sunita',
|
||||||
|
'Vitthal',
|
||||||
|
'Kamala',
|
||||||
|
'Ganpat',
|
||||||
|
'Shobha',
|
||||||
|
'Dnyaneshwar',
|
||||||
|
'Mangala',
|
||||||
|
'Bhaskar',
|
||||||
|
'Anita',
|
||||||
|
'Tukaram',
|
||||||
|
'Sarika',
|
||||||
|
'Nivrutti',
|
||||||
|
'Rekha',
|
||||||
|
'Popat',
|
||||||
|
'Chhaya',
|
||||||
|
'Ashok',
|
||||||
|
'Vaishali',
|
||||||
|
'Bapu',
|
||||||
|
'Nanda',
|
||||||
|
'Sitaram',
|
||||||
|
'Jayshree',
|
||||||
|
'Madhukar',
|
||||||
|
'Pushpa',
|
||||||
|
'Eknath',
|
||||||
|
'Sulochana',
|
||||||
|
'Baban',
|
||||||
|
'Indubai',
|
||||||
|
'Hanumant',
|
||||||
|
'Kalpana'
|
||||||
|
];
|
||||||
|
|
||||||
|
const SURNAMES = [
|
||||||
|
'Jadhav',
|
||||||
|
'Pawar',
|
||||||
|
'Shinde',
|
||||||
|
'Gaikwad',
|
||||||
|
'More',
|
||||||
|
'Kadam',
|
||||||
|
'Bhosale',
|
||||||
|
'Patil',
|
||||||
|
'Sawant',
|
||||||
|
'Chavan',
|
||||||
|
'Salunkhe',
|
||||||
|
'Mane',
|
||||||
|
'Deshmukh',
|
||||||
|
'Thorat',
|
||||||
|
'Nikam',
|
||||||
|
'Wagh',
|
||||||
|
'Kale',
|
||||||
|
'Ghadge',
|
||||||
|
'Bhoir',
|
||||||
|
'Tandel'
|
||||||
|
];
|
||||||
|
|
||||||
|
const VILLAGES = [
|
||||||
|
'Wadgaon',
|
||||||
|
'Kolhewadi',
|
||||||
|
'Shirgaon',
|
||||||
|
'Nandgaon',
|
||||||
|
'Pimpalgaon',
|
||||||
|
'Bhosari',
|
||||||
|
'Rajgurunagar',
|
||||||
|
'Talegaon',
|
||||||
|
'Karjat',
|
||||||
|
'Murbad',
|
||||||
|
'Shahapur',
|
||||||
|
'Vasind'
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Reasons a Form 97 declarant gives — Form 98 has its own single reason. */
|
||||||
|
const NO_PAN_REASONS = [
|
||||||
|
'Applied for PAN, allotment awaited',
|
||||||
|
'Total income below taxable limit',
|
||||||
|
'Not required to file return of income',
|
||||||
|
'First-time account holder, PAN not yet obtained'
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every kind of transaction one of these customers puts through — not only the
|
||||||
|
* ones that triggered a declaration.
|
||||||
|
*
|
||||||
|
* `rule114B` marks the categories listed in Rule 114B, where quoting PAN (and
|
||||||
|
* therefore the Form 97/98 declaration) is mandatory above the limit. The rest
|
||||||
|
* are ordinary account activity, which the report still has to show: the point
|
||||||
|
* is the customer's whole picture, not just the declared slice.
|
||||||
|
*
|
||||||
|
* `drCr` is fixed where the category only goes one way; 'EITHER' lets the
|
||||||
|
* generator choose. `cashOnly` forces counter cash, `nonCash` excludes it.
|
||||||
|
*/
|
||||||
|
interface TxnKind {
|
||||||
|
nature: string;
|
||||||
|
group: string;
|
||||||
|
rule114B: boolean;
|
||||||
|
drCr: 'DR' | 'CR' | 'EITHER';
|
||||||
|
cashOnly?: boolean;
|
||||||
|
nonCash?: boolean;
|
||||||
|
/** Relative frequency — small routine postings outnumber big declared ones. */
|
||||||
|
weight: number;
|
||||||
|
/** Typical value band, before the occasional large-value tail. */
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TXN_KINDS: TxnKind[] = [
|
||||||
|
// ── Rule 114B categories: the transactions the declaration was taken for ──
|
||||||
|
{
|
||||||
|
nature: 'Cash deposit exceeding ₹50,000 in a day',
|
||||||
|
group: 'Cash',
|
||||||
|
rule114B: true,
|
||||||
|
drCr: 'CR',
|
||||||
|
cashOnly: true,
|
||||||
|
weight: 14,
|
||||||
|
min: 50_000,
|
||||||
|
max: 470_000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nature: 'Cash withdrawal exceeding ₹50,000',
|
||||||
|
group: 'Cash',
|
||||||
|
rule114B: true,
|
||||||
|
drCr: 'DR',
|
||||||
|
cashOnly: true,
|
||||||
|
weight: 8,
|
||||||
|
min: 50_000,
|
||||||
|
max: 380_000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nature: 'Opening of deposit account',
|
||||||
|
group: 'Account',
|
||||||
|
rule114B: true,
|
||||||
|
drCr: 'CR',
|
||||||
|
cashOnly: true,
|
||||||
|
weight: 3,
|
||||||
|
min: 5_000,
|
||||||
|
max: 50_000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nature: 'Term deposit exceeding ₹50,000',
|
||||||
|
group: 'Deposit',
|
||||||
|
rule114B: true,
|
||||||
|
drCr: 'DR',
|
||||||
|
weight: 5,
|
||||||
|
min: 50_000,
|
||||||
|
max: 900_000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nature: 'Purchase of demand draft in cash',
|
||||||
|
group: 'Remittance',
|
||||||
|
rule114B: true,
|
||||||
|
drCr: 'DR',
|
||||||
|
cashOnly: true,
|
||||||
|
weight: 4,
|
||||||
|
min: 50_000,
|
||||||
|
max: 260_000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nature: 'Sale/purchase of goods or services above ₹2,00,000',
|
||||||
|
group: 'Trade',
|
||||||
|
rule114B: true,
|
||||||
|
drCr: 'EITHER',
|
||||||
|
weight: 3,
|
||||||
|
min: 200_000,
|
||||||
|
max: 850_000
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Ordinary account activity ────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
nature: 'Cash deposit',
|
||||||
|
group: 'Cash',
|
||||||
|
rule114B: false,
|
||||||
|
drCr: 'CR',
|
||||||
|
cashOnly: true,
|
||||||
|
weight: 16,
|
||||||
|
min: 500,
|
||||||
|
max: 49_900
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nature: 'Cash withdrawal',
|
||||||
|
group: 'Cash',
|
||||||
|
rule114B: false,
|
||||||
|
drCr: 'DR',
|
||||||
|
cashOnly: true,
|
||||||
|
weight: 15,
|
||||||
|
min: 500,
|
||||||
|
max: 49_900
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nature: 'Cheque deposit — outward clearing',
|
||||||
|
group: 'Clearing',
|
||||||
|
rule114B: false,
|
||||||
|
drCr: 'CR',
|
||||||
|
nonCash: true,
|
||||||
|
weight: 7,
|
||||||
|
min: 2_000,
|
||||||
|
max: 180_000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nature: 'Cheque paid — inward clearing',
|
||||||
|
group: 'Clearing',
|
||||||
|
rule114B: false,
|
||||||
|
drCr: 'DR',
|
||||||
|
nonCash: true,
|
||||||
|
weight: 6,
|
||||||
|
min: 2_000,
|
||||||
|
max: 160_000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nature: 'NEFT inward credit',
|
||||||
|
group: 'Remittance',
|
||||||
|
rule114B: false,
|
||||||
|
drCr: 'CR',
|
||||||
|
nonCash: true,
|
||||||
|
weight: 6,
|
||||||
|
min: 1_000,
|
||||||
|
max: 220_000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nature: 'NEFT outward remittance',
|
||||||
|
group: 'Remittance',
|
||||||
|
rule114B: false,
|
||||||
|
drCr: 'DR',
|
||||||
|
nonCash: true,
|
||||||
|
weight: 5,
|
||||||
|
min: 1_000,
|
||||||
|
max: 200_000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nature: 'RTGS inward credit',
|
||||||
|
group: 'Remittance',
|
||||||
|
rule114B: false,
|
||||||
|
drCr: 'CR',
|
||||||
|
nonCash: true,
|
||||||
|
weight: 2,
|
||||||
|
min: 200_000,
|
||||||
|
max: 1_200_000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nature: 'UPI / mobile banking transfer',
|
||||||
|
group: 'Digital',
|
||||||
|
rule114B: false,
|
||||||
|
drCr: 'EITHER',
|
||||||
|
nonCash: true,
|
||||||
|
weight: 10,
|
||||||
|
min: 100,
|
||||||
|
max: 24_000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nature: 'ATM cash withdrawal',
|
||||||
|
group: 'Digital',
|
||||||
|
rule114B: false,
|
||||||
|
drCr: 'DR',
|
||||||
|
cashOnly: true,
|
||||||
|
weight: 9,
|
||||||
|
min: 500,
|
||||||
|
max: 20_000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nature: 'Transfer between own accounts',
|
||||||
|
group: 'Transfer',
|
||||||
|
rule114B: false,
|
||||||
|
drCr: 'EITHER',
|
||||||
|
nonCash: true,
|
||||||
|
weight: 5,
|
||||||
|
min: 1_000,
|
||||||
|
max: 120_000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nature: 'Loan instalment (EMI) recovery',
|
||||||
|
group: 'Loan',
|
||||||
|
rule114B: false,
|
||||||
|
drCr: 'DR',
|
||||||
|
weight: 7,
|
||||||
|
min: 2_500,
|
||||||
|
max: 42_000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nature: 'Loan disbursement credited',
|
||||||
|
group: 'Loan',
|
||||||
|
rule114B: false,
|
||||||
|
drCr: 'CR',
|
||||||
|
nonCash: true,
|
||||||
|
weight: 2,
|
||||||
|
min: 50_000,
|
||||||
|
max: 700_000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nature: 'Interest credited on deposit',
|
||||||
|
group: 'Interest',
|
||||||
|
rule114B: false,
|
||||||
|
drCr: 'CR',
|
||||||
|
nonCash: true,
|
||||||
|
weight: 6,
|
||||||
|
min: 120,
|
||||||
|
max: 14_000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nature: 'Interest debited on loan',
|
||||||
|
group: 'Interest',
|
||||||
|
rule114B: false,
|
||||||
|
drCr: 'DR',
|
||||||
|
nonCash: true,
|
||||||
|
weight: 4,
|
||||||
|
min: 200,
|
||||||
|
max: 11_000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nature: 'Term deposit matured and credited',
|
||||||
|
group: 'Deposit',
|
||||||
|
rule114B: false,
|
||||||
|
drCr: 'CR',
|
||||||
|
nonCash: true,
|
||||||
|
weight: 3,
|
||||||
|
min: 25_000,
|
||||||
|
max: 620_000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nature: 'Recurring deposit instalment',
|
||||||
|
group: 'Deposit',
|
||||||
|
rule114B: false,
|
||||||
|
drCr: 'DR',
|
||||||
|
weight: 5,
|
||||||
|
min: 500,
|
||||||
|
max: 12_000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nature: 'Service charges / commission recovered',
|
||||||
|
group: 'Charges',
|
||||||
|
rule114B: false,
|
||||||
|
drCr: 'DR',
|
||||||
|
nonCash: true,
|
||||||
|
weight: 6,
|
||||||
|
min: 25,
|
||||||
|
max: 2_400
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nature: 'Cheque return charges',
|
||||||
|
group: 'Charges',
|
||||||
|
rule114B: false,
|
||||||
|
drCr: 'DR',
|
||||||
|
nonCash: true,
|
||||||
|
weight: 2,
|
||||||
|
min: 100,
|
||||||
|
max: 900
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nature: 'Locker rent recovered',
|
||||||
|
group: 'Charges',
|
||||||
|
rule114B: false,
|
||||||
|
drCr: 'DR',
|
||||||
|
nonCash: true,
|
||||||
|
weight: 1,
|
||||||
|
min: 800,
|
||||||
|
max: 4_500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nature: 'Dividend credited on shares',
|
||||||
|
group: 'Shares',
|
||||||
|
rule114B: false,
|
||||||
|
drCr: 'CR',
|
||||||
|
nonCash: true,
|
||||||
|
weight: 2,
|
||||||
|
min: 200,
|
||||||
|
max: 9_000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nature: 'Share capital subscription',
|
||||||
|
group: 'Shares',
|
||||||
|
rule114B: false,
|
||||||
|
drCr: 'DR',
|
||||||
|
weight: 2,
|
||||||
|
min: 500,
|
||||||
|
max: 25_000
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Flattened by weight so a single draw picks a category at the right rate. */
|
||||||
|
const KIND_POOL: TxnKind[] = TXN_KINDS.flatMap((k) => Array<TxnKind>(k.weight).fill(k));
|
||||||
|
|
||||||
|
/** The distinct groups, for the report's category filter. */
|
||||||
|
export const TXN_GROUPS: string[] = [...new Set(TXN_KINDS.map((k) => k.group))].sort();
|
||||||
|
|
||||||
|
let declarantCache: FormDeclarant[] | null = null;
|
||||||
|
let txnCache: FormDeclarantTxn[] | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The customers who filed a declaration. Roughly one in five is a Form 98
|
||||||
|
* (agricultural income only), which matches the mix a semi-urban co-operative
|
||||||
|
* bank sees — the rural branches carry most of them.
|
||||||
|
*/
|
||||||
|
export function formDeclarants(): FormDeclarant[] {
|
||||||
|
if (declarantCache) {
|
||||||
|
return declarantCache;
|
||||||
|
}
|
||||||
|
const rand = lcg(31071998);
|
||||||
|
const branches = GL_BRANCHES.filter((b) => b.code !== 'HO');
|
||||||
|
const out: FormDeclarant[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < 148; i++) {
|
||||||
|
const branch = branches[i % branches.length];
|
||||||
|
// Form 98 is the agricultural-income declaration, so it clusters in the
|
||||||
|
// outlying branches rather than being spread evenly.
|
||||||
|
const rural = branch.code === '0004' || branch.code === '0006';
|
||||||
|
const formType: FormType = rand() < (rural ? 0.42 : 0.08) ? 'FORM_61' : 'FORM_60';
|
||||||
|
|
||||||
|
const first = pick(rand, FIRST_NAMES);
|
||||||
|
const last = pick(rand, SURNAMES);
|
||||||
|
const seq = `${i + 1}`.padStart(3, '0');
|
||||||
|
|
||||||
|
// Declarations are taken in the run-up to and during the ledger period.
|
||||||
|
const declOffset = Math.floor(rand() * 150);
|
||||||
|
const declDate = new Date(`${GL_PERIOD.fyStart}T00:00:00Z`);
|
||||||
|
declDate.setUTCDate(declDate.getUTCDate() - 60 + declOffset);
|
||||||
|
|
||||||
|
const birthYear = 1955 + Math.floor(rand() * 45);
|
||||||
|
const birthMonth = 1 + Math.floor(rand() * 12);
|
||||||
|
const birthDay = 1 + Math.floor(rand() * 28);
|
||||||
|
|
||||||
|
out.push({
|
||||||
|
ackNo: `${branch.code}/F${formType === 'FORM_60' ? '97' : '98'}/${seq}`,
|
||||||
|
formType,
|
||||||
|
name: `${first} ${last}`,
|
||||||
|
fatherName: `${pick(rand, FIRST_NAMES)} ${last}`,
|
||||||
|
dateOfBirth: `${birthYear}-${`${birthMonth}`.padStart(2, '0')}-${`${birthDay}`.padStart(2, '0')}`,
|
||||||
|
address: `${pick(rand, VILLAGES)}, Tal. ${pick(rand, VILLAGES)}, Dist. Thane`,
|
||||||
|
branchCode: branch.code,
|
||||||
|
accountNo: `${branch.code}${(4100000 + i * 137).toString().padStart(8, '0')}`,
|
||||||
|
reasonForNoPan:
|
||||||
|
formType === 'FORM_61' ? 'Income consists only of agricultural income' : pick(rand, NO_PAN_REASONS),
|
||||||
|
// Form 98 declarants report agricultural income, which the Act exempts, so
|
||||||
|
// their declared figures sit below the Form 97 band.
|
||||||
|
estimatedIncome:
|
||||||
|
formType === 'FORM_61' ? 90_000 + Math.floor(rand() * 160_000) : 140_000 + Math.floor(rand() * 380_000),
|
||||||
|
declaredOn: isoDate(declDate),
|
||||||
|
aadhaarLast4: `${1000 + Math.floor(rand() * 9000)}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
declarantCache = out;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every transaction these customers put through inside the ledger period.
|
||||||
|
*
|
||||||
|
* A declaration is taken per transaction category, so an active customer shows
|
||||||
|
* several rows; a customer who only opened an account shows one.
|
||||||
|
*/
|
||||||
|
export function formDeclarantTxns(): FormDeclarantTxn[] {
|
||||||
|
if (txnCache) {
|
||||||
|
return txnCache;
|
||||||
|
}
|
||||||
|
const rand = lcg(19980731);
|
||||||
|
const start = new Date(`${GL_PERIOD.fyStart}T00:00:00Z`);
|
||||||
|
const end = new Date(`${GL_PERIOD.fyEnd}T00:00:00Z`);
|
||||||
|
const span = Math.round((end.getTime() - start.getTime()) / 86_400_000);
|
||||||
|
|
||||||
|
const out: FormDeclarantTxn[] = [];
|
||||||
|
let voucher = 1;
|
||||||
|
|
||||||
|
for (const d of formDeclarants()) {
|
||||||
|
// Weighted low — most declarants are occasional users and a handful bank
|
||||||
|
// every week. Squaring the draw gives that long tail rather than a flat
|
||||||
|
// spread, while still leaving enough volume for a day-wise filter to show.
|
||||||
|
const count = 1 + Math.floor(rand() * rand() * 22);
|
||||||
|
|
||||||
|
for (let n = 0; n < count; n++) {
|
||||||
|
const day = new Date(start.getTime());
|
||||||
|
day.setUTCDate(day.getUTCDate() + Math.floor(rand() * span));
|
||||||
|
if (day.getUTCDay() === 0) {
|
||||||
|
day.setUTCDate(day.getUTCDate() + 1); // Sunday — no counter business.
|
||||||
|
}
|
||||||
|
|
||||||
|
const kind = pick(rand, KIND_POOL);
|
||||||
|
const drCr: 'DR' | 'CR' = kind.drCr === 'EITHER' ? (rand() < 0.5 ? 'DR' : 'CR') : kind.drCr;
|
||||||
|
|
||||||
|
// The channel follows the category: cash categories go over the counter,
|
||||||
|
// clearing goes through the house, and the rest split across the
|
||||||
|
// electronic rails a co-operative bank offers.
|
||||||
|
let mode: TxnMode;
|
||||||
|
if (kind.cashOnly) {
|
||||||
|
mode = kind.nature.startsWith('ATM') ? 'ATM' : 'CASH';
|
||||||
|
} else if (kind.nonCash) {
|
||||||
|
mode =
|
||||||
|
kind.group === 'Clearing'
|
||||||
|
? 'CLEARING'
|
||||||
|
: kind.nature.startsWith('RTGS')
|
||||||
|
? 'RTGS'
|
||||||
|
: kind.nature.startsWith('NEFT')
|
||||||
|
? 'NEFT'
|
||||||
|
: kind.group === 'Digital'
|
||||||
|
? 'UPI'
|
||||||
|
: 'TRANSFER';
|
||||||
|
} else {
|
||||||
|
// Categories that can go either way — a term deposit or an EMI is paid
|
||||||
|
// in cash as often as it is transferred at this kind of bank.
|
||||||
|
mode = rand() < 0.55 ? 'CASH' : 'TRANSFER';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inside the category's own band, with a thin large-value tail on the
|
||||||
|
// Rule 114B rows so the report has CTR-sized entries to flag.
|
||||||
|
let amount = kind.min + Math.floor(rand() * (kind.max - kind.min));
|
||||||
|
if (kind.rule114B && kind.cashOnly && rand() < 0.09) {
|
||||||
|
amount = FORM_THRESHOLD.CTR + Math.floor(rand() * 1_400_000);
|
||||||
|
}
|
||||||
|
amount = Math.round(amount / 100) * 100 || 100;
|
||||||
|
|
||||||
|
const cashWord = mode === 'CASH' || mode === 'ATM';
|
||||||
|
out.push({
|
||||||
|
txnId: `TXN${`${out.length + 1}`.padStart(5, '0')}`,
|
||||||
|
ackNo: d.ackNo,
|
||||||
|
date: isoDate(day),
|
||||||
|
branchCode: d.branchCode,
|
||||||
|
accountNo: d.accountNo,
|
||||||
|
mode,
|
||||||
|
drCr,
|
||||||
|
amount,
|
||||||
|
nature: kind.nature,
|
||||||
|
group: kind.group,
|
||||||
|
rule114B: kind.rule114B,
|
||||||
|
particulars:
|
||||||
|
drCr === 'DR'
|
||||||
|
? `${cashWord ? 'Cash paid to' : 'Debit to account of'} ${d.name} — ${kind.nature.toLowerCase()}`
|
||||||
|
: `${cashWord ? 'Cash received from' : 'Credit to account of'} ${d.name} — ${kind.nature.toLowerCase()}`,
|
||||||
|
voucherNo: `${d.branchCode}/V/${`${voucher++}`.padStart(5, '0')}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out.sort((a, b) => (a.date === b.date ? a.txnId.localeCompare(b.txnId) : a.date.localeCompare(b.date)));
|
||||||
|
txnCache = out;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function declarantByAck(ackNo: string): FormDeclarant | undefined {
|
||||||
|
return formDeclarants().find((d) => d.ackNo === ackNo);
|
||||||
|
}
|
||||||
@@ -0,0 +1,370 @@
|
|||||||
|
<!--
|
||||||
|
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="fd-root">
|
||||||
|
<!-- HEADER -->
|
||||||
|
<div class="fd-head">
|
||||||
|
<div class="fd-head-left">
|
||||||
|
<fa-icon icon="file-signature" class="fd-head-ic"></fa-icon>
|
||||||
|
<div>
|
||||||
|
<h2 class="fd-head-t">Form 97 / 98 Transaction Report</h2>
|
||||||
|
<div class="fd-head-s">
|
||||||
|
Every transaction put through on a declaration under Rule 114B, for customers who have no PAN. Form 97 is the
|
||||||
|
general declaration; Form 98 is for a person whose only income is agricultural.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="fd-btn fd-btn--primary" [disabled]="generating" (click)="exportExcel()">
|
||||||
|
<fa-icon icon="file-excel"></fa-icon> {{ generating ? 'Generating…' : 'Download Excel' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- FILTERS -->
|
||||||
|
<div class="fd-filters">
|
||||||
|
<div class="fd-filter-row">
|
||||||
|
<div class="fd-modes">
|
||||||
|
@for (m of periodModes; track m.key) {
|
||||||
|
<button class="fd-chip" [class.active]="periodMode === m.key" (click)="periodMode = m.key">
|
||||||
|
{{ m.label }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@switch (periodMode) {
|
||||||
|
@case ('day') {
|
||||||
|
<label class="fd-field">
|
||||||
|
<span>Date</span>
|
||||||
|
<input type="date" [(ngModel)]="filterDay" [min]="period.fyStart" [max]="period.fyEnd" />
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
@case ('week') {
|
||||||
|
<label class="fd-field">
|
||||||
|
<span>Any date in week</span>
|
||||||
|
<input type="date" [(ngModel)]="filterWeek" [min]="period.fyStart" [max]="period.fyEnd" />
|
||||||
|
</label>
|
||||||
|
<span class="fd-resolved">Week: {{ rangeLabel }}</span>
|
||||||
|
}
|
||||||
|
@case ('month') {
|
||||||
|
<label class="fd-field">
|
||||||
|
<span>Month</span>
|
||||||
|
<input type="month" [(ngModel)]="filterMonth" min="2026-04" max="2026-07" />
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
@default {
|
||||||
|
<label class="fd-field">
|
||||||
|
<span>From</span>
|
||||||
|
<input type="date" [(ngModel)]="filterFrom" [min]="period.fyStart" [max]="period.fyEnd" />
|
||||||
|
</label>
|
||||||
|
<label class="fd-field">
|
||||||
|
<span>To</span>
|
||||||
|
<input type="date" [(ngModel)]="filterTo" [min]="period.fyStart" [max]="period.fyEnd" />
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fd-filter-row">
|
||||||
|
<label class="fd-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="fd-field">
|
||||||
|
<span>Declaration</span>
|
||||||
|
<select [(ngModel)]="formType">
|
||||||
|
<option value="">Both</option>
|
||||||
|
<option value="FORM_60">Form 97 — no PAN</option>
|
||||||
|
<option value="FORM_61">Form 98 — agricultural income</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="fd-field">
|
||||||
|
<span>Transaction type</span>
|
||||||
|
<select [(ngModel)]="group">
|
||||||
|
<option value="">All types</option>
|
||||||
|
@for (g of txnGroups; track g) {
|
||||||
|
<option [value]="g">{{ g }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="fd-field fd-field--grow">
|
||||||
|
<span>Search</span>
|
||||||
|
<input type="text" [(ngModel)]="search" placeholder="Name, account number or acknowledgement no…" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="fd-check">
|
||||||
|
<input type="checkbox" [(ngModel)]="flaggedOnly" />
|
||||||
|
Only CTR reportable
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button class="fd-btn" (click)="resetFilters()"><fa-icon icon="undo"></fa-icon> Reset</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!canSelectBranch) {
|
||||||
|
<div class="fd-scope-note">
|
||||||
|
<fa-icon icon="lock"></fa-icon>
|
||||||
|
Showing {{ branchLabel }} only. Head Office logins can select any branch.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SUMMARY -->
|
||||||
|
<div class="fd-kpis">
|
||||||
|
<div class="fd-kpi">
|
||||||
|
<div class="fd-kpi-n">{{ summary.persons }}</div>
|
||||||
|
<div class="fd-kpi-l">Declarants</div>
|
||||||
|
<div class="fd-kpi-sub">{{ summary.form60Persons }} on Form 97 · {{ summary.form61Persons }} on Form 98</div>
|
||||||
|
</div>
|
||||||
|
<div class="fd-kpi fd-kpi--blue">
|
||||||
|
<div class="fd-kpi-n">{{ summary.txnCount }}</div>
|
||||||
|
<div class="fd-kpi-l">Transactions — all types</div>
|
||||||
|
<div class="fd-kpi-sub">{{ summary.declarationRequired }} in a Rule 114B category</div>
|
||||||
|
</div>
|
||||||
|
<div class="fd-kpi fd-kpi--amber">
|
||||||
|
<div class="fd-kpi-n">{{ summary.cashTotal | number: '1.0-0' }}</div>
|
||||||
|
<div class="fd-kpi-l">Cash turnover (₹)</div>
|
||||||
|
<div class="fd-kpi-sub">{{ summary.cashCount }} cash transactions</div>
|
||||||
|
</div>
|
||||||
|
<div class="fd-kpi" [class.fd-kpi--red]="summary.ctrReportable > 0">
|
||||||
|
<div class="fd-kpi-n">{{ summary.ctrReportable }}</div>
|
||||||
|
<div class="fd-kpi-l">CTR reportable</div>
|
||||||
|
<div class="fd-kpi-sub">cash at or above ₹10,00,000</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CATEGORY MIX — what these customers actually do, beyond the declared rows -->
|
||||||
|
@if (summary.byGroup.length) {
|
||||||
|
<div class="fd-mix">
|
||||||
|
<span class="fd-mix-t">Transaction mix</span>
|
||||||
|
@for (g of summary.byGroup; track g.group) {
|
||||||
|
<button
|
||||||
|
class="fd-mix-item"
|
||||||
|
[class.active]="group === g.group"
|
||||||
|
(click)="group = group === g.group ? '' : g.group"
|
||||||
|
>
|
||||||
|
{{ g.group }} <strong>{{ g.count }}</strong>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- VIEW SWITCH -->
|
||||||
|
<div class="fd-views">
|
||||||
|
<button class="fd-chip" [class.active]="view === 'person'" (click)="view = 'person'">
|
||||||
|
<fa-icon icon="users"></fa-icon> Person wise
|
||||||
|
</button>
|
||||||
|
<button class="fd-chip" [class.active]="view === 'transaction'" (click)="view = 'transaction'">
|
||||||
|
<fa-icon icon="list"></fa-icon> Transaction wise
|
||||||
|
</button>
|
||||||
|
<span class="fd-spacer"></span>
|
||||||
|
<span class="fd-count">
|
||||||
|
{{ view === 'person' ? personRows.length + ' declarants' : txnRows.length + ' transactions' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PERSON WISE -->
|
||||||
|
@if (view === 'person') {
|
||||||
|
<div class="fd-table-wrap">
|
||||||
|
<table class="fd-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th></th>
|
||||||
|
<th>Acknowledgement</th>
|
||||||
|
<th>Form</th>
|
||||||
|
<th>Name of declarant</th>
|
||||||
|
<th>Account No</th>
|
||||||
|
<th>Branch</th>
|
||||||
|
<th class="num">Txns</th>
|
||||||
|
<th class="num">Rule 114B</th>
|
||||||
|
<th class="num">Cash turnover</th>
|
||||||
|
<th class="num">Credit</th>
|
||||||
|
<th class="num">Debit</th>
|
||||||
|
<th class="num">Declared income</th>
|
||||||
|
<th class="num">Cash ÷ income</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (p of personRows; track p.declarant.ackNo) {
|
||||||
|
<tr class="fd-person" [class.open]="expanded === p.declarant.ackNo" (click)="toggle(p.declarant.ackNo)">
|
||||||
|
<td>
|
||||||
|
<fa-icon [icon]="expanded === p.declarant.ackNo ? 'caret-down' : 'caret-right'"></fa-icon>
|
||||||
|
</td>
|
||||||
|
<td class="mono">{{ p.declarant.ackNo }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="fd-form" [class.fd-form--61]="p.declarant.formType === 'FORM_61'">
|
||||||
|
{{ formLabel[p.declarant.formType] }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<strong>{{ p.declarant.name }}</strong>
|
||||||
|
@if (p.ctrCount > 0) {
|
||||||
|
<span class="fd-tag fd-tag--ctr">{{ p.ctrCount }} CTR</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="mono">{{ p.declarant.accountNo }}</td>
|
||||||
|
<td>{{ p.declarant.branchCode }}</td>
|
||||||
|
<td class="num">{{ p.txnCount }}</td>
|
||||||
|
<td class="num">{{ p.declaredCount }}</td>
|
||||||
|
<td class="num strong">{{ p.cashTotal | number: '1.0-0' }}</td>
|
||||||
|
<td class="num">{{ p.totalCredit | number: '1.0-0' }}</td>
|
||||||
|
<td class="num">{{ p.totalDebit | number: '1.0-0' }}</td>
|
||||||
|
<td class="num">{{ p.declarant.estimatedIncome | number: '1.0-0' }}</td>
|
||||||
|
<td class="num">
|
||||||
|
<span [class]="ratioClass(p.incomeRatio)">{{ p.incomeRatio | number: '1.1-1' }}×</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
@if (expanded === p.declarant.ackNo) {
|
||||||
|
<tr class="fd-detail">
|
||||||
|
<td colspan="13">
|
||||||
|
<div class="fd-decl">
|
||||||
|
<div>
|
||||||
|
<label>Father's name</label><span>{{ p.declarant.fatherName }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Date of birth</label><span>{{ p.declarant.dateOfBirth }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Aadhaar</label><span class="mono">XXXX XXXX {{ p.declarant.aadhaarLast4 }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Declared on</label><span>{{ p.declarant.declaredOn }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="wide">
|
||||||
|
<label>Address</label><span>{{ p.declarant.address }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="wide">
|
||||||
|
<label>Reason for no PAN</label><span>{{ p.declarant.reasonForNoPan }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="fd-subtable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Txn ID</th>
|
||||||
|
<th>Mode</th>
|
||||||
|
<th>Dr/Cr</th>
|
||||||
|
<th class="num">Amount</th>
|
||||||
|
<th>Nature of transaction (Rule 114B)</th>
|
||||||
|
<th>Voucher</th>
|
||||||
|
<th>Flag</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (t of p.txns; track t.txnId) {
|
||||||
|
<tr>
|
||||||
|
<td class="mono">{{ t.date }}</td>
|
||||||
|
<td class="mono">{{ t.txnId }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="fd-mode">{{ t.mode }}</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' }}</td>
|
||||||
|
<td>{{ t.nature }}</td>
|
||||||
|
<td class="mono">{{ t.voucherNo }}</td>
|
||||||
|
<td>
|
||||||
|
@if (t.ctrReportable) {
|
||||||
|
<span class="fd-tag fd-tag--ctr">CTR</span>
|
||||||
|
} @else if (t.declarationRequired) {
|
||||||
|
<span class="fd-tag">Rule 114B</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="13" class="fd-empty">
|
||||||
|
No Form 97 or Form 98 transactions for {{ rangeLabel }} at {{ branchLabel }}.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- TRANSACTION WISE -->
|
||||||
|
@if (view === 'transaction') {
|
||||||
|
<div class="fd-table-wrap">
|
||||||
|
<table class="fd-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Txn ID</th>
|
||||||
|
<th>Acknowledgement</th>
|
||||||
|
<th>Form</th>
|
||||||
|
<th>Name of declarant</th>
|
||||||
|
<th>Account No</th>
|
||||||
|
<th>Branch</th>
|
||||||
|
<th>Category</th>
|
||||||
|
<th>Mode</th>
|
||||||
|
<th>Dr/Cr</th>
|
||||||
|
<th class="num">Amount</th>
|
||||||
|
<th>Nature of transaction</th>
|
||||||
|
<th>Voucher</th>
|
||||||
|
<th>Flag</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (t of txnRows; track t.txnId) {
|
||||||
|
<tr [class.fd-row--ctr]="t.ctrReportable">
|
||||||
|
<td class="mono">{{ t.date }}</td>
|
||||||
|
<td class="mono">{{ t.txnId }}</td>
|
||||||
|
<td class="mono">{{ t.ackNo }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="fd-form" [class.fd-form--61]="t.formType === 'FORM_61'">{{ formLabel[t.formType] }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ t.declarantName }}</td>
|
||||||
|
<td class="mono">{{ t.accountNo }}</td>
|
||||||
|
<td>{{ t.branchCode }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="fd-group">{{ t.group }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="fd-mode">{{ t.mode }}</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' }}</td>
|
||||||
|
<td>{{ t.nature }}</td>
|
||||||
|
<td class="mono">{{ t.voucherNo }}</td>
|
||||||
|
<td>
|
||||||
|
@if (t.ctrReportable) {
|
||||||
|
<span class="fd-tag fd-tag--ctr">CTR</span>
|
||||||
|
} @else if (t.declarationRequired) {
|
||||||
|
<span class="fd-tag">Rule 114B</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="14" class="fd-empty">
|
||||||
|
No Form 97 or Form 98 transactions for {{ rangeLabel }} at {{ branchLabel }}.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="fd-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,608 @@
|
|||||||
|
/**
|
||||||
|
* 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 {
|
||||||
|
--f-navy: #1a237e;
|
||||||
|
--f-blue: #1d4ed8;
|
||||||
|
--f-blue-l: #eff6ff;
|
||||||
|
--f-green: #15803d;
|
||||||
|
--f-green-l: #dcfce7;
|
||||||
|
--f-amber: #b45309;
|
||||||
|
--f-amber-l: #fef3c7;
|
||||||
|
--f-red: #c0392b;
|
||||||
|
--f-red-l: #fdecea;
|
||||||
|
--f-g50: #f8fafc;
|
||||||
|
--f-g100: #f1f5f9;
|
||||||
|
--f-g200: #e2e8f0;
|
||||||
|
--f-g300: #cbd5e1;
|
||||||
|
--f-g500: #64748b;
|
||||||
|
--f-g700: #334155;
|
||||||
|
--f-g900: #0f172a;
|
||||||
|
|
||||||
|
display: block;
|
||||||
|
font-family: 'DM Sans', system-ui, sans-serif;
|
||||||
|
background: var(--f-g50);
|
||||||
|
color: var(--f-g900);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fd-root {
|
||||||
|
padding: 18px 22px;
|
||||||
|
max-width: 1700px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* HEADER */
|
||||||
|
.fd-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fd-head-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fd-head-ic {
|
||||||
|
font-size: 26px;
|
||||||
|
color: var(--f-navy);
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fd-head-t {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fd-head-s {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--f-g500);
|
||||||
|
max-width: 820px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* BUTTONS */
|
||||||
|
.fd-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 15px;
|
||||||
|
border: 1px solid var(--f-g300);
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--f-g700);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: all 0.15s;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: var(--f-g50);
|
||||||
|
border-color: var(--f-g500);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--primary {
|
||||||
|
background: var(--f-green);
|
||||||
|
border-color: var(--f-green);
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: #12692f;
|
||||||
|
border-color: #12692f;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* FILTERS */
|
||||||
|
.fd-filters {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--f-g200);
|
||||||
|
border-radius: 11px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fd-filter-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fd-modes {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fd-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border: 1px solid var(--f-g300);
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--f-g700);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--f-navy);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: var(--f-navy);
|
||||||
|
border-color: var(--f-navy);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fd-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--f-g500);
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
padding: 7px 10px;
|
||||||
|
border: 1px solid var(--f-g300);
|
||||||
|
border-radius: 7px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--f-g900);
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--f-navy);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
background: var(--f-g100);
|
||||||
|
color: var(--f-g500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--grow {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 240px;
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fd-resolved {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--f-g500);
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fd-check {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--f-g700);
|
||||||
|
padding-bottom: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fd-scope-note {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--f-amber);
|
||||||
|
background: var(--f-amber-l);
|
||||||
|
border: 1px solid #fcd34d;
|
||||||
|
border-radius: 7px;
|
||||||
|
padding: 6px 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* KPIs */
|
||||||
|
.fd-kpis {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fd-kpi {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--f-g200);
|
||||||
|
border-left: 4px solid var(--f-navy);
|
||||||
|
border-radius: 11px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
|
||||||
|
&--blue {
|
||||||
|
border-left-color: var(--f-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--amber {
|
||||||
|
border-left-color: var(--f-amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--red {
|
||||||
|
border-left-color: var(--f-red);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fd-kpi-n {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: 'DM Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fd-kpi-l {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--f-g700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fd-kpi-sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--f-g500);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* VIEW SWITCH */
|
||||||
|
.fd-views {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fd-spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fd-count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--f-g500);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* TABLE */
|
||||||
|
.fd-table-wrap {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--f-g200);
|
||||||
|
border-radius: 11px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fd-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 12.5px;
|
||||||
|
|
||||||
|
th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 9px 10px;
|
||||||
|
background: var(--f-g100);
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--f-g700);
|
||||||
|
white-space: nowrap;
|
||||||
|
border-bottom: 1px solid var(--f-g200);
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-bottom: 1px solid var(--f-g100);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.num {
|
||||||
|
text-align: right;
|
||||||
|
font-family: 'DM Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strong {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:hover:not(.fd-detail) {
|
||||||
|
background: var(--f-g50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fd-person {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&.open {
|
||||||
|
background: var(--f-blue-l);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fd-row--ctr {
|
||||||
|
background: var(--f-red-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fd-detail {
|
||||||
|
background: #fcfcfe;
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 12px 16px 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fd-decl {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 8px 18px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--f-g200);
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
> 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(--f-g500);
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fd-subtable {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 12px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--f-g200);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 7px 10px;
|
||||||
|
background: var(--f-g50);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--f-g500);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-top: 1px solid var(--f-g100);
|
||||||
|
}
|
||||||
|
|
||||||
|
.num {
|
||||||
|
text-align: right;
|
||||||
|
font-family: 'DM Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strong {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badges */
|
||||||
|
.fd-form {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: var(--f-blue-l);
|
||||||
|
color: var(--f-blue);
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&--61 {
|
||||||
|
background: var(--f-green-l);
|
||||||
|
color: var(--f-green);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fd-mode {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 7px;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: var(--f-g100);
|
||||||
|
color: var(--f-g700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fd-group {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--f-g300);
|
||||||
|
color: var(--f-g700);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Category mix — doubles as a one-click filter. */
|
||||||
|
.fd-mix {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fd-mix-t {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--f-g500);
|
||||||
|
margin-right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fd-mix-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 11px;
|
||||||
|
border: 1px solid var(--f-g200);
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 7px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--f-g700);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-family: 'DM Mono', monospace;
|
||||||
|
color: var(--f-navy);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--f-navy);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: var(--f-navy);
|
||||||
|
border-color: var(--f-navy);
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fd-tag {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 6px;
|
||||||
|
padding: 1px 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: var(--f-amber-l);
|
||||||
|
color: var(--f-amber);
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&--ctr {
|
||||||
|
background: var(--f-red-l);
|
||||||
|
color: var(--f-red);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fd-ratio {
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
&--mid {
|
||||||
|
color: var(--f-amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--high {
|
||||||
|
color: var(--f-red);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cr {
|
||||||
|
color: var(--f-green);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dr {
|
||||||
|
color: var(--f-red);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family: 'DM Mono', monospace;
|
||||||
|
font-size: 11.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fd-empty {
|
||||||
|
padding: 44px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--f-g500);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* TOAST */
|
||||||
|
.fd-toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 32px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%) translateY(20px);
|
||||||
|
background: var(--f-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) {
|
||||||
|
.fd-kpis {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fd-decl {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,474 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
import * as ExcelJS from 'exceljs';
|
||||||
|
|
||||||
|
import { BranchScopeService } from '../core/branch-scope.service';
|
||||||
|
import { GL_BRANCHES, GL_PERIOD, glBranchName } from '../gl-reports/gl-reports-data';
|
||||||
|
import { FORM_LABEL, FORM_THRESHOLD, FormType, TXN_GROUPS } from './form-declaration-data';
|
||||||
|
import {
|
||||||
|
DateRange,
|
||||||
|
FormPersonRow,
|
||||||
|
FormReportFilter,
|
||||||
|
FormReportSummary,
|
||||||
|
FormTxnRow,
|
||||||
|
formPersonRows,
|
||||||
|
formReportSummary,
|
||||||
|
formTxnRows,
|
||||||
|
monthRange,
|
||||||
|
weekRange
|
||||||
|
} from './form-declaration.compute';
|
||||||
|
|
||||||
|
type PeriodMode = 'day' | 'week' | 'month' | 'range';
|
||||||
|
type ViewMode = 'person' | 'transaction';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-form-declaration-report',
|
||||||
|
templateUrl: './form-declaration-report.component.html',
|
||||||
|
styleUrls: ['./form-declaration-report.component.scss'],
|
||||||
|
imports: [
|
||||||
|
...STANDALONE_SHARED_IMPORTS,
|
||||||
|
FaIconComponent,
|
||||||
|
FormsModule
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class FormDeclarationReportComponent implements OnInit {
|
||||||
|
private scope = inject(BranchScopeService);
|
||||||
|
|
||||||
|
readonly period = GL_PERIOD;
|
||||||
|
readonly branches = GL_BRANCHES;
|
||||||
|
readonly formLabel = FORM_LABEL;
|
||||||
|
readonly threshold = FORM_THRESHOLD;
|
||||||
|
readonly txnGroups = TXN_GROUPS;
|
||||||
|
|
||||||
|
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' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Filter state ───────────────────────────────────────────────────────────
|
||||||
|
periodMode: PeriodMode = 'month';
|
||||||
|
filterDay = '2026-07-31';
|
||||||
|
/** Any date inside the wanted week; resolved to Mon–Sat. */
|
||||||
|
filterWeek = '2026-07-27';
|
||||||
|
filterMonth = '2026-07';
|
||||||
|
filterFrom = GL_PERIOD.fyStart;
|
||||||
|
filterTo = GL_PERIOD.fyEnd;
|
||||||
|
branchCode = '';
|
||||||
|
formType: FormType | '' = '';
|
||||||
|
group = '';
|
||||||
|
search = '';
|
||||||
|
flaggedOnly = false;
|
||||||
|
|
||||||
|
view: ViewMode = 'person';
|
||||||
|
/** Acknowledgement number of the person row currently expanded. */
|
||||||
|
expanded: string | 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 {
|
||||||
|
if (!this.canSelectBranch) {
|
||||||
|
return this.scope.officeName;
|
||||||
|
}
|
||||||
|
return this.branchCode ? glBranchName(this.branchCode) : 'All Branches (Consolidated)';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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(): FormReportFilter {
|
||||||
|
return {
|
||||||
|
range: this.range,
|
||||||
|
branchCode: this.effectiveBranch,
|
||||||
|
formType: this.formType,
|
||||||
|
group: this.group,
|
||||||
|
search: this.search,
|
||||||
|
flaggedOnly: this.flaggedOnly
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
resetFilters(): void {
|
||||||
|
this.periodMode = 'month';
|
||||||
|
this.filterDay = '2026-07-31';
|
||||||
|
this.filterWeek = '2026-07-27';
|
||||||
|
this.filterMonth = '2026-07';
|
||||||
|
this.filterFrom = GL_PERIOD.fyStart;
|
||||||
|
this.filterTo = GL_PERIOD.fyEnd;
|
||||||
|
this.formType = '';
|
||||||
|
this.group = '';
|
||||||
|
this.search = '';
|
||||||
|
this.flaggedOnly = false;
|
||||||
|
this.expanded = null;
|
||||||
|
// Reset returns a branch login to its own branch, never to "all".
|
||||||
|
this.branchCode = this.scope.defaultBranchCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Data ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
get summary(): FormReportSummary {
|
||||||
|
return formReportSummary(this.filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
get personRows(): FormPersonRow[] {
|
||||||
|
return formPersonRows(this.filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
get txnRows(): FormTxnRow[] {
|
||||||
|
return formTxnRows(this.filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle(ackNo: string): void {
|
||||||
|
this.expanded = this.expanded === ackNo ? null : ackNo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cash turnover far above declared income is the point of the report. */
|
||||||
|
ratioClass(ratio: number): string {
|
||||||
|
if (ratio >= 5) {
|
||||||
|
return 'fd-ratio fd-ratio--high';
|
||||||
|
}
|
||||||
|
if (ratio >= 2) {
|
||||||
|
return 'fd-ratio fd-ratio--mid';
|
||||||
|
}
|
||||||
|
return 'fd-ratio';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Excel ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async exportExcel(): Promise<void> {
|
||||||
|
this.generating = true;
|
||||||
|
try {
|
||||||
|
const wb = new ExcelJS.Workbook();
|
||||||
|
wb.creator = 'CBSNexora';
|
||||||
|
wb.created = new Date();
|
||||||
|
|
||||||
|
this.writeSummarySheet(wb);
|
||||||
|
this.writePersonSheet(wb);
|
||||||
|
this.writeTransactionSheet(wb);
|
||||||
|
|
||||||
|
const buffer = await wb.xlsx.writeBuffer();
|
||||||
|
const blob = new Blob([buffer], {
|
||||||
|
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||||
|
});
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `Form-97-98-Transaction-Report-${this.range.from}-to-${this.range.to}.xlsx`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
this.toast(`Exported ${this.summary.txnCount} transactions for ${this.rangeLabel}`);
|
||||||
|
} catch {
|
||||||
|
this.toast('Could not generate the workbook — please retry');
|
||||||
|
} finally {
|
||||||
|
this.generating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Title band + filter caption, repeated on every sheet so pages stand alone. */
|
||||||
|
private banner(sheet: ExcelJS.Worksheet, title: string, cols: number): void {
|
||||||
|
const t = sheet.addRow([title]);
|
||||||
|
sheet.mergeCells(t.number, 1, t.number, cols);
|
||||||
|
t.font = { bold: true, size: 14, color: { argb: 'FFFFFFFF' } };
|
||||||
|
t.alignment = { horizontal: 'center', vertical: 'middle' };
|
||||||
|
t.height = 24;
|
||||||
|
t.getCell(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF1A237E' } };
|
||||||
|
|
||||||
|
const formCaption = this.formType ? FORM_LABEL[this.formType] : 'Form 97 and Form 98';
|
||||||
|
const meta = sheet.addRow([
|
||||||
|
`Period: ${this.rangeLabel} · Branch: ${this.branchLabel} · Declaration: ${formCaption}` +
|
||||||
|
` · Category: ${this.group || 'All transaction types'}`
|
||||||
|
]);
|
||||||
|
sheet.mergeCells(meta.number, 1, meta.number, cols);
|
||||||
|
meta.font = { italic: true, size: 10 };
|
||||||
|
sheet.addRow([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private headerRow(sheet: ExcelJS.Worksheet, headers: string[]): void {
|
||||||
|
const head = sheet.addRow(headers);
|
||||||
|
head.font = { bold: true };
|
||||||
|
head.alignment = { wrapText: true, horizontal: 'center' };
|
||||||
|
head.eachCell((c) => {
|
||||||
|
c.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE2E8F0' } };
|
||||||
|
c.border = { bottom: { style: 'thin' } };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private writeSummarySheet(wb: ExcelJS.Workbook): void {
|
||||||
|
const s = this.summary;
|
||||||
|
const sheet = wb.addWorksheet('Summary');
|
||||||
|
sheet.columns = [
|
||||||
|
{ width: 52 },
|
||||||
|
{ width: 24 }];
|
||||||
|
this.banner(sheet, 'Form 97 / 98 Transaction Report — Summary', 2);
|
||||||
|
this.headerRow(sheet, [
|
||||||
|
'Particulars',
|
||||||
|
'Value'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const money = new Set([
|
||||||
|
'Total cash turnover',
|
||||||
|
'Total credits',
|
||||||
|
'Total debits'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const lines: [string, string | number][] = [
|
||||||
|
[
|
||||||
|
'Declarants transacting in the period',
|
||||||
|
s.persons
|
||||||
|
],
|
||||||
|
[
|
||||||
|
' of which Form 97 (no PAN)',
|
||||||
|
s.form60Persons
|
||||||
|
],
|
||||||
|
[
|
||||||
|
' of which Form 98 (agricultural income only)',
|
||||||
|
s.form61Persons
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Total transactions — all types',
|
||||||
|
s.txnCount
|
||||||
|
],
|
||||||
|
[
|
||||||
|
' of which cash (counter and ATM)',
|
||||||
|
s.cashCount
|
||||||
|
],
|
||||||
|
[
|
||||||
|
' of which in a Rule 114B category',
|
||||||
|
s.declarationRequired
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Total cash turnover',
|
||||||
|
s.cashTotal
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Total credits',
|
||||||
|
s.totalCredit
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Total debits',
|
||||||
|
s.totalDebit
|
||||||
|
],
|
||||||
|
[
|
||||||
|
`Cash transactions at or above ₹${FORM_THRESHOLD.CTR.toLocaleString('en-IN')} (CTR reportable)`,
|
||||||
|
s.ctrReportable
|
||||||
|
]
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [
|
||||||
|
label,
|
||||||
|
value
|
||||||
|
] of lines) {
|
||||||
|
const row = sheet.addRow([
|
||||||
|
label,
|
||||||
|
value
|
||||||
|
]);
|
||||||
|
if (money.has(label)) {
|
||||||
|
row.getCell(2).numFmt = '#,##0.00';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category mix — what the customer group actually does, beyond the
|
||||||
|
// declared transactions.
|
||||||
|
sheet.addRow([]);
|
||||||
|
this.headerRow(sheet, [
|
||||||
|
'Transaction category',
|
||||||
|
'Count'
|
||||||
|
]);
|
||||||
|
for (const g of s.byGroup) {
|
||||||
|
sheet.addRow([
|
||||||
|
g.group,
|
||||||
|
g.count
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
sheet.views = [{ state: 'frozen', ySplit: 4 }];
|
||||||
|
}
|
||||||
|
|
||||||
|
private writePersonSheet(wb: ExcelJS.Workbook): void {
|
||||||
|
const sheet = wb.addWorksheet('Person wise');
|
||||||
|
const widths = [
|
||||||
|
18,
|
||||||
|
12,
|
||||||
|
26,
|
||||||
|
24,
|
||||||
|
18,
|
||||||
|
14,
|
||||||
|
10,
|
||||||
|
10,
|
||||||
|
10,
|
||||||
|
16,
|
||||||
|
16,
|
||||||
|
16,
|
||||||
|
14,
|
||||||
|
10,
|
||||||
|
34
|
||||||
|
];
|
||||||
|
sheet.columns = widths.map((w) => ({ width: w }));
|
||||||
|
this.banner(sheet, 'Form 97 / 98 Transaction Report — Person wise', widths.length);
|
||||||
|
this.headerRow(sheet, [
|
||||||
|
'Acknowledgement',
|
||||||
|
'Form',
|
||||||
|
'Name of declarant',
|
||||||
|
"Father's name",
|
||||||
|
'Account No',
|
||||||
|
'Branch',
|
||||||
|
'Total txns',
|
||||||
|
'Cash txns',
|
||||||
|
'Rule 114B txns',
|
||||||
|
'Cash turnover',
|
||||||
|
'Total credit',
|
||||||
|
'Total debit',
|
||||||
|
'Declared income',
|
||||||
|
'CTR rows',
|
||||||
|
'Categories transacted'
|
||||||
|
]);
|
||||||
|
|
||||||
|
for (const p of this.personRows) {
|
||||||
|
const row = sheet.addRow([
|
||||||
|
p.declarant.ackNo,
|
||||||
|
FORM_LABEL[p.declarant.formType],
|
||||||
|
p.declarant.name,
|
||||||
|
p.declarant.fatherName,
|
||||||
|
p.declarant.accountNo,
|
||||||
|
glBranchName(p.declarant.branchCode),
|
||||||
|
p.txnCount,
|
||||||
|
p.cashCount,
|
||||||
|
p.declaredCount,
|
||||||
|
p.cashTotal,
|
||||||
|
p.totalCredit,
|
||||||
|
p.totalDebit,
|
||||||
|
p.declarant.estimatedIncome,
|
||||||
|
p.ctrCount,
|
||||||
|
p.groups.join(', ')
|
||||||
|
]);
|
||||||
|
for (const c of [
|
||||||
|
10,
|
||||||
|
11,
|
||||||
|
12,
|
||||||
|
13
|
||||||
|
]) {
|
||||||
|
row.getCell(c).numFmt = '#,##0.00';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sheet.views = [{ state: 'frozen', ySplit: 4 }];
|
||||||
|
}
|
||||||
|
|
||||||
|
private writeTransactionSheet(wb: ExcelJS.Workbook): void {
|
||||||
|
const sheet = wb.addWorksheet('Transactions');
|
||||||
|
const widths = [
|
||||||
|
12,
|
||||||
|
12,
|
||||||
|
18,
|
||||||
|
12,
|
||||||
|
26,
|
||||||
|
18,
|
||||||
|
14,
|
||||||
|
12,
|
||||||
|
10,
|
||||||
|
6,
|
||||||
|
16,
|
||||||
|
46,
|
||||||
|
16,
|
||||||
|
12
|
||||||
|
];
|
||||||
|
sheet.columns = widths.map((w) => ({ width: w }));
|
||||||
|
this.banner(sheet, 'Form 97 / 98 Transaction Report — Transaction wise', widths.length);
|
||||||
|
this.headerRow(sheet, [
|
||||||
|
'Date',
|
||||||
|
'Txn ID',
|
||||||
|
'Acknowledgement',
|
||||||
|
'Form',
|
||||||
|
'Name of declarant',
|
||||||
|
'Account No',
|
||||||
|
'Branch',
|
||||||
|
'Category',
|
||||||
|
'Mode',
|
||||||
|
'Dr/Cr',
|
||||||
|
'Amount',
|
||||||
|
'Nature of transaction',
|
||||||
|
'Voucher No',
|
||||||
|
'Flag'
|
||||||
|
]);
|
||||||
|
|
||||||
|
for (const t of this.txnRows) {
|
||||||
|
const row = sheet.addRow([
|
||||||
|
t.date,
|
||||||
|
t.txnId,
|
||||||
|
t.ackNo,
|
||||||
|
FORM_LABEL[t.formType],
|
||||||
|
t.declarantName,
|
||||||
|
t.accountNo,
|
||||||
|
glBranchName(t.branchCode),
|
||||||
|
t.group,
|
||||||
|
t.mode,
|
||||||
|
t.drCr,
|
||||||
|
t.amount,
|
||||||
|
t.nature,
|
||||||
|
t.voucherNo,
|
||||||
|
t.ctrReportable ? 'CTR' : t.declarationRequired ? 'Rule 114B' : ''
|
||||||
|
]);
|
||||||
|
row.getCell(11).numFmt = '#,##0.00';
|
||||||
|
if (t.ctrReportable) {
|
||||||
|
row.getCell(14).font = { bold: true, color: { argb: 'FFC0392B' } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sheet.views = [{ state: 'frozen', ySplit: 4 }];
|
||||||
|
}
|
||||||
|
|
||||||
|
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,296 @@
|
|||||||
|
/**
|
||||||
|
* 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 { GL_PERIOD } from '../gl-reports/gl-reports-data';
|
||||||
|
import {
|
||||||
|
FORM_THRESHOLD,
|
||||||
|
FormDeclarant,
|
||||||
|
FormDeclarantTxn,
|
||||||
|
FormType,
|
||||||
|
formDeclarants,
|
||||||
|
formDeclarantTxns
|
||||||
|
} from './form-declaration-data';
|
||||||
|
|
||||||
|
export interface DateRange {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FormReportFilter {
|
||||||
|
range: DateRange;
|
||||||
|
/** '' means every branch. */
|
||||||
|
branchCode: string;
|
||||||
|
/** '' means both declaration types. */
|
||||||
|
formType: FormType | '';
|
||||||
|
/** '' means every transaction category (Cash, Clearing, Loan, …). */
|
||||||
|
group: string;
|
||||||
|
/** Free text over name, account number and acknowledgement number. */
|
||||||
|
search: string;
|
||||||
|
/** Show only rows a compliance officer would need to look at. */
|
||||||
|
flaggedOnly: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One transaction, joined to the declarant who filed for it. */
|
||||||
|
export interface FormTxnRow extends FormDeclarantTxn {
|
||||||
|
declarantName: string;
|
||||||
|
formType: FormType;
|
||||||
|
fatherName: string;
|
||||||
|
declaredOn: string;
|
||||||
|
reasonForNoPan: string;
|
||||||
|
estimatedIncome: number;
|
||||||
|
/** Counter or ATM cash, as opposed to a book or electronic entry. */
|
||||||
|
isCash: boolean;
|
||||||
|
/**
|
||||||
|
* The transaction is one of the categories in Rule 114B, so PAN — and in its
|
||||||
|
* absence the Form 97/98 declaration — was mandatory for it.
|
||||||
|
*/
|
||||||
|
declarationRequired: boolean;
|
||||||
|
/** Cash at or above the CTR limit — also lands in the monthly CTR. */
|
||||||
|
ctrReportable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A declarant with their totals for the period. */
|
||||||
|
export interface FormPersonRow {
|
||||||
|
declarant: FormDeclarant;
|
||||||
|
txns: FormTxnRow[];
|
||||||
|
txnCount: number;
|
||||||
|
cashCount: number;
|
||||||
|
totalCredit: number;
|
||||||
|
totalDebit: number;
|
||||||
|
cashTotal: number;
|
||||||
|
/** Largest single transaction, used to sort the riskiest to the top. */
|
||||||
|
maxAmount: number;
|
||||||
|
/** How many of the period's transactions needed the declaration. */
|
||||||
|
declaredCount: number;
|
||||||
|
ctrCount: number;
|
||||||
|
/** Distinct categories the customer transacted in during the period. */
|
||||||
|
groups: string[];
|
||||||
|
/**
|
||||||
|
* Aggregate cash for the period against the income the customer declared.
|
||||||
|
* A ratio well above 1 is the classic "turnover inconsistent with declared
|
||||||
|
* income" flag, which is why the declaration is collected in the first place.
|
||||||
|
*/
|
||||||
|
incomeRatio: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FormReportSummary {
|
||||||
|
persons: number;
|
||||||
|
form60Persons: number;
|
||||||
|
form61Persons: number;
|
||||||
|
txnCount: number;
|
||||||
|
cashTotal: number;
|
||||||
|
cashCount: number;
|
||||||
|
totalCredit: number;
|
||||||
|
totalDebit: number;
|
||||||
|
/** Transactions falling in a Rule 114B category. */
|
||||||
|
declarationRequired: number;
|
||||||
|
ctrReportable: number;
|
||||||
|
/** Transaction count by category, largest first — drives the mix panel. */
|
||||||
|
byGroup: { group: string; count: number; amount: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(range: DateRange): DateRange {
|
||||||
|
const from = range.from < GL_PERIOD.fyStart ? GL_PERIOD.fyStart : range.from;
|
||||||
|
const to = range.to > GL_PERIOD.fyEnd ? GL_PERIOD.fyEnd : range.to;
|
||||||
|
// A backwards range would silently return nothing; collapse it to a single day.
|
||||||
|
return from > to ? { from: to, to: to } : { from, to };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Monday-to-Saturday banking week containing the given date. */
|
||||||
|
export function weekRange(anchor: string): DateRange {
|
||||||
|
const d = new Date(`${anchor}T00:00:00Z`);
|
||||||
|
const dow = d.getUTCDay(); // 0 = Sunday
|
||||||
|
const monday = new Date(d.getTime());
|
||||||
|
monday.setUTCDate(d.getUTCDate() - (dow === 0 ? 6 : dow - 1));
|
||||||
|
const saturday = new Date(monday.getTime());
|
||||||
|
saturday.setUTCDate(monday.getUTCDate() + 5);
|
||||||
|
const iso = (x: Date) => x.toISOString().slice(0, 10);
|
||||||
|
return { from: iso(monday), to: iso(saturday) };
|
||||||
|
}
|
||||||
|
|
||||||
|
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}-${`${last}`.padStart(2, '0')}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transactions matching the filter, each joined to its declarant.
|
||||||
|
*
|
||||||
|
* Memoised on the filter signature: the getters that feed the template are read
|
||||||
|
* many times per change-detection pass, and re-scanning several hundred rows on
|
||||||
|
* every read is what made the GL reports stall.
|
||||||
|
*/
|
||||||
|
let rowCacheKey = '';
|
||||||
|
let rowCache: FormTxnRow[] = [];
|
||||||
|
|
||||||
|
export function formTxnRows(filter: FormReportFilter): FormTxnRow[] {
|
||||||
|
const range = clamp(filter.range);
|
||||||
|
const key = [
|
||||||
|
range.from,
|
||||||
|
range.to,
|
||||||
|
filter.branchCode,
|
||||||
|
filter.formType,
|
||||||
|
filter.group,
|
||||||
|
filter.search.trim().toLowerCase(),
|
||||||
|
filter.flaggedOnly ? '1' : '0'
|
||||||
|
].join('|');
|
||||||
|
|
||||||
|
if (key === rowCacheKey) {
|
||||||
|
return rowCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
const byAck = new Map(
|
||||||
|
formDeclarants().map((d) => [
|
||||||
|
d.ackNo,
|
||||||
|
d
|
||||||
|
])
|
||||||
|
);
|
||||||
|
const needle = filter.search.trim().toLowerCase();
|
||||||
|
|
||||||
|
const rows: FormTxnRow[] = [];
|
||||||
|
for (const t of formDeclarantTxns()) {
|
||||||
|
if (t.date < range.from || t.date > range.to) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (filter.branchCode && t.branchCode !== filter.branchCode) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (filter.group && t.group !== filter.group) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const d = byAck.get(t.ackNo);
|
||||||
|
if (!d) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (filter.formType && d.formType !== filter.formType) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
needle &&
|
||||||
|
!d.name.toLowerCase().includes(needle) &&
|
||||||
|
!t.accountNo.toLowerCase().includes(needle) &&
|
||||||
|
!d.ackNo.toLowerCase().includes(needle)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ATM dispensing is cash in the customer's hands, so it counts towards the
|
||||||
|
// cash view even though it never crossed the counter.
|
||||||
|
const isCash = t.mode === 'CASH' || t.mode === 'ATM';
|
||||||
|
const ctrReportable = isCash && t.amount >= FORM_THRESHOLD.CTR;
|
||||||
|
|
||||||
|
if (filter.flaggedOnly && !ctrReportable) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.push({
|
||||||
|
...t,
|
||||||
|
declarantName: d.name,
|
||||||
|
formType: d.formType,
|
||||||
|
fatherName: d.fatherName,
|
||||||
|
declaredOn: d.declaredOn,
|
||||||
|
reasonForNoPan: d.reasonForNoPan,
|
||||||
|
estimatedIncome: d.estimatedIncome,
|
||||||
|
isCash,
|
||||||
|
declarationRequired: t.rule114B,
|
||||||
|
ctrReportable
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
rowCacheKey = key;
|
||||||
|
rowCache = rows;
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The same rows grouped by declarant, heaviest customer first. */
|
||||||
|
export function formPersonRows(filter: FormReportFilter): FormPersonRow[] {
|
||||||
|
const byAck = new Map(
|
||||||
|
formDeclarants().map((d) => [
|
||||||
|
d.ackNo,
|
||||||
|
d
|
||||||
|
])
|
||||||
|
);
|
||||||
|
const grouped = new Map<string, FormTxnRow[]>();
|
||||||
|
|
||||||
|
for (const r of formTxnRows(filter)) {
|
||||||
|
const list = grouped.get(r.ackNo);
|
||||||
|
if (list) {
|
||||||
|
list.push(r);
|
||||||
|
} else {
|
||||||
|
grouped.set(r.ackNo, [r]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const out: FormPersonRow[] = [];
|
||||||
|
for (const [
|
||||||
|
ackNo,
|
||||||
|
txns
|
||||||
|
] of grouped) {
|
||||||
|
const declarant = byAck.get(ackNo);
|
||||||
|
if (!declarant) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const cash = txns.filter((t) => t.isCash);
|
||||||
|
const cashTotal = cash.reduce((s, t) => s + t.amount, 0);
|
||||||
|
out.push({
|
||||||
|
declarant,
|
||||||
|
txns,
|
||||||
|
txnCount: txns.length,
|
||||||
|
cashCount: cash.length,
|
||||||
|
totalCredit: txns.filter((t) => t.drCr === 'CR').reduce((s, t) => s + t.amount, 0),
|
||||||
|
totalDebit: txns.filter((t) => t.drCr === 'DR').reduce((s, t) => s + t.amount, 0),
|
||||||
|
cashTotal,
|
||||||
|
maxAmount: txns.reduce((m, t) => Math.max(m, t.amount), 0),
|
||||||
|
declaredCount: txns.filter((t) => t.declarationRequired).length,
|
||||||
|
ctrCount: txns.filter((t) => t.ctrReportable).length,
|
||||||
|
groups: [...new Set(txns.map((t) => t.group))].sort(),
|
||||||
|
incomeRatio: declarant.estimatedIncome > 0 ? cashTotal / declarant.estimatedIncome : 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
out.sort((a, b) => b.cashTotal - a.cashTotal);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formReportSummary(filter: FormReportFilter): FormReportSummary {
|
||||||
|
const rows = formTxnRows(filter);
|
||||||
|
const acks = new Set(rows.map((r) => r.ackNo));
|
||||||
|
const form61 = new Set(rows.filter((r) => r.formType === 'FORM_61').map((r) => r.ackNo));
|
||||||
|
const form60 = new Set(rows.filter((r) => r.formType === 'FORM_60').map((r) => r.ackNo));
|
||||||
|
|
||||||
|
const groupMap = new Map<string, { group: string; count: number; amount: number }>();
|
||||||
|
for (const r of rows) {
|
||||||
|
const g = groupMap.get(r.group);
|
||||||
|
if (g) {
|
||||||
|
g.count++;
|
||||||
|
g.amount += r.amount;
|
||||||
|
} else {
|
||||||
|
groupMap.set(r.group, { group: r.group, count: 1, amount: r.amount });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cash = rows.filter((r) => r.isCash);
|
||||||
|
|
||||||
|
return {
|
||||||
|
persons: acks.size,
|
||||||
|
form60Persons: form60.size,
|
||||||
|
form61Persons: form61.size,
|
||||||
|
txnCount: rows.length,
|
||||||
|
cashTotal: cash.reduce((s, r) => s + r.amount, 0),
|
||||||
|
cashCount: cash.length,
|
||||||
|
totalCredit: rows.filter((r) => r.drCr === 'CR').reduce((s, r) => s + r.amount, 0),
|
||||||
|
totalDebit: rows.filter((r) => r.drCr === 'DR').reduce((s, r) => s + r.amount, 0),
|
||||||
|
declarationRequired: rows.filter((r) => r.declarationRequired).length,
|
||||||
|
ctrReportable: rows.filter((r) => r.ctrReportable).length,
|
||||||
|
byGroup: [...groupMap.values()].sort((a, b) => b.count - a.count)
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -114,18 +114,84 @@ export const GL_ACCOUNTS: GlAccount[] = [
|
|||||||
{ code: '420103', name: "Auditors' Fees", type: 'EXPENSE', group: 'Operating Expenses' },
|
{ code: '420103', name: "Auditors' Fees", type: 'EXPENSE', group: 'Operating Expenses' },
|
||||||
{ code: '420104', name: 'Rent, Taxes & Lighting', type: 'EXPENSE', group: 'Operating Expenses' },
|
{ code: '420104', name: 'Rent, Taxes & Lighting', type: 'EXPENSE', group: 'Operating Expenses' },
|
||||||
{ code: '420105', name: 'Depreciation on Bank Property', type: 'EXPENSE', group: 'Operating Expenses' },
|
{ code: '420105', name: 'Depreciation on Bank Property', type: 'EXPENSE', group: 'Operating Expenses' },
|
||||||
{ code: '430101', name: 'Printing & Stationery', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true },
|
{
|
||||||
{ code: '430102', name: 'Postage, Telegram & Telephone', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true },
|
code: '430101',
|
||||||
{ code: '430103', name: 'Advertisement & Publicity', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true },
|
name: 'Printing & Stationery',
|
||||||
{ code: '430104', name: 'Repairs & Maintenance', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true },
|
type: 'EXPENSE',
|
||||||
{ code: '430105', name: 'Travelling & Conveyance', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true },
|
group: 'Miscellaneous Expenses',
|
||||||
{ code: '430106', name: 'Computer & Software Expenses', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true },
|
miscExpense: true
|
||||||
{ code: '430107', name: 'Legal & Professional Charges', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true },
|
},
|
||||||
{ code: '430108', name: 'Insurance (DICGC & Other)', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true },
|
{
|
||||||
{ code: '430109', name: 'Bank Charges & Commission Paid', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true },
|
code: '430102',
|
||||||
{ code: '430110', name: 'AGM & Meeting Expenses', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true },
|
name: 'Postage, Telegram & Telephone',
|
||||||
|
type: 'EXPENSE',
|
||||||
|
group: 'Miscellaneous Expenses',
|
||||||
|
miscExpense: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: '430103',
|
||||||
|
name: 'Advertisement & Publicity',
|
||||||
|
type: 'EXPENSE',
|
||||||
|
group: 'Miscellaneous Expenses',
|
||||||
|
miscExpense: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: '430104',
|
||||||
|
name: 'Repairs & Maintenance',
|
||||||
|
type: 'EXPENSE',
|
||||||
|
group: 'Miscellaneous Expenses',
|
||||||
|
miscExpense: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: '430105',
|
||||||
|
name: 'Travelling & Conveyance',
|
||||||
|
type: 'EXPENSE',
|
||||||
|
group: 'Miscellaneous Expenses',
|
||||||
|
miscExpense: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: '430106',
|
||||||
|
name: 'Computer & Software Expenses',
|
||||||
|
type: 'EXPENSE',
|
||||||
|
group: 'Miscellaneous Expenses',
|
||||||
|
miscExpense: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: '430107',
|
||||||
|
name: 'Legal & Professional Charges',
|
||||||
|
type: 'EXPENSE',
|
||||||
|
group: 'Miscellaneous Expenses',
|
||||||
|
miscExpense: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: '430108',
|
||||||
|
name: 'Insurance (DICGC & Other)',
|
||||||
|
type: 'EXPENSE',
|
||||||
|
group: 'Miscellaneous Expenses',
|
||||||
|
miscExpense: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: '430109',
|
||||||
|
name: 'Bank Charges & Commission Paid',
|
||||||
|
type: 'EXPENSE',
|
||||||
|
group: 'Miscellaneous Expenses',
|
||||||
|
miscExpense: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: '430110',
|
||||||
|
name: 'AGM & Meeting Expenses',
|
||||||
|
type: 'EXPENSE',
|
||||||
|
group: 'Miscellaneous Expenses',
|
||||||
|
miscExpense: true
|
||||||
|
},
|
||||||
{ code: '430111', name: 'Electricity Charges', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true },
|
{ code: '430111', name: 'Electricity Charges', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true },
|
||||||
{ code: '430112', name: 'Other Miscellaneous Expenses', type: 'EXPENSE', group: 'Miscellaneous Expenses', miscExpense: true },
|
{
|
||||||
|
code: '430112',
|
||||||
|
name: 'Other Miscellaneous Expenses',
|
||||||
|
type: 'EXPENSE',
|
||||||
|
group: 'Miscellaneous Expenses',
|
||||||
|
miscExpense: true
|
||||||
|
},
|
||||||
|
|
||||||
// ── Contingent (memoranda, not part of the balance sheet totals) ──────────
|
// ── Contingent (memoranda, not part of the balance sheet totals) ──────────
|
||||||
{ code: '910101', name: 'Guarantees Issued — Financial', type: 'CONTINGENT', group: 'Guarantees' },
|
{ code: '910101', name: 'Guarantees Issued — Financial', type: 'CONTINGENT', group: 'Guarantees' },
|
||||||
@@ -261,8 +327,20 @@ const VOUCHER_TEMPLATES: {
|
|||||||
{ debit: '110101', credit: '320102', narration: 'Locker rent collected', scale: 9_000, frequency: 0.3 },
|
{ debit: '110101', credit: '320102', narration: 'Locker rent collected', scale: 9_000, frequency: 0.3 },
|
||||||
{ debit: '110101', credit: '320103', narration: 'Loan processing fees', scale: 18_000, frequency: 0.5 },
|
{ debit: '110101', credit: '320103', narration: 'Loan processing fees', scale: 18_000, frequency: 0.5 },
|
||||||
{ debit: '110101', credit: '320105', narration: 'Miscellaneous income', scale: 7_500, frequency: 0.4 },
|
{ debit: '110101', credit: '320105', narration: 'Miscellaneous income', scale: 7_500, frequency: 0.4 },
|
||||||
{ debit: '410101', credit: '230101', narration: 'Interest provided on savings deposits', scale: 128_000, frequency: 0.8 },
|
{
|
||||||
{ debit: '410102', credit: '230101', narration: 'Interest provided on term deposits', scale: 265_000, frequency: 0.9 },
|
debit: '410101',
|
||||||
|
credit: '230101',
|
||||||
|
narration: 'Interest provided on savings deposits',
|
||||||
|
scale: 128_000,
|
||||||
|
frequency: 0.8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
debit: '410102',
|
||||||
|
credit: '230101',
|
||||||
|
narration: 'Interest provided on term deposits',
|
||||||
|
scale: 265_000,
|
||||||
|
frequency: 0.9
|
||||||
|
},
|
||||||
{ debit: '420101', credit: '110101', narration: 'Salaries and allowances paid', scale: 210_000, frequency: 0.35 },
|
{ debit: '420101', credit: '110101', narration: 'Salaries and allowances paid', scale: 210_000, frequency: 0.35 },
|
||||||
{ debit: '420104', credit: '110101', narration: 'Rent, taxes and lighting', scale: 46_000, frequency: 0.3 },
|
{ debit: '420104', credit: '110101', narration: 'Rent, taxes and lighting', scale: 46_000, frequency: 0.3 },
|
||||||
{ debit: '430101', credit: '110101', narration: 'Printing and stationery purchased', scale: 9_400, frequency: 0.3 },
|
{ debit: '430101', credit: '110101', narration: 'Printing and stationery purchased', scale: 9_400, frequency: 0.3 },
|
||||||
@@ -275,14 +353,32 @@ const VOUCHER_TEMPLATES: {
|
|||||||
{ debit: '430109', credit: '110101', narration: 'Bank charges and commission paid', scale: 4_800, frequency: 0.3 },
|
{ debit: '430109', credit: '110101', narration: 'Bank charges and commission paid', scale: 4_800, frequency: 0.3 },
|
||||||
{ debit: '430111', credit: '110101', narration: 'Electricity charges', scale: 13_600, frequency: 0.25 },
|
{ debit: '430111', credit: '110101', narration: 'Electricity charges', scale: 13_600, frequency: 0.25 },
|
||||||
{ debit: '430112', credit: '110101', narration: 'Other miscellaneous expenses', scale: 8_900, frequency: 0.3 },
|
{ debit: '430112', credit: '110101', narration: 'Other miscellaneous expenses', scale: 8_900, frequency: 0.3 },
|
||||||
{ debit: '110102', credit: '110101', narration: 'Cash remitted to RBI currency chest', scale: 1_800_000, frequency: 0.2 },
|
{
|
||||||
{ debit: '110101', credit: '110103', narration: 'Funds drawn from correspondent bank', scale: 1_400_000, frequency: 0.2 }
|
debit: '110102',
|
||||||
|
credit: '110101',
|
||||||
|
narration: 'Cash remitted to RBI currency chest',
|
||||||
|
scale: 1_800_000,
|
||||||
|
frequency: 0.2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
debit: '110101',
|
||||||
|
credit: '110103',
|
||||||
|
narration: 'Funds drawn from correspondent bank',
|
||||||
|
scale: 1_400_000,
|
||||||
|
frequency: 0.2
|
||||||
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Head-Office-only vouchers — investments, reserves and centralised charges. */
|
/** Head-Office-only vouchers — investments, reserves and centralised charges. */
|
||||||
const HO_TEMPLATES: typeof VOUCHER_TEMPLATES = [
|
const HO_TEMPLATES: typeof VOUCHER_TEMPLATES = [
|
||||||
{ debit: '120101', credit: '110102', narration: 'Purchase of Govt securities', scale: 2_400_000, frequency: 0.12 },
|
{ debit: '120101', credit: '110102', narration: 'Purchase of Govt securities', scale: 2_400_000, frequency: 0.12 },
|
||||||
{ debit: '120102', credit: '110102', narration: 'Purchase of State Govt securities', scale: 1_900_000, frequency: 0.12 },
|
{
|
||||||
|
debit: '120102',
|
||||||
|
credit: '110102',
|
||||||
|
narration: 'Purchase of State Govt securities',
|
||||||
|
scale: 1_900_000,
|
||||||
|
frequency: 0.12
|
||||||
|
},
|
||||||
{ debit: '110102', credit: '320104', narration: 'Profit on sale of securities', scale: 78_000, frequency: 0.1 },
|
{ debit: '110102', credit: '320104', narration: 'Profit on sale of securities', scale: 78_000, frequency: 0.1 },
|
||||||
{ debit: '420102', credit: '110101', narration: "Directors' sitting fees", scale: 32_000, frequency: 0.08 },
|
{ debit: '420102', credit: '110101', narration: "Directors' sitting fees", scale: 32_000, frequency: 0.08 },
|
||||||
{ debit: '420103', credit: '110101', narration: "Statutory auditors' fees", scale: 45_000, frequency: 0.06 },
|
{ debit: '420103', credit: '110101', narration: "Statutory auditors' fees", scale: 45_000, frequency: 0.06 },
|
||||||
@@ -342,7 +438,10 @@ export function glPostings(): GlPosting[] {
|
|||||||
let openingDebit = 0;
|
let openingDebit = 0;
|
||||||
let openingCredit = 0;
|
let openingCredit = 0;
|
||||||
|
|
||||||
for (const [code, amount] of Object.entries(OPENING_BALANCES)) {
|
for (const [
|
||||||
|
code,
|
||||||
|
amount
|
||||||
|
] of Object.entries(OPENING_BALANCES)) {
|
||||||
if (!amount) {
|
if (!amount) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -425,7 +524,10 @@ export function glPostings(): GlPosting[] {
|
|||||||
for (const branch of GL_BRANCHES) {
|
for (const branch of GL_BRANCHES) {
|
||||||
// Head Office posts fewer counter transactions than the branches.
|
// Head Office posts fewer counter transactions than the branches.
|
||||||
const weight = branch.code === 'HO' ? 0.45 : 1;
|
const weight = branch.code === 'HO' ? 0.45 : 1;
|
||||||
const templates = branch.code === 'HO' ? [...VOUCHER_TEMPLATES, ...HO_TEMPLATES] : VOUCHER_TEMPLATES;
|
const templates = branch.code === 'HO' ? [
|
||||||
|
...VOUCHER_TEMPLATES,
|
||||||
|
...HO_TEMPLATES
|
||||||
|
] : VOUCHER_TEMPLATES;
|
||||||
|
|
||||||
for (const tpl of templates) {
|
for (const tpl of templates) {
|
||||||
if (rand() > tpl.frequency * weight) {
|
if (rand() > tpl.frequency * weight) {
|
||||||
@@ -487,6 +589,131 @@ export function glPostings(): GlPosting[] {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customer sub-ledger.
|
||||||
|
*
|
||||||
|
* The GL is account-level, so a client-wise report needs a customer dimension on top.
|
||||||
|
* Each customer holds a share of one GL head at one branch; balances are not stored,
|
||||||
|
* they are derived at query time as `share x that head's GL balance`. That way the
|
||||||
|
* client-wise report always reconciles to the General Ledger exactly, whatever period
|
||||||
|
* or branch is selected, instead of drifting as a second source of truth.
|
||||||
|
*/
|
||||||
|
export interface GlCustomer {
|
||||||
|
cif: string;
|
||||||
|
name: string;
|
||||||
|
branchCode: string;
|
||||||
|
/** GL head this relationship sits under. */
|
||||||
|
accountCode: string;
|
||||||
|
accountNo: string;
|
||||||
|
/** Fraction of the branch's balance in that head. Shares per (branch, head) sum to 1. */
|
||||||
|
share: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FIRST_NAMES = [
|
||||||
|
'Ravi',
|
||||||
|
'Priya',
|
||||||
|
'Vikram',
|
||||||
|
'Nirav',
|
||||||
|
'Kavita',
|
||||||
|
'Arjun',
|
||||||
|
'Lakshmi',
|
||||||
|
'Sameer',
|
||||||
|
'Anjali',
|
||||||
|
'Rohan',
|
||||||
|
'Meera',
|
||||||
|
'Karan',
|
||||||
|
'Divya',
|
||||||
|
'Sanjay',
|
||||||
|
'Pooja',
|
||||||
|
'Amit',
|
||||||
|
'Sneha',
|
||||||
|
'Manish',
|
||||||
|
'Rekha',
|
||||||
|
'Deepak'
|
||||||
|
];
|
||||||
|
const LAST_NAMES = [
|
||||||
|
'Sharma',
|
||||||
|
'Patel',
|
||||||
|
'Reddy',
|
||||||
|
'Shah',
|
||||||
|
'Verma',
|
||||||
|
'Deshmukh',
|
||||||
|
'Iyer',
|
||||||
|
'Kapoor',
|
||||||
|
'Nair',
|
||||||
|
'Joshi',
|
||||||
|
'Mehta',
|
||||||
|
'Gupta',
|
||||||
|
'Rao',
|
||||||
|
'Bhatt',
|
||||||
|
'Chauhan'
|
||||||
|
];
|
||||||
|
const FIRMS = [
|
||||||
|
'Traders',
|
||||||
|
'Enterprises',
|
||||||
|
'Agencies',
|
||||||
|
'& Sons',
|
||||||
|
'Industries',
|
||||||
|
'Stores',
|
||||||
|
'Corporation'
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Customer-facing heads a client relationship can sit under. */
|
||||||
|
const CUSTOMER_HEADS = [
|
||||||
|
'220101',
|
||||||
|
'220102',
|
||||||
|
'220103',
|
||||||
|
'220104',
|
||||||
|
'130101',
|
||||||
|
'130102',
|
||||||
|
'130103',
|
||||||
|
'130104',
|
||||||
|
'130105',
|
||||||
|
'130106'
|
||||||
|
];
|
||||||
|
|
||||||
|
let cachedCustomers: GlCustomer[] | null = null;
|
||||||
|
|
||||||
|
export function glCustomers(): GlCustomer[] {
|
||||||
|
if (cachedCustomers) {
|
||||||
|
return cachedCustomers;
|
||||||
|
}
|
||||||
|
const rand = lcg(76543210);
|
||||||
|
const out: GlCustomer[] = [];
|
||||||
|
let seq = 1;
|
||||||
|
|
||||||
|
for (const branch of GL_BRANCHES) {
|
||||||
|
for (const head of CUSTOMER_HEADS) {
|
||||||
|
// Deposits are held by many customers; advances by fewer, larger ones.
|
||||||
|
const isAdvance = head.startsWith('13');
|
||||||
|
const count = isAdvance ? 2 + Math.floor(rand() * 3) : 3 + Math.floor(rand() * 4);
|
||||||
|
const weights: number[] = [];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
weights.push(0.35 + rand() * 0.65);
|
||||||
|
}
|
||||||
|
const weightTotal = weights.reduce((a, b) => a + b, 0);
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const corporate = isAdvance && rand() > 0.55;
|
||||||
|
const name = corporate
|
||||||
|
? `${LAST_NAMES[Math.floor(rand() * LAST_NAMES.length)]} ${FIRMS[Math.floor(rand() * FIRMS.length)]}`
|
||||||
|
: `${FIRST_NAMES[Math.floor(rand() * FIRST_NAMES.length)]} ${LAST_NAMES[Math.floor(rand() * LAST_NAMES.length)]}`;
|
||||||
|
out.push({
|
||||||
|
cif: `CIF${`${100000 + seq}`}`,
|
||||||
|
name,
|
||||||
|
branchCode: branch.code,
|
||||||
|
accountCode: head,
|
||||||
|
accountNo: `${branch.code === 'HO' ? '0001' : branch.code}${head}${`${seq}`.padStart(4, '0')}`,
|
||||||
|
share: weights[i] / weightTotal
|
||||||
|
});
|
||||||
|
seq++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cachedCustomers = out;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
export function glAccount(code: string): GlAccount | undefined {
|
export function glAccount(code: string): GlAccount | undefined {
|
||||||
return GL_ACCOUNTS.find((a) => a.code === code);
|
return GL_ACCOUNTS.find((a) => a.code === code);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,17 +79,33 @@
|
|||||||
|
|
||||||
<div class="glr-filter">
|
<div class="glr-filter">
|
||||||
<label>Branch</label>
|
<label>Branch</label>
|
||||||
<select class="glr-inp" [(ngModel)]="branchCode">
|
@if (canSelectBranch) {
|
||||||
<option value="">All Branches (Consolidated)</option>
|
<select class="glr-inp" [(ngModel)]="branchCode">
|
||||||
@for (b of branches; track b.code) {
|
<option value="">All Branches (Consolidated)</option>
|
||||||
<option [value]="b.code">{{ b.code }} — {{ b.name }}</option>
|
@for (b of branches; track b.code) {
|
||||||
}
|
<option [value]="b.code">{{ b.code }} — {{ b.name }}</option>
|
||||||
</select>
|
}
|
||||||
|
</select>
|
||||||
|
} @else {
|
||||||
|
<span class="glr-chip" title="Your login is restricted to this branch">
|
||||||
|
<fa-icon icon="university"></fa-icon> {{ scopeName }}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="glr-btn" (click)="resetFilters()">Reset</button>
|
<button class="glr-btn" (click)="resetFilters()">Reset</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (scopeIsUnresolved) {
|
||||||
|
<div class="glr-balance glr-balance--bad">
|
||||||
|
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||||
|
<span>
|
||||||
|
Your office <strong>{{ scopeName }}</strong> does not match any branch in the ledger, so no figures can be
|
||||||
|
shown. Ask an administrator to align your office name with a branch.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<!-- REPORT SELECTOR -->
|
<!-- REPORT SELECTOR -->
|
||||||
<div class="glr-tabs">
|
<div class="glr-tabs">
|
||||||
@for (r of reports; track r.code) {
|
@for (r of reports; track r.code) {
|
||||||
@@ -193,7 +209,9 @@
|
|||||||
@for (r of pnlExpense; track r.code) {
|
@for (r of pnlExpense; track r.code) {
|
||||||
<tr>
|
<tr>
|
||||||
<td class="mono">{{ r.code }}</td>
|
<td class="mono">{{ r.code }}</td>
|
||||||
<td>{{ r.name }}<span class="muted"> · {{ r.group }}</span></td>
|
<td>
|
||||||
|
{{ r.name }}<span class="muted"> · {{ r.group }}</span>
|
||||||
|
</td>
|
||||||
<td class="ta-r mono">{{ r.amount | number: '1.2-2' }}</td>
|
<td class="ta-r mono">{{ r.amount | number: '1.2-2' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
} @empty {
|
} @empty {
|
||||||
@@ -229,7 +247,9 @@
|
|||||||
@for (r of pnlIncome; track r.code) {
|
@for (r of pnlIncome; track r.code) {
|
||||||
<tr>
|
<tr>
|
||||||
<td class="mono">{{ r.code }}</td>
|
<td class="mono">{{ r.code }}</td>
|
||||||
<td>{{ r.name }}<span class="muted"> · {{ r.group }}</span></td>
|
<td>
|
||||||
|
{{ r.name }}<span class="muted"> · {{ r.group }}</span>
|
||||||
|
</td>
|
||||||
<td class="ta-r mono">{{ r.amount | number: '1.2-2' }}</td>
|
<td class="ta-r mono">{{ r.amount | number: '1.2-2' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
} @empty {
|
} @empty {
|
||||||
@@ -281,7 +301,9 @@
|
|||||||
@for (r of bsLiabilities; track r.code) {
|
@for (r of bsLiabilities; track r.code) {
|
||||||
<tr>
|
<tr>
|
||||||
<td class="mono">{{ r.code }}</td>
|
<td class="mono">{{ r.code }}</td>
|
||||||
<td>{{ r.name }}<span class="muted"> · {{ r.group }}</span></td>
|
<td>
|
||||||
|
{{ r.name }}<span class="muted"> · {{ r.group }}</span>
|
||||||
|
</td>
|
||||||
<td class="ta-r mono">{{ r.amount | number: '1.2-2' }}</td>
|
<td class="ta-r mono">{{ r.amount | number: '1.2-2' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
@@ -312,7 +334,9 @@
|
|||||||
@for (r of bsAssets; track r.code) {
|
@for (r of bsAssets; track r.code) {
|
||||||
<tr>
|
<tr>
|
||||||
<td class="mono">{{ r.code }}</td>
|
<td class="mono">{{ r.code }}</td>
|
||||||
<td>{{ r.name }}<span class="muted"> · {{ r.group }}</span></td>
|
<td>
|
||||||
|
{{ r.name }}<span class="muted"> · {{ r.group }}</span>
|
||||||
|
</td>
|
||||||
<td class="ta-r mono">{{ r.amount | number: '1.2-2' }}</td>
|
<td class="ta-r mono">{{ r.amount | number: '1.2-2' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
@@ -331,11 +355,17 @@
|
|||||||
@if (activeReport === 'comparative-balance-sheet') {
|
@if (activeReport === 'comparative-balance-sheet') {
|
||||||
<div class="glr-section">
|
<div class="glr-section">
|
||||||
<p class="glr-note">
|
<p class="glr-note">
|
||||||
Compares the closing position as at <strong>{{ range.to }}</strong> against the position immediately before
|
Compares the closing position as at <strong>{{ range.to }}</strong> against the position immediately before this
|
||||||
this period began. Change the filter above to compare a different span.
|
period began. Change the filter above to compare a different span.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@for (block of [{ title: 'Capital & Liabilities', rows: compLiabilities }, { title: 'Assets', rows: compAssets }]; track block.title) {
|
@for (
|
||||||
|
block of [
|
||||||
|
{ title: 'Capital & Liabilities', rows: compLiabilities },
|
||||||
|
{ title: 'Assets', rows: compAssets }
|
||||||
|
];
|
||||||
|
track block.title
|
||||||
|
) {
|
||||||
<div class="glr-tbl-wrap">
|
<div class="glr-tbl-wrap">
|
||||||
<div class="glr-tbl-h">{{ block.title }}</div>
|
<div class="glr-tbl-h">{{ block.title }}</div>
|
||||||
<table class="glr-tbl">
|
<table class="glr-tbl">
|
||||||
@@ -353,7 +383,9 @@
|
|||||||
@for (r of block.rows; track r.code) {
|
@for (r of block.rows; track r.code) {
|
||||||
<tr>
|
<tr>
|
||||||
<td class="mono">{{ r.code }}</td>
|
<td class="mono">{{ r.code }}</td>
|
||||||
<td>{{ r.name }}<span class="muted"> · {{ r.group }}</span></td>
|
<td>
|
||||||
|
{{ r.name }}<span class="muted"> · {{ r.group }}</span>
|
||||||
|
</td>
|
||||||
<td class="ta-r mono">{{ r.current | number: '1.2-2' }}</td>
|
<td class="ta-r mono">{{ r.current | number: '1.2-2' }}</td>
|
||||||
<td class="ta-r mono">{{ r.previous | number: '1.2-2' }}</td>
|
<td class="ta-r mono">{{ r.previous | number: '1.2-2' }}</td>
|
||||||
<td class="ta-r mono" [class.var-up]="r.variance > 0" [class.var-down]="r.variance < 0">
|
<td class="ta-r mono" [class.var-up]="r.variance > 0" [class.var-down]="r.variance < 0">
|
||||||
@@ -379,8 +411,8 @@
|
|||||||
@if (activeReport === 'contingent-liabilities') {
|
@if (activeReport === 'contingent-liabilities') {
|
||||||
<div class="glr-section">
|
<div class="glr-section">
|
||||||
<p class="glr-note">
|
<p class="glr-note">
|
||||||
Off-balance-sheet commitments outstanding as at <strong>{{ range.to }}</strong>. These are memoranda accounts
|
Off-balance-sheet commitments outstanding as at <strong>{{ range.to }}</strong
|
||||||
and are deliberately excluded from the Trial Balance and Balance Sheet totals.
|
>. These are memoranda accounts and are deliberately excluded from the Trial Balance and Balance Sheet totals.
|
||||||
</p>
|
</p>
|
||||||
<div class="glr-tbl-wrap">
|
<div class="glr-tbl-wrap">
|
||||||
<table class="glr-tbl">
|
<table class="glr-tbl">
|
||||||
@@ -419,8 +451,8 @@
|
|||||||
@if (activeReport === 'gl-wise-dr-cr') {
|
@if (activeReport === 'gl-wise-dr-cr') {
|
||||||
<div class="glr-section">
|
<div class="glr-section">
|
||||||
<p class="glr-note">
|
<p class="glr-note">
|
||||||
Debit and credit turnover posted between <strong>{{ rangeLabel }}</strong> for {{ branchLabel }}. Every
|
Debit and credit turnover posted between <strong>{{ rangeLabel }}</strong> for {{ branchLabel }}. Every voucher
|
||||||
voucher is double-entry, so the debit and credit totals below must match.
|
is double-entry, so the debit and credit totals below must match.
|
||||||
</p>
|
</p>
|
||||||
<div class="glr-tbl-wrap">
|
<div class="glr-tbl-wrap">
|
||||||
<table class="glr-tbl">
|
<table class="glr-tbl">
|
||||||
@@ -474,8 +506,8 @@
|
|||||||
@if (activeReport === 'misc-expense-ledger') {
|
@if (activeReport === 'misc-expense-ledger') {
|
||||||
<div class="glr-section">
|
<div class="glr-section">
|
||||||
<p class="glr-note">
|
<p class="glr-note">
|
||||||
Every miscellaneous-expense voucher posted between <strong>{{ rangeLabel }}</strong> for {{ branchLabel }}
|
Every miscellaneous-expense voucher posted between <strong>{{ rangeLabel }}</strong> for
|
||||||
— {{ miscRows.length | number }} entries.
|
{{ branchLabel }} — {{ miscRows.length | number }} entries.
|
||||||
</p>
|
</p>
|
||||||
<div class="glr-tbl-wrap glr-tbl-wrap--tall">
|
<div class="glr-tbl-wrap glr-tbl-wrap--tall">
|
||||||
<table class="glr-tbl">
|
<table class="glr-tbl">
|
||||||
@@ -518,6 +550,506 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<!-- ══════════════ GL AVERAGE BALANCE ══════════════ -->
|
||||||
|
@if (activeReport === 'gl-average-balance') {
|
||||||
|
<div class="glr-section">
|
||||||
|
<p class="glr-note">
|
||||||
|
Average of each account's closing balance across every day in
|
||||||
|
<strong>{{ rangeLabel }}</strong> for {{ branchLabel }}. Banks work from average balances rather than a closing
|
||||||
|
snapshot for interest, CRR/SLR and profitability, because a single day's figure can be dressed up.
|
||||||
|
</p>
|
||||||
|
<div class="glr-tbl-wrap glr-tbl-wrap--tall">
|
||||||
|
<table class="glr-tbl">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>GL Code</th>
|
||||||
|
<th>Account</th>
|
||||||
|
<th>Group</th>
|
||||||
|
<th class="ta-r">Opening</th>
|
||||||
|
<th class="ta-r">Closing</th>
|
||||||
|
<th class="ta-r">Average Daily</th>
|
||||||
|
<th class="ta-r">Peak</th>
|
||||||
|
<th class="ta-r">Lowest</th>
|
||||||
|
<th class="ta-c">Active Days</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (r of averageRows; track r.code) {
|
||||||
|
<tr>
|
||||||
|
<td class="mono">{{ r.code }}</td>
|
||||||
|
<td>{{ r.name }}</td>
|
||||||
|
<td class="muted">{{ r.group }}</td>
|
||||||
|
<td class="ta-r mono">{{ r.opening | number: '1.2-2' }}</td>
|
||||||
|
<td class="ta-r mono">{{ r.closing | number: '1.2-2' }}</td>
|
||||||
|
<td class="ta-r mono">
|
||||||
|
<strong>{{ r.average | number: '1.2-2' }}</strong>
|
||||||
|
</td>
|
||||||
|
<td class="ta-r mono">{{ r.peak | number: '1.2-2' }}</td>
|
||||||
|
<td class="ta-r mono">{{ r.lowest | number: '1.2-2' }}</td>
|
||||||
|
<td class="ta-c mono">{{ r.activeDays }}</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="9" class="glr-empty">No ledger activity for this period and branch.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="total-row">
|
||||||
|
<td colspan="5">TOTAL ({{ averageRows.length }} accounts)</td>
|
||||||
|
<td class="ta-r mono">{{ avgTotalAverage | number: '1.2-2' }}</td>
|
||||||
|
<td colspan="3"></td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- ══════════════ GL MAPPING FOR REPORT ══════════════ -->
|
||||||
|
@if (activeReport === 'gl-mapping') {
|
||||||
|
<div class="glr-section">
|
||||||
|
<p class="glr-note">
|
||||||
|
Which General Ledger accounts feed each RBI return line. Mappings are created on the OSS 1, OSS 2 and OSS 3
|
||||||
|
screens using <strong>Add / Remove GLs</strong>; this is the consolidated view of them, with the value each
|
||||||
|
mapping currently produces from the ledger.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="glr-tbl-wrap">
|
||||||
|
<div class="glr-tbl-h">Mapped return lines ({{ mappingRows.length }})</div>
|
||||||
|
<table class="glr-tbl">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Return</th>
|
||||||
|
<th>Return line</th>
|
||||||
|
<th>Mapped GL accounts</th>
|
||||||
|
<th class="ta-r">Ledger value (₹ thousands)</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (r of mappingRows; track r.returnCode + r.lineKey) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ r.returnLabel }}</td>
|
||||||
|
<td class="mono">{{ r.lineKey }}</td>
|
||||||
|
<td>
|
||||||
|
@for (c of r.codes; track c) {
|
||||||
|
<span class="glr-gl-chip mono">{{ c }}</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="ta-r mono">{{ r.value | number: '1.2-2' }}</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="glr-empty">
|
||||||
|
No return lines mapped yet. Open OSS 1, 2 or 3 and use “Add / Remove GLs” to pin accounts to a line.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="glr-tbl-wrap glr-tbl-wrap--tall">
|
||||||
|
<div class="glr-tbl-h">Unmapped GL accounts ({{ unmappedAccounts.length }}) — not feeding any return line</div>
|
||||||
|
<table class="glr-tbl">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>GL Code</th>
|
||||||
|
<th>Account</th>
|
||||||
|
<th>Group</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (a of unmappedAccounts; track a.code) {
|
||||||
|
<tr>
|
||||||
|
<td class="mono">{{ a.code }}</td>
|
||||||
|
<td>{{ a.name }}</td>
|
||||||
|
<td class="muted">{{ a.group }}</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="3" class="glr-empty">Every GL account feeds at least one return line.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- ══════════════ ACCOUNT WISE BALANCE LIST ══════════════ -->
|
||||||
|
@if (activeReport === 'account-wise-balance') {
|
||||||
|
<div class="glr-section">
|
||||||
|
<p class="glr-note">
|
||||||
|
Closing balance of every account as at <strong>{{ range.to }}</strong
|
||||||
|
>, broken out by branch — which branch is actually carrying each balance.
|
||||||
|
</p>
|
||||||
|
<div class="glr-tbl-wrap glr-tbl-wrap--tall">
|
||||||
|
<table class="glr-tbl">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>GL Code</th>
|
||||||
|
<th>Account</th>
|
||||||
|
<th>Group</th>
|
||||||
|
@for (c of branchColumns; track c) {
|
||||||
|
<th class="ta-r">{{ branchName(c) }}</th>
|
||||||
|
}
|
||||||
|
<th class="ta-r">Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (r of accountWiseRows; track r.code) {
|
||||||
|
<tr>
|
||||||
|
<td class="mono">{{ r.code }}</td>
|
||||||
|
<td>{{ r.name }}</td>
|
||||||
|
<td class="muted">{{ r.group }}</td>
|
||||||
|
@for (c of branchColumns; track c) {
|
||||||
|
<td class="ta-r mono">{{ r.byBranch[c] ? (r.byBranch[c] | number: '1.2-2') : '' }}</td>
|
||||||
|
}
|
||||||
|
<td class="ta-r mono">
|
||||||
|
<strong>{{ r.total | number: '1.2-2' }}</strong>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td [attr.colspan]="branchColumns.length + 4" class="glr-empty">
|
||||||
|
No balances for this period and branch.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="total-row">
|
||||||
|
<td colspan="3">TOTAL ({{ accountWiseRows.length }} accounts)</td>
|
||||||
|
@for (c of branchColumns; track c) {
|
||||||
|
<td class="ta-r mono">{{ branchColumnTotal(c) | number: '1.2-2' }}</td>
|
||||||
|
}
|
||||||
|
<td class="ta-r mono">{{ accountWiseGrandTotal | number: '1.2-2' }}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- ══════════════ DISTRIBUTION OF BALANCE ══════════════ -->
|
||||||
|
@if (activeReport === 'distribution-of-balance') {
|
||||||
|
<div class="glr-section">
|
||||||
|
<p class="glr-note">
|
||||||
|
Composition of the book as at <strong>{{ range.to }}</strong> for {{ branchLabel }} — how concentrated each side
|
||||||
|
of the balance sheet is by account group.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="glr-two-col">
|
||||||
|
@for (
|
||||||
|
block of [
|
||||||
|
{ title: 'Capital & Liabilities', rows: liabilityDistribution, total: liabilityDistTotal },
|
||||||
|
{ title: 'Assets', rows: assetDistribution, total: assetDistTotal }
|
||||||
|
];
|
||||||
|
track block.title
|
||||||
|
) {
|
||||||
|
<div class="glr-tbl-wrap">
|
||||||
|
<div class="glr-tbl-h">{{ block.title }}</div>
|
||||||
|
<table class="glr-tbl">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Group</th>
|
||||||
|
<th class="ta-c">A/cs</th>
|
||||||
|
<th class="ta-r">Amount (₹)</th>
|
||||||
|
<th class="ta-r">Share</th>
|
||||||
|
<th class="glr-bar-col">Distribution</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (r of block.rows; track r.group) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ r.group }}</td>
|
||||||
|
<td class="ta-c mono">{{ r.accounts }}</td>
|
||||||
|
<td class="ta-r mono">{{ r.amount | number: '1.2-2' }}</td>
|
||||||
|
<td class="ta-r mono">
|
||||||
|
<strong>{{ r.share | number: '1.2-2' }}%</strong>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="glr-bar">
|
||||||
|
<div class="glr-bar-fill" [style.width.%]="r.share"></div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="glr-empty">No balances on this side.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
<tr class="total-row">
|
||||||
|
<td colspan="2">Total</td>
|
||||||
|
<td class="ta-r mono">{{ block.total | number: '1.2-2' }}</td>
|
||||||
|
<td class="ta-r mono">100.00%</td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- ══════════════ CLIENT WISE BALANCE ══════════════ -->
|
||||||
|
@if (activeReport === 'client-wise-balance') {
|
||||||
|
<div class="glr-section">
|
||||||
|
<div class="glr-kpis">
|
||||||
|
<div class="glr-kpi glr-kpi--teal">
|
||||||
|
<div class="glr-kpi-n">₹{{ clientDeposits | number: '1.2-2' }}</div>
|
||||||
|
<div class="glr-kpi-l">Customer Deposits</div>
|
||||||
|
</div>
|
||||||
|
<div class="glr-kpi glr-kpi--amber">
|
||||||
|
<div class="glr-kpi-n">₹{{ clientAdvances | number: '1.2-2' }}</div>
|
||||||
|
<div class="glr-kpi-l">Customer Advances</div>
|
||||||
|
</div>
|
||||||
|
<div class="glr-kpi glr-kpi--green">
|
||||||
|
<div class="glr-kpi-n">{{ clientRows.length | number }}</div>
|
||||||
|
<div class="glr-kpi-l">Relationships</div>
|
||||||
|
<div class="glr-kpi-sub">as at {{ range.to }} · {{ branchLabel }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="glr-note">
|
||||||
|
Customer balances are derived from the General Ledger — each relationship holds a share of its branch's balance
|
||||||
|
in that head — so this report always reconciles to the GL rather than drifting as a second source.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="glr-tbl-wrap glr-tbl-wrap--tall">
|
||||||
|
<table class="glr-tbl">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>CIF</th>
|
||||||
|
<th>Customer</th>
|
||||||
|
<th>Branch</th>
|
||||||
|
<th>Account No</th>
|
||||||
|
<th>Product</th>
|
||||||
|
<th class="ta-c">Side</th>
|
||||||
|
<th class="ta-r">Balance (₹)</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (r of clientRows; track r.cif) {
|
||||||
|
<tr>
|
||||||
|
<td class="mono">{{ r.cif }}</td>
|
||||||
|
<td>{{ r.name }}</td>
|
||||||
|
<td>{{ branchName(r.branchCode) }}</td>
|
||||||
|
<td class="mono">{{ r.accountNo }}</td>
|
||||||
|
<td class="muted">{{ r.product }}</td>
|
||||||
|
<td class="ta-c">
|
||||||
|
<span
|
||||||
|
class="type-pill"
|
||||||
|
[class.type-liability]="r.side === 'DEPOSIT'"
|
||||||
|
[class.type-asset]="r.side === 'ADVANCE'"
|
||||||
|
>
|
||||||
|
{{ r.side }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="ta-r mono">{{ r.balance | number: '1.2-2' }}</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="glr-empty">No customer relationships for this period and branch.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- ══════════════ BANKER HEAD REPORT ══════════════ -->
|
||||||
|
@if (activeReport === 'banker-head') {
|
||||||
|
<div class="glr-section">
|
||||||
|
<p class="glr-note">
|
||||||
|
Head-wise abstract for <strong>{{ rangeLabel }}</strong> — the trial balance rolled up to principal heads, with
|
||||||
|
opening, turnover and closing for each. Debit and credit turnover must agree.
|
||||||
|
</p>
|
||||||
|
<div class="glr-tbl-wrap">
|
||||||
|
<table class="glr-tbl">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Principal Head</th>
|
||||||
|
<th class="ta-c">Accounts</th>
|
||||||
|
<th class="ta-r">Opening</th>
|
||||||
|
<th class="ta-r">Debit</th>
|
||||||
|
<th class="ta-r">Credit</th>
|
||||||
|
<th class="ta-r">Closing</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (r of bankerHeadRows; track r.head) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<strong>{{ r.head }}</strong>
|
||||||
|
</td>
|
||||||
|
<td class="ta-c mono">{{ r.accounts }}</td>
|
||||||
|
<td class="ta-r mono">{{ r.opening | number: '1.2-2' }}</td>
|
||||||
|
<td class="ta-r mono">{{ r.debit ? (r.debit | number: '1.2-2') : '' }}</td>
|
||||||
|
<td class="ta-r mono">{{ r.credit ? (r.credit | number: '1.2-2') : '' }}</td>
|
||||||
|
<td class="ta-r mono">
|
||||||
|
<strong>{{ r.closing | number: '1.2-2' }}</strong>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="glr-empty">No activity for this period and branch.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
<tr class="total-row">
|
||||||
|
<td colspan="2">TOTAL ({{ bankerHeadRows.length }} heads)</td>
|
||||||
|
<td></td>
|
||||||
|
<td class="ta-r mono">{{ headDebitTotal | number: '1.2-2' }}</td>
|
||||||
|
<td class="ta-r mono">{{ headCreditTotal | number: '1.2-2' }}</td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- ══════════════ SUNDRY REGISTER ══════════════ -->
|
||||||
|
@if (activeReport === 'sundry-register') {
|
||||||
|
<div class="glr-section">
|
||||||
|
<p class="glr-note">
|
||||||
|
Outstanding items in the sundry, suspense and bills-payable heads as at <strong>{{ range.to }}</strong
|
||||||
|
>, aged from their posting date. These heads are where unreconciled entries collect, so ageing is the point of
|
||||||
|
the register.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
@if (sundryBuckets.length) {
|
||||||
|
<div class="glr-kpis">
|
||||||
|
@for (b of sundryBuckets; track b.bucket) {
|
||||||
|
<div
|
||||||
|
class="glr-kpi"
|
||||||
|
[class.glr-kpi--green]="b.bucket === 'Up to 30 days'"
|
||||||
|
[class.glr-kpi--amber]="b.bucket === '31 to 90 days'"
|
||||||
|
[class.glr-kpi--red]="b.bucket === 'Over 180 days'"
|
||||||
|
>
|
||||||
|
<div class="glr-kpi-n">₹{{ b.amount | number: '1.2-2' }}</div>
|
||||||
|
<div class="glr-kpi-l">{{ b.bucket }}</div>
|
||||||
|
<div class="glr-kpi-sub">{{ b.count }} item(s)</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="glr-tbl-wrap glr-tbl-wrap--tall">
|
||||||
|
<table class="glr-tbl">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Voucher No</th>
|
||||||
|
<th>Branch</th>
|
||||||
|
<th>GL Code</th>
|
||||||
|
<th>Account</th>
|
||||||
|
<th>Narration</th>
|
||||||
|
<th class="ta-r">Amount (₹)</th>
|
||||||
|
<th class="ta-c">Age</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (r of sundryRows; track r.voucherNo + r.code + r.date) {
|
||||||
|
<tr>
|
||||||
|
<td class="mono">{{ r.date }}</td>
|
||||||
|
<td class="mono">{{ r.voucherNo }}</td>
|
||||||
|
<td>{{ branchName(r.branchCode) }}</td>
|
||||||
|
<td class="mono">{{ r.code }}</td>
|
||||||
|
<td>{{ r.name }}</td>
|
||||||
|
<td class="muted">{{ r.narration }}</td>
|
||||||
|
<td class="ta-r mono">{{ r.amount | number: '1.2-2' }}</td>
|
||||||
|
<td class="ta-c">
|
||||||
|
<span
|
||||||
|
class="type-pill"
|
||||||
|
[class.type-income]="r.bucket === 'Up to 30 days'"
|
||||||
|
[class.type-expense]="r.bucket === '31 to 90 days'"
|
||||||
|
[class.type-contingent]="r.bucket === 'Over 180 days'"
|
||||||
|
>
|
||||||
|
{{ r.ageDays }}d
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="8" class="glr-empty">No sundry or suspense items outstanding for this selection.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="total-row">
|
||||||
|
<td colspan="6">Total outstanding ({{ sundryRows.length }} items)</td>
|
||||||
|
<td class="ta-r mono">{{ sundryTotal | number: '1.2-2' }}</td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- ══════════════ VOUCHER REGISTER ══════════════ -->
|
||||||
|
@if (activeReport === 'voucher-register') {
|
||||||
|
<div class="glr-section">
|
||||||
|
<div class="glr-balance" [class.glr-balance--bad]="unbalancedVouchers > 0">
|
||||||
|
<fa-icon [icon]="unbalancedVouchers ? 'exclamation-triangle' : 'check-circle'"></fa-icon>
|
||||||
|
<span>
|
||||||
|
{{ voucherRows.length | number }} voucher(s) raised in {{ rangeLabel }} for {{ branchLabel }} —
|
||||||
|
{{
|
||||||
|
unbalancedVouchers
|
||||||
|
? unbalancedVouchers + ' voucher(s) do NOT balance and must be investigated'
|
||||||
|
: 'every voucher is double-entry and balances'
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="glr-tbl-wrap glr-tbl-wrap--tall">
|
||||||
|
<table class="glr-tbl">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Voucher No</th>
|
||||||
|
<th>Branch</th>
|
||||||
|
<th>Narration</th>
|
||||||
|
<th>Debit account(s)</th>
|
||||||
|
<th>Credit account(s)</th>
|
||||||
|
<th class="ta-r">Amount (₹)</th>
|
||||||
|
<th class="ta-c">Legs</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (r of voucherRows; track r.voucherNo) {
|
||||||
|
<tr [class.glr-unbalanced]="!r.balanced">
|
||||||
|
<td class="mono">{{ r.date }}</td>
|
||||||
|
<td class="mono">{{ r.voucherNo }}</td>
|
||||||
|
<td>{{ branchName(r.branchCode) }}</td>
|
||||||
|
<td class="muted">{{ r.narration }}</td>
|
||||||
|
<td class="mono">{{ r.debitAccounts }}</td>
|
||||||
|
<td class="mono">{{ r.creditAccounts }}</td>
|
||||||
|
<td class="ta-r mono">{{ r.amount | number: '1.2-2' }}</td>
|
||||||
|
<td class="ta-c mono">{{ r.legs }}</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="8" class="glr-empty">No vouchers raised in this period for this branch.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="total-row">
|
||||||
|
<td colspan="6">Total ({{ voucherRows.length }} vouchers)</td>
|
||||||
|
<td class="ta-r mono">{{ voucherTotal | number: '1.2-2' }}</td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<!-- TOAST -->
|
<!-- TOAST -->
|
||||||
<div class="glr-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
|
<div class="glr-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -160,6 +160,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Read-only branch shown to a branch-level login in place of the selector. */
|
||||||
|
.glr-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
min-width: 190px;
|
||||||
|
border: 1px solid var(--g-g200);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--g-g100);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--g-g700);
|
||||||
|
}
|
||||||
|
|
||||||
/* Day / Month / Range segmented control */
|
/* Day / Month / Range segmented control */
|
||||||
.glr-seg {
|
.glr-seg {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -434,6 +449,45 @@
|
|||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A voucher whose debits and credits disagree. */
|
||||||
|
.glr-unbalanced td {
|
||||||
|
background: var(--g-red-l) !important;
|
||||||
|
color: #7f1d1d;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* GL code chips on the mapping report */
|
||||||
|
.glr-gl-chip {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 8px;
|
||||||
|
margin: 1px 3px 1px 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #e0f2fe;
|
||||||
|
color: #075985;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Share bars on the distribution report */
|
||||||
|
.glr-bar-col {
|
||||||
|
width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glr-bar {
|
||||||
|
height: 9px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--g-g100);
|
||||||
|
overflow: hidden;
|
||||||
|
min-width: 110px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glr-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(90deg, var(--g-navy), var(--g-blue));
|
||||||
|
transition: width 0.25s;
|
||||||
|
}
|
||||||
|
|
||||||
/* Account-type pills on the GL Wise report */
|
/* Account-type pills on the GL Wise report */
|
||||||
.type-pill {
|
.type-pill {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|||||||
@@ -6,24 +6,42 @@
|
|||||||
* 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 { Component, OnInit, inject } from '@angular/core';
|
import { Component, DestroyRef, OnInit, inject } from '@angular/core';
|
||||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { BranchScopeService } from 'app/core/branch-scope.service';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
import * as ExcelJS from 'exceljs';
|
import * as ExcelJS from 'exceljs';
|
||||||
import { GL_BRANCHES, GL_PERIOD, glBranchName } from './gl-reports-data';
|
import { GL_ACCOUNTS, GL_BRANCHES, GL_PERIOD, glBranchName } from './gl-reports-data';
|
||||||
import {
|
import {
|
||||||
|
AccountBranchRow,
|
||||||
|
AverageBalanceRow,
|
||||||
BalanceSheetRow,
|
BalanceSheetRow,
|
||||||
ComparativeRow,
|
ComparativeRow,
|
||||||
|
DistributionRow,
|
||||||
GlDateRange,
|
GlDateRange,
|
||||||
GlReportSet,
|
GlReportSet,
|
||||||
MiscExpenseRow,
|
MiscExpenseRow,
|
||||||
PnlRow,
|
PnlRow,
|
||||||
|
BankerHeadRow,
|
||||||
|
ClientBalanceRow,
|
||||||
|
SundryItemRow,
|
||||||
|
VoucherRow,
|
||||||
|
accountWiseByBranch,
|
||||||
|
averageBalances,
|
||||||
|
balanceDistribution,
|
||||||
|
bankerHeadSummary,
|
||||||
|
clientWiseBalances,
|
||||||
|
sundryRegister,
|
||||||
|
voucherRegister,
|
||||||
buildReports,
|
buildReports,
|
||||||
clampToLedger,
|
clampToLedger,
|
||||||
|
glValueForCodes,
|
||||||
sum
|
sum
|
||||||
} from './gl-reports.compute';
|
} from './gl-reports.compute';
|
||||||
|
import { GlMappingEntry, allMappings, glLabel, returnName } from 'app/rbi-circulars/gl-mapping';
|
||||||
|
|
||||||
export type GlReportCode =
|
export type GlReportCode =
|
||||||
| 'trial-balance'
|
| 'trial-balance'
|
||||||
@@ -32,7 +50,15 @@ export type GlReportCode =
|
|||||||
| 'comparative-balance-sheet'
|
| 'comparative-balance-sheet'
|
||||||
| 'contingent-liabilities'
|
| 'contingent-liabilities'
|
||||||
| 'gl-wise-dr-cr'
|
| 'gl-wise-dr-cr'
|
||||||
| 'misc-expense-ledger';
|
| 'misc-expense-ledger'
|
||||||
|
| 'gl-average-balance'
|
||||||
|
| 'gl-mapping'
|
||||||
|
| 'account-wise-balance'
|
||||||
|
| 'distribution-of-balance'
|
||||||
|
| 'client-wise-balance'
|
||||||
|
| 'banker-head'
|
||||||
|
| 'sundry-register'
|
||||||
|
| 'voucher-register';
|
||||||
|
|
||||||
type PeriodMode = 'day' | 'month' | 'range';
|
type PeriodMode = 'day' | 'month' | 'range';
|
||||||
|
|
||||||
@@ -49,6 +75,19 @@ type PeriodMode = 'day' | 'month' | 'range';
|
|||||||
export class GlReportsComponent implements OnInit {
|
export class GlReportsComponent implements OnInit {
|
||||||
private route = inject(ActivatedRoute);
|
private route = inject(ActivatedRoute);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
|
private scope = inject(BranchScopeService);
|
||||||
|
private destroyRef = inject(DestroyRef);
|
||||||
|
|
||||||
|
/** Head Office may pick any branch; a branch login is locked to its own book. */
|
||||||
|
get canSelectBranch(): boolean {
|
||||||
|
return this.scope.canSelectBranch;
|
||||||
|
}
|
||||||
|
get scopeIsUnresolved(): boolean {
|
||||||
|
return this.scope.isUnresolved;
|
||||||
|
}
|
||||||
|
get scopeName(): string {
|
||||||
|
return this.scope.scopeLabel(this.branchCode);
|
||||||
|
}
|
||||||
|
|
||||||
readonly reports: { code: GlReportCode; label: string; icon: string }[] = [
|
readonly reports: { code: GlReportCode; label: string; icon: string }[] = [
|
||||||
{ code: 'trial-balance', label: 'Trial Balance', icon: 'balance-scale' },
|
{ code: 'trial-balance', label: 'Trial Balance', icon: 'balance-scale' },
|
||||||
@@ -57,7 +96,15 @@ export class GlReportsComponent implements OnInit {
|
|||||||
{ code: 'comparative-balance-sheet', label: 'Comparative Balance Sheet', icon: 'balance-scale' },
|
{ code: 'comparative-balance-sheet', label: 'Comparative Balance Sheet', icon: 'balance-scale' },
|
||||||
{ code: 'contingent-liabilities', label: 'Contingent Liabilities', icon: 'exclamation-circle' },
|
{ code: 'contingent-liabilities', label: 'Contingent Liabilities', icon: 'exclamation-circle' },
|
||||||
{ code: 'gl-wise-dr-cr', label: 'GL Wise DR / CR', icon: 'project-diagram' },
|
{ code: 'gl-wise-dr-cr', label: 'GL Wise DR / CR', icon: 'project-diagram' },
|
||||||
{ code: 'misc-expense-ledger', label: 'Misc Expense Ledger', icon: 'receipt' }
|
{ code: 'misc-expense-ledger', label: 'Misc Expense Ledger', icon: 'receipt' },
|
||||||
|
{ code: 'gl-average-balance', label: 'GL Average Balance', icon: 'calculator' },
|
||||||
|
{ code: 'gl-mapping', label: 'GL Mapping for Report', icon: 'project-diagram' },
|
||||||
|
{ code: 'account-wise-balance', label: 'Account wise Balance List', icon: 'list' },
|
||||||
|
{ code: 'distribution-of-balance', label: 'Distribution of Balance', icon: 'chart-pie' },
|
||||||
|
{ code: 'client-wise-balance', label: 'Client wise Balance', icon: 'users' },
|
||||||
|
{ code: 'banker-head', label: 'Banker Head Report', icon: 'university' },
|
||||||
|
{ code: 'sundry-register', label: 'Sundry Register', icon: 'clipboard-list' },
|
||||||
|
{ code: 'voucher-register', label: 'Voucher Register', icon: 'receipt' }
|
||||||
];
|
];
|
||||||
|
|
||||||
activeReport: GlReportCode = 'trial-balance';
|
activeReport: GlReportCode = 'trial-balance';
|
||||||
@@ -80,16 +127,29 @@ export class GlReportsComponent implements OnInit {
|
|||||||
private toastTimer: any;
|
private toastTimer: any;
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
const param = this.route.snapshot.paramMap.get('report') as GlReportCode | null;
|
// Subscribe rather than read the snapshot: navigating between GL menu items only
|
||||||
if (param && this.reports.some((r) => r.code === param)) {
|
// changes the route parameter, so Angular reuses this component and ngOnInit would
|
||||||
this.activeReport = param;
|
// never fire again — leaving the previously selected report on screen.
|
||||||
}
|
this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => {
|
||||||
|
const param = params.get('report') as GlReportCode | null;
|
||||||
|
if (param && this.reports.some((r) => r.code === param)) {
|
||||||
|
this.activeReport = param;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// A branch login opens on its own book and cannot move off it.
|
||||||
|
this.branchCode = this.scope.defaultBranchCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
showReport(code: GlReportCode): void {
|
showReport(code: GlReportCode): void {
|
||||||
this.activeReport = code;
|
this.activeReport = code;
|
||||||
// Keep the URL deep-linkable so the GL menu items land on the right report.
|
// Keep the URL deep-linkable so the GL menu items land on the right report.
|
||||||
this.router.navigate(['/gl/reports', code], { replaceUrl: true });
|
this.router.navigate(
|
||||||
|
[
|
||||||
|
'/gl/reports',
|
||||||
|
code
|
||||||
|
],
|
||||||
|
{ replaceUrl: true }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
get reportLabel(): string {
|
get reportLabel(): string {
|
||||||
@@ -120,21 +180,33 @@ export class GlReportsComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get branchLabel(): string {
|
get branchLabel(): string {
|
||||||
|
if (!this.canSelectBranch) {
|
||||||
|
return this.scopeName;
|
||||||
|
}
|
||||||
return this.branchCode ? glBranchName(this.branchCode) : 'All Branches (Consolidated)';
|
return this.branchCode ? glBranchName(this.branchCode) : 'All Branches (Consolidated)';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The branch actually used for every query. Applied at the data boundary rather than
|
||||||
|
* only in the UI so a branch login cannot widen its scope by tampering with state.
|
||||||
|
*/
|
||||||
|
private get effectiveBranch(): string {
|
||||||
|
return this.scope.enforce(this.branchCode);
|
||||||
|
}
|
||||||
|
|
||||||
resetFilters(): void {
|
resetFilters(): void {
|
||||||
this.periodMode = 'month';
|
this.periodMode = 'month';
|
||||||
this.filterMonth = '2026-07';
|
this.filterMonth = '2026-07';
|
||||||
this.filterDay = '2026-07-31';
|
this.filterDay = '2026-07-31';
|
||||||
this.filterFrom = GL_PERIOD.fyStart;
|
this.filterFrom = GL_PERIOD.fyStart;
|
||||||
this.filterTo = GL_PERIOD.fyEnd;
|
this.filterTo = GL_PERIOD.fyEnd;
|
||||||
this.branchCode = '';
|
// Reset returns a branch login to its own branch, never to "all".
|
||||||
|
this.branchCode = this.scope.defaultBranchCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** All seven reports for the current filter — memoised inside buildReports. */
|
/** All seven reports for the current filter — memoised inside buildReports. */
|
||||||
get data(): GlReportSet {
|
get data(): GlReportSet {
|
||||||
return buildReports(this.range, this.branchCode);
|
return buildReports(this.range, this.effectiveBranch);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Trial Balance ──────────────────────────────────────────────────────────
|
// ── Trial Balance ──────────────────────────────────────────────────────────
|
||||||
@@ -234,6 +306,139 @@ export class GlReportsComponent implements OnInit {
|
|||||||
return glBranchName(code);
|
return glBranchName(code);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── GL Average Balance ─────────────────────────────────────────────────────
|
||||||
|
get averageRows(): AverageBalanceRow[] {
|
||||||
|
return averageBalances(this.range, this.effectiveBranch);
|
||||||
|
}
|
||||||
|
get avgTotalAverage(): number {
|
||||||
|
return sum(this.averageRows, (r) => r.average);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GL Mapping for Report ──────────────────────────────────────────────────
|
||||||
|
/** Every RBI return line currently pinned to GL accounts, with its ledger value. */
|
||||||
|
get mappingRows(): {
|
||||||
|
returnCode: string;
|
||||||
|
returnLabel: string;
|
||||||
|
lineKey: string;
|
||||||
|
codes: string[];
|
||||||
|
codeLabels: string;
|
||||||
|
value: number;
|
||||||
|
}[] {
|
||||||
|
return allMappings()
|
||||||
|
.map((m: GlMappingEntry) => ({
|
||||||
|
returnCode: m.returnCode,
|
||||||
|
returnLabel: returnName(m.returnCode),
|
||||||
|
lineKey: m.lineKey,
|
||||||
|
codes: m.codes,
|
||||||
|
codeLabels: m.codes.map((c) => glLabel(c)).join(', '),
|
||||||
|
// Returns are in thousands; the ledger is in rupees.
|
||||||
|
value: glValueForCodes(m.codes, this.range, this.effectiveBranch) / 1000
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.returnCode.localeCompare(b.returnCode) || a.lineKey.localeCompare(b.lineKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GL accounts not pinned to any return line — the gap a mapping review looks for. */
|
||||||
|
get unmappedAccounts(): { code: string; name: string; group: string }[] {
|
||||||
|
const used = new Set(allMappings().flatMap((m) => m.codes));
|
||||||
|
return this.chartOfAccounts
|
||||||
|
.filter((a) => !used.has(a.code))
|
||||||
|
.map((a) => ({ code: a.code, name: a.name, group: a.group }));
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly chartOfAccounts = GL_ACCOUNTS;
|
||||||
|
|
||||||
|
// ── Account wise Balance List ──────────────────────────────────────────────
|
||||||
|
/** Branch columns shown — every branch for Head Office, just their own otherwise. */
|
||||||
|
get branchColumns(): string[] {
|
||||||
|
return this.canSelectBranch ? GL_BRANCHES.map((b) => b.code) : [this.effectiveBranch];
|
||||||
|
}
|
||||||
|
get accountWiseRows(): AccountBranchRow[] {
|
||||||
|
return accountWiseByBranch(this.range, this.branchColumns);
|
||||||
|
}
|
||||||
|
branchColumnTotal(code: string): number {
|
||||||
|
return sum(this.accountWiseRows, (r) => r.byBranch[code] ?? 0);
|
||||||
|
}
|
||||||
|
get accountWiseGrandTotal(): number {
|
||||||
|
return sum(this.accountWiseRows, (r) => r.total);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Client wise Balance ────────────────────────────────────────────────────
|
||||||
|
get clientRows(): ClientBalanceRow[] {
|
||||||
|
return clientWiseBalances(this.range, this.effectiveBranch);
|
||||||
|
}
|
||||||
|
get clientDeposits(): number {
|
||||||
|
return sum(
|
||||||
|
this.clientRows.filter((r) => r.side === 'DEPOSIT'),
|
||||||
|
(r) => r.balance
|
||||||
|
);
|
||||||
|
}
|
||||||
|
get clientAdvances(): number {
|
||||||
|
return sum(
|
||||||
|
this.clientRows.filter((r) => r.side === 'ADVANCE'),
|
||||||
|
(r) => r.balance
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Banker Head Report ─────────────────────────────────────────────────────
|
||||||
|
get bankerHeadRows(): BankerHeadRow[] {
|
||||||
|
return bankerHeadSummary(this.range, this.effectiveBranch);
|
||||||
|
}
|
||||||
|
get headDebitTotal(): number {
|
||||||
|
return sum(this.bankerHeadRows, (r) => r.debit);
|
||||||
|
}
|
||||||
|
get headCreditTotal(): number {
|
||||||
|
return sum(this.bankerHeadRows, (r) => r.credit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sundry Register ────────────────────────────────────────────────────────
|
||||||
|
get sundryRows(): SundryItemRow[] {
|
||||||
|
return sundryRegister(this.range, this.effectiveBranch);
|
||||||
|
}
|
||||||
|
get sundryTotal(): number {
|
||||||
|
return sum(this.sundryRows, (r) => r.amount);
|
||||||
|
}
|
||||||
|
/** Ageing summary shown above the register. */
|
||||||
|
get sundryBuckets(): { bucket: string; count: number; amount: number }[] {
|
||||||
|
const order = [
|
||||||
|
'Up to 30 days',
|
||||||
|
'31 to 90 days',
|
||||||
|
'91 to 180 days',
|
||||||
|
'Over 180 days'
|
||||||
|
];
|
||||||
|
return order
|
||||||
|
.map((bucket) => {
|
||||||
|
const rows = this.sundryRows.filter((r) => r.bucket === bucket);
|
||||||
|
return { bucket, count: rows.length, amount: sum(rows, (r) => r.amount) };
|
||||||
|
})
|
||||||
|
.filter((b) => b.count > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Voucher Register ───────────────────────────────────────────────────────
|
||||||
|
get voucherRows(): VoucherRow[] {
|
||||||
|
return voucherRegister(this.range, this.effectiveBranch);
|
||||||
|
}
|
||||||
|
get voucherTotal(): number {
|
||||||
|
return sum(this.voucherRows, (r) => r.amount);
|
||||||
|
}
|
||||||
|
/** Vouchers whose debits and credits do not agree — should always be zero. */
|
||||||
|
get unbalancedVouchers(): number {
|
||||||
|
return this.voucherRows.filter((r) => !r.balanced).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Distribution of Balance ────────────────────────────────────────────────
|
||||||
|
get assetDistribution(): DistributionRow[] {
|
||||||
|
return balanceDistribution(this.range, this.effectiveBranch, 'ASSET');
|
||||||
|
}
|
||||||
|
get liabilityDistribution(): DistributionRow[] {
|
||||||
|
return balanceDistribution(this.range, this.effectiveBranch, 'LIABILITY');
|
||||||
|
}
|
||||||
|
get assetDistTotal(): number {
|
||||||
|
return sum(this.assetDistribution, (r) => r.amount);
|
||||||
|
}
|
||||||
|
get liabilityDistTotal(): number {
|
||||||
|
return sum(this.liabilityDistribution, (r) => r.amount);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Excel export ───────────────────────────────────────────────────────────
|
// ── Excel export ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Exports whichever report is on screen, carrying the active filter into the header. */
|
/** Exports whichever report is on screen, carrying the active filter into the header. */
|
||||||
@@ -314,7 +519,17 @@ export class GlReportsComponent implements OnInit {
|
|||||||
'Closing Dr',
|
'Closing Dr',
|
||||||
'Closing Cr'
|
'Closing Cr'
|
||||||
],
|
],
|
||||||
widths: [12, 42, 26, 16, 16, 16, 16, 16, 16],
|
widths: [
|
||||||
|
12,
|
||||||
|
42,
|
||||||
|
26,
|
||||||
|
16,
|
||||||
|
16,
|
||||||
|
16,
|
||||||
|
16,
|
||||||
|
16,
|
||||||
|
16
|
||||||
|
],
|
||||||
rows: [
|
rows: [
|
||||||
...this.data.trialBalance.map((r) => [
|
...this.data.trialBalance.map((r) => [
|
||||||
r.code,
|
r.code,
|
||||||
@@ -349,13 +564,49 @@ export class GlReportsComponent implements OnInit {
|
|||||||
'Group',
|
'Group',
|
||||||
'Amount'
|
'Amount'
|
||||||
],
|
],
|
||||||
widths: [14, 12, 42, 26, 18],
|
widths: [
|
||||||
|
14,
|
||||||
|
12,
|
||||||
|
42,
|
||||||
|
26,
|
||||||
|
18
|
||||||
|
],
|
||||||
rows: [
|
rows: [
|
||||||
...this.pnlExpense.map((r) => ['Expenditure', r.code, r.name, r.group, r.amount]),
|
...this.pnlExpense.map((r) => [
|
||||||
['Total Expenditure', '', '', '', this.totalExpense],
|
'Expenditure',
|
||||||
...this.pnlIncome.map((r) => ['Income', r.code, r.name, r.group, r.amount]),
|
r.code,
|
||||||
['Total Income', '', '', '', this.totalIncome],
|
r.name,
|
||||||
['Net Profit', '', '', '', this.netProfit]
|
r.group,
|
||||||
|
r.amount
|
||||||
|
]),
|
||||||
|
[
|
||||||
|
'Total Expenditure',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
this.totalExpense
|
||||||
|
],
|
||||||
|
...this.pnlIncome.map((r) => [
|
||||||
|
'Income',
|
||||||
|
r.code,
|
||||||
|
r.name,
|
||||||
|
r.group,
|
||||||
|
r.amount
|
||||||
|
]),
|
||||||
|
[
|
||||||
|
'Total Income',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
this.totalIncome
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Net Profit',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
this.netProfit
|
||||||
|
]
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
case 'balance-sheet':
|
case 'balance-sheet':
|
||||||
@@ -367,13 +618,49 @@ export class GlReportsComponent implements OnInit {
|
|||||||
'Group',
|
'Group',
|
||||||
'Amount'
|
'Amount'
|
||||||
],
|
],
|
||||||
widths: [14, 12, 42, 26, 18],
|
widths: [
|
||||||
|
14,
|
||||||
|
12,
|
||||||
|
42,
|
||||||
|
26,
|
||||||
|
18
|
||||||
|
],
|
||||||
rows: [
|
rows: [
|
||||||
...this.bsLiabilities.map((r) => ['Liability', r.code, r.name, r.group, r.amount]),
|
...this.bsLiabilities.map((r) => [
|
||||||
['Liability', '', 'Surplus for the period', 'Profit & Loss', this.bsSurplus],
|
'Liability',
|
||||||
['Total Liabilities', '', '', '', this.bsTotalLiabilities],
|
r.code,
|
||||||
...this.bsAssets.map((r) => ['Asset', r.code, r.name, r.group, r.amount]),
|
r.name,
|
||||||
['Total Assets', '', '', '', this.bsTotalAssets]
|
r.group,
|
||||||
|
r.amount
|
||||||
|
]),
|
||||||
|
[
|
||||||
|
'Liability',
|
||||||
|
'',
|
||||||
|
'Surplus for the period',
|
||||||
|
'Profit & Loss',
|
||||||
|
this.bsSurplus
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Total Liabilities',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
this.bsTotalLiabilities
|
||||||
|
],
|
||||||
|
...this.bsAssets.map((r) => [
|
||||||
|
'Asset',
|
||||||
|
r.code,
|
||||||
|
r.name,
|
||||||
|
r.group,
|
||||||
|
r.amount
|
||||||
|
]),
|
||||||
|
[
|
||||||
|
'Total Assets',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
this.bsTotalAssets
|
||||||
|
]
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
case 'comparative-balance-sheet':
|
case 'comparative-balance-sheet':
|
||||||
@@ -387,7 +674,15 @@ export class GlReportsComponent implements OnInit {
|
|||||||
'Variance',
|
'Variance',
|
||||||
'Variance %'
|
'Variance %'
|
||||||
],
|
],
|
||||||
widths: [12, 12, 42, 18, 18, 18, 14],
|
widths: [
|
||||||
|
12,
|
||||||
|
12,
|
||||||
|
42,
|
||||||
|
18,
|
||||||
|
18,
|
||||||
|
18,
|
||||||
|
14
|
||||||
|
],
|
||||||
rows: this.data.comparative.map((r) => [
|
rows: this.data.comparative.map((r) => [
|
||||||
r.side,
|
r.side,
|
||||||
r.code,
|
r.code,
|
||||||
@@ -406,10 +701,25 @@ export class GlReportsComponent implements OnInit {
|
|||||||
'Group',
|
'Group',
|
||||||
'Amount'
|
'Amount'
|
||||||
],
|
],
|
||||||
widths: [12, 48, 26, 18],
|
widths: [
|
||||||
|
12,
|
||||||
|
48,
|
||||||
|
26,
|
||||||
|
18
|
||||||
|
],
|
||||||
rows: [
|
rows: [
|
||||||
...this.data.contingent.map((r) => [r.code, r.name, r.group, r.amount]),
|
...this.data.contingent.map((r) => [
|
||||||
['TOTAL', '', '', this.contingentTotal]
|
r.code,
|
||||||
|
r.name,
|
||||||
|
r.group,
|
||||||
|
r.amount
|
||||||
|
]),
|
||||||
|
[
|
||||||
|
'TOTAL',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
this.contingentTotal
|
||||||
|
]
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
case 'gl-wise-dr-cr':
|
case 'gl-wise-dr-cr':
|
||||||
@@ -424,10 +734,394 @@ export class GlReportsComponent implements OnInit {
|
|||||||
'Net (Dr-Cr)',
|
'Net (Dr-Cr)',
|
||||||
'Vouchers'
|
'Vouchers'
|
||||||
],
|
],
|
||||||
widths: [12, 42, 14, 26, 18, 18, 18, 12],
|
widths: [
|
||||||
|
12,
|
||||||
|
42,
|
||||||
|
14,
|
||||||
|
26,
|
||||||
|
18,
|
||||||
|
18,
|
||||||
|
18,
|
||||||
|
12
|
||||||
|
],
|
||||||
rows: [
|
rows: [
|
||||||
...this.data.glWise.map((r) => [r.code, r.name, r.type, r.group, r.debit, r.credit, r.net, r.vouchers]),
|
...this.data.glWise.map((r) => [
|
||||||
['TOTAL', '', '', '', this.glWiseDebit, this.glWiseCredit, this.glWiseDebit - this.glWiseCredit, null]
|
r.code,
|
||||||
|
r.name,
|
||||||
|
r.type,
|
||||||
|
r.group,
|
||||||
|
r.debit,
|
||||||
|
r.credit,
|
||||||
|
r.net,
|
||||||
|
r.vouchers
|
||||||
|
]),
|
||||||
|
[
|
||||||
|
'TOTAL',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
this.glWiseDebit,
|
||||||
|
this.glWiseCredit,
|
||||||
|
this.glWiseDebit - this.glWiseCredit,
|
||||||
|
null
|
||||||
|
]
|
||||||
|
]
|
||||||
|
};
|
||||||
|
case 'gl-average-balance':
|
||||||
|
return {
|
||||||
|
headers: [
|
||||||
|
'GL Code',
|
||||||
|
'Account',
|
||||||
|
'Group',
|
||||||
|
'Opening',
|
||||||
|
'Closing',
|
||||||
|
'Average Daily Balance',
|
||||||
|
'Peak',
|
||||||
|
'Lowest',
|
||||||
|
'Active Days'
|
||||||
|
],
|
||||||
|
widths: [
|
||||||
|
12,
|
||||||
|
42,
|
||||||
|
26,
|
||||||
|
18,
|
||||||
|
18,
|
||||||
|
22,
|
||||||
|
18,
|
||||||
|
18,
|
||||||
|
12
|
||||||
|
],
|
||||||
|
rows: [
|
||||||
|
...this.averageRows.map((r) => [
|
||||||
|
r.code,
|
||||||
|
r.name,
|
||||||
|
r.group,
|
||||||
|
r.opening,
|
||||||
|
r.closing,
|
||||||
|
r.average,
|
||||||
|
r.peak,
|
||||||
|
r.lowest,
|
||||||
|
r.activeDays
|
||||||
|
]),
|
||||||
|
[
|
||||||
|
'TOTAL',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
this.avgTotalAverage,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
]
|
||||||
|
]
|
||||||
|
};
|
||||||
|
case 'gl-mapping':
|
||||||
|
return {
|
||||||
|
headers: [
|
||||||
|
'Return',
|
||||||
|
'Return Line',
|
||||||
|
'Mapped GL Accounts',
|
||||||
|
'Ledger Value (Rs Thousands)'
|
||||||
|
],
|
||||||
|
widths: [
|
||||||
|
42,
|
||||||
|
46,
|
||||||
|
60,
|
||||||
|
24
|
||||||
|
],
|
||||||
|
rows: [
|
||||||
|
...this.mappingRows.map((r) => [
|
||||||
|
r.returnLabel,
|
||||||
|
r.lineKey,
|
||||||
|
r.codeLabels,
|
||||||
|
r.value
|
||||||
|
]),
|
||||||
|
...(this.mappingRows.length ? [] : [[
|
||||||
|
'— no return lines mapped —',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
null
|
||||||
|
]]),
|
||||||
|
[
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
null
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'UNMAPPED GL ACCOUNTS',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
null
|
||||||
|
],
|
||||||
|
...this.unmappedAccounts.map((a) => [
|
||||||
|
'',
|
||||||
|
a.code + ' — ' + a.name,
|
||||||
|
a.group,
|
||||||
|
null
|
||||||
|
])
|
||||||
|
]
|
||||||
|
};
|
||||||
|
case 'account-wise-balance':
|
||||||
|
return {
|
||||||
|
headers: [
|
||||||
|
'GL Code',
|
||||||
|
'Account',
|
||||||
|
'Group',
|
||||||
|
...this.branchColumns.map((c) => this.branchName(c)),
|
||||||
|
'Total'
|
||||||
|
],
|
||||||
|
widths: [
|
||||||
|
12,
|
||||||
|
40,
|
||||||
|
24,
|
||||||
|
...this.branchColumns.map(() => 18),
|
||||||
|
20
|
||||||
|
],
|
||||||
|
rows: [
|
||||||
|
...this.accountWiseRows.map((r) => [
|
||||||
|
r.code,
|
||||||
|
r.name,
|
||||||
|
r.group,
|
||||||
|
...this.branchColumns.map((c) => r.byBranch[c] ?? 0),
|
||||||
|
r.total
|
||||||
|
]),
|
||||||
|
[
|
||||||
|
'TOTAL',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
...this.branchColumns.map((c) => this.branchColumnTotal(c)),
|
||||||
|
this.accountWiseGrandTotal
|
||||||
|
]
|
||||||
|
]
|
||||||
|
};
|
||||||
|
case 'distribution-of-balance':
|
||||||
|
return {
|
||||||
|
headers: [
|
||||||
|
'Side',
|
||||||
|
'Group',
|
||||||
|
'Accounts',
|
||||||
|
'Amount',
|
||||||
|
'Share %'
|
||||||
|
],
|
||||||
|
widths: [
|
||||||
|
16,
|
||||||
|
40,
|
||||||
|
14,
|
||||||
|
22,
|
||||||
|
14
|
||||||
|
],
|
||||||
|
rows: [
|
||||||
|
...this.liabilityDistribution.map((r) => [
|
||||||
|
'Liability',
|
||||||
|
r.group,
|
||||||
|
r.accounts,
|
||||||
|
r.amount,
|
||||||
|
Number(r.share.toFixed(2))
|
||||||
|
]),
|
||||||
|
[
|
||||||
|
'Total Liabilities',
|
||||||
|
'',
|
||||||
|
null,
|
||||||
|
this.liabilityDistTotal,
|
||||||
|
100
|
||||||
|
],
|
||||||
|
...this.assetDistribution.map((r) => [
|
||||||
|
'Asset',
|
||||||
|
r.group,
|
||||||
|
r.accounts,
|
||||||
|
r.amount,
|
||||||
|
Number(r.share.toFixed(2))
|
||||||
|
]),
|
||||||
|
[
|
||||||
|
'Total Assets',
|
||||||
|
'',
|
||||||
|
null,
|
||||||
|
this.assetDistTotal,
|
||||||
|
100
|
||||||
|
]
|
||||||
|
]
|
||||||
|
};
|
||||||
|
case 'client-wise-balance':
|
||||||
|
return {
|
||||||
|
headers: [
|
||||||
|
'CIF',
|
||||||
|
'Customer',
|
||||||
|
'Branch',
|
||||||
|
'Account No',
|
||||||
|
'Product',
|
||||||
|
'Side',
|
||||||
|
'Balance'
|
||||||
|
],
|
||||||
|
widths: [
|
||||||
|
14,
|
||||||
|
34,
|
||||||
|
18,
|
||||||
|
24,
|
||||||
|
34,
|
||||||
|
12,
|
||||||
|
20
|
||||||
|
],
|
||||||
|
rows: [
|
||||||
|
...this.clientRows.map((r) => [
|
||||||
|
r.cif,
|
||||||
|
r.name,
|
||||||
|
this.branchName(r.branchCode),
|
||||||
|
r.accountNo,
|
||||||
|
r.product,
|
||||||
|
r.side,
|
||||||
|
r.balance
|
||||||
|
]),
|
||||||
|
[
|
||||||
|
'TOTAL DEPOSITS',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
this.clientDeposits
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'TOTAL ADVANCES',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
this.clientAdvances
|
||||||
|
]
|
||||||
|
]
|
||||||
|
};
|
||||||
|
case 'banker-head':
|
||||||
|
return {
|
||||||
|
headers: [
|
||||||
|
'Principal Head',
|
||||||
|
'Accounts',
|
||||||
|
'Opening',
|
||||||
|
'Debit',
|
||||||
|
'Credit',
|
||||||
|
'Closing'
|
||||||
|
],
|
||||||
|
widths: [
|
||||||
|
34,
|
||||||
|
12,
|
||||||
|
20,
|
||||||
|
20,
|
||||||
|
20,
|
||||||
|
20
|
||||||
|
],
|
||||||
|
rows: [
|
||||||
|
...this.bankerHeadRows.map((r) => [
|
||||||
|
r.head,
|
||||||
|
r.accounts,
|
||||||
|
r.opening,
|
||||||
|
r.debit,
|
||||||
|
r.credit,
|
||||||
|
r.closing
|
||||||
|
]),
|
||||||
|
[
|
||||||
|
'TOTAL',
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
this.headDebitTotal,
|
||||||
|
this.headCreditTotal,
|
||||||
|
null
|
||||||
|
]
|
||||||
|
]
|
||||||
|
};
|
||||||
|
case 'sundry-register':
|
||||||
|
return {
|
||||||
|
headers: [
|
||||||
|
'Date',
|
||||||
|
'Voucher No',
|
||||||
|
'Branch',
|
||||||
|
'GL Code',
|
||||||
|
'Account',
|
||||||
|
'Narration',
|
||||||
|
'Amount',
|
||||||
|
'Age (days)',
|
||||||
|
'Ageing Bucket'
|
||||||
|
],
|
||||||
|
widths: [
|
||||||
|
14,
|
||||||
|
20,
|
||||||
|
18,
|
||||||
|
12,
|
||||||
|
32,
|
||||||
|
42,
|
||||||
|
18,
|
||||||
|
12,
|
||||||
|
18
|
||||||
|
],
|
||||||
|
rows: [
|
||||||
|
...this.sundryRows.map((r) => [
|
||||||
|
r.date,
|
||||||
|
r.voucherNo,
|
||||||
|
this.branchName(r.branchCode),
|
||||||
|
r.code,
|
||||||
|
r.name,
|
||||||
|
r.narration,
|
||||||
|
r.amount,
|
||||||
|
r.ageDays,
|
||||||
|
r.bucket
|
||||||
|
]),
|
||||||
|
[
|
||||||
|
'TOTAL',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
this.sundryTotal,
|
||||||
|
null,
|
||||||
|
''
|
||||||
|
]
|
||||||
|
]
|
||||||
|
};
|
||||||
|
case 'voucher-register':
|
||||||
|
return {
|
||||||
|
headers: [
|
||||||
|
'Date',
|
||||||
|
'Voucher No',
|
||||||
|
'Branch',
|
||||||
|
'Narration',
|
||||||
|
'Debit Account(s)',
|
||||||
|
'Credit Account(s)',
|
||||||
|
'Amount',
|
||||||
|
'Legs'
|
||||||
|
],
|
||||||
|
widths: [
|
||||||
|
14,
|
||||||
|
20,
|
||||||
|
18,
|
||||||
|
42,
|
||||||
|
38,
|
||||||
|
38,
|
||||||
|
20,
|
||||||
|
10
|
||||||
|
],
|
||||||
|
rows: [
|
||||||
|
...this.voucherRows.map((r) => [
|
||||||
|
r.date,
|
||||||
|
r.voucherNo,
|
||||||
|
this.branchName(r.branchCode),
|
||||||
|
r.narration,
|
||||||
|
r.debitAccounts,
|
||||||
|
r.creditAccounts,
|
||||||
|
r.amount,
|
||||||
|
r.legs
|
||||||
|
]),
|
||||||
|
[
|
||||||
|
'TOTAL',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
this.voucherTotal,
|
||||||
|
null
|
||||||
|
]
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
case 'misc-expense-ledger':
|
case 'misc-expense-ledger':
|
||||||
@@ -442,7 +1136,15 @@ export class GlReportsComponent implements OnInit {
|
|||||||
'Narration',
|
'Narration',
|
||||||
'Amount'
|
'Amount'
|
||||||
],
|
],
|
||||||
widths: [14, 20, 18, 12, 36, 46, 18],
|
widths: [
|
||||||
|
14,
|
||||||
|
20,
|
||||||
|
18,
|
||||||
|
12,
|
||||||
|
36,
|
||||||
|
46,
|
||||||
|
18
|
||||||
|
],
|
||||||
rows: [
|
rows: [
|
||||||
...this.miscRows.map((r) => [
|
...this.miscRows.map((r) => [
|
||||||
r.date,
|
r.date,
|
||||||
@@ -453,7 +1155,15 @@ export class GlReportsComponent implements OnInit {
|
|||||||
r.narration,
|
r.narration,
|
||||||
r.amount
|
r.amount
|
||||||
]),
|
]),
|
||||||
['TOTAL', '', '', '', '', '', this.miscTotal]
|
[
|
||||||
|
'TOTAL',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
this.miscTotal
|
||||||
|
]
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,14 +15,7 @@
|
|||||||
* postings and Angular re-evaluates template getters on every change detection.
|
* postings and Angular re-evaluates template getters on every change detection.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import { GL_ACCOUNTS, GL_PERIOD, GlAccount, GlPosting, glAccount, glCustomers, glPostings } from './gl-reports-data';
|
||||||
GL_ACCOUNTS,
|
|
||||||
GL_PERIOD,
|
|
||||||
GlAccount,
|
|
||||||
GlPosting,
|
|
||||||
glAccount,
|
|
||||||
glPostings
|
|
||||||
} from './gl-reports-data';
|
|
||||||
|
|
||||||
export interface GlDateRange {
|
export interface GlDateRange {
|
||||||
from: string;
|
from: string;
|
||||||
@@ -96,8 +89,22 @@ function matchesBranch(p: GlPosting, branchCode: string): boolean {
|
|||||||
return !branchCode || p.branchCode === branchCode;
|
return !branchCode || p.branchCode === branchCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Both scans below walk the whole ~21k-posting ledger, so their results are cached
|
||||||
|
* per (date, branch). Angular re-evaluates template getters on every change-detection
|
||||||
|
* pass; without this a screen with a few hundred mapped lines would spend seconds per
|
||||||
|
* click rescanning the ledger and appear frozen.
|
||||||
|
*/
|
||||||
|
const balanceCache = new Map<string, Map<string, number>>();
|
||||||
|
const turnoverCache = new Map<string, Map<string, { debit: number; credit: number; vouchers: Set<string> }>>();
|
||||||
|
|
||||||
/** Signed balance on the account's natural side, for postings up to and including `to`. */
|
/** Signed balance on the account's natural side, for postings up to and including `to`. */
|
||||||
function cumulativeBalances(to: string, branchCode: string): Map<string, number> {
|
function cumulativeBalances(to: string, branchCode: string): Map<string, number> {
|
||||||
|
const key = `${to}|${branchCode}`;
|
||||||
|
const hit = balanceCache.get(key);
|
||||||
|
if (hit) {
|
||||||
|
return hit;
|
||||||
|
}
|
||||||
const out = new Map<string, number>();
|
const out = new Map<string, number>();
|
||||||
for (const p of glPostings()) {
|
for (const p of glPostings()) {
|
||||||
if (p.date > to || !matchesBranch(p, branchCode)) {
|
if (p.date > to || !matchesBranch(p, branchCode)) {
|
||||||
@@ -110,11 +117,20 @@ function cumulativeBalances(to: string, branchCode: string): Map<string, number>
|
|||||||
const delta = isDebitSide(account) ? p.debit - p.credit : p.credit - p.debit;
|
const delta = isDebitSide(account) ? p.debit - p.credit : p.credit - p.debit;
|
||||||
out.set(p.accountCode, (out.get(p.accountCode) ?? 0) + delta);
|
out.set(p.accountCode, (out.get(p.accountCode) ?? 0) + delta);
|
||||||
}
|
}
|
||||||
|
balanceCache.set(key, out);
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Debit and credit turnover strictly inside the range. */
|
/** Debit and credit turnover strictly inside the range. */
|
||||||
function periodTurnover(range: GlDateRange, branchCode: string): Map<string, { debit: number; credit: number; vouchers: Set<string> }> {
|
function periodTurnover(
|
||||||
|
range: GlDateRange,
|
||||||
|
branchCode: string
|
||||||
|
): Map<string, { debit: number; credit: number; vouchers: Set<string> }> {
|
||||||
|
const key = `${range.from}|${range.to}|${branchCode}`;
|
||||||
|
const hit = turnoverCache.get(key);
|
||||||
|
if (hit) {
|
||||||
|
return hit;
|
||||||
|
}
|
||||||
const out = new Map<string, { debit: number; credit: number; vouchers: Set<string> }>();
|
const out = new Map<string, { debit: number; credit: number; vouchers: Set<string> }>();
|
||||||
for (const p of glPostings()) {
|
for (const p of glPostings()) {
|
||||||
if (p.date < range.from || p.date > range.to || !matchesBranch(p, branchCode)) {
|
if (p.date < range.from || p.date > range.to || !matchesBranch(p, branchCode)) {
|
||||||
@@ -129,6 +145,7 @@ function periodTurnover(range: GlDateRange, branchCode: string): Map<string, { d
|
|||||||
entry.credit += p.credit;
|
entry.credit += p.credit;
|
||||||
entry.vouchers.add(p.voucherNo);
|
entry.vouchers.add(p.voucherNo);
|
||||||
}
|
}
|
||||||
|
turnoverCache.set(key, out);
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,8 +207,7 @@ export function profitAndLoss(range: GlDateRange, branchCode: string): { income:
|
|||||||
if (!move) {
|
if (!move) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const amount =
|
const amount = account.type === 'INCOME' ? move.credit - move.debit : move.debit - move.credit;
|
||||||
account.type === 'INCOME' ? move.credit - move.debit : move.debit - move.credit;
|
|
||||||
if (!amount) {
|
if (!amount) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -205,7 +221,10 @@ export function profitAndLoss(range: GlDateRange, branchCode: string): { income:
|
|||||||
* Balance sheet as at the period end. The current-period surplus is folded in as
|
* Balance sheet as at the period end. The current-period surplus is folded in as
|
||||||
* a liability line so the two sides tally the way the printed statement does.
|
* a liability line so the two sides tally the way the printed statement does.
|
||||||
*/
|
*/
|
||||||
export function balanceSheet(range: GlDateRange, branchCode: string): {
|
export function balanceSheet(
|
||||||
|
range: GlDateRange,
|
||||||
|
branchCode: string
|
||||||
|
): {
|
||||||
assets: BalanceSheetRow[];
|
assets: BalanceSheetRow[];
|
||||||
liabilities: BalanceSheetRow[];
|
liabilities: BalanceSheetRow[];
|
||||||
surplus: number;
|
surplus: number;
|
||||||
@@ -315,7 +334,417 @@ export function miscExpenseLedger(range: GlDateRange, branchCode: string): MiscE
|
|||||||
amount: p.debit
|
amount: p.debit
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return rows.sort((a, b) => (a.date === b.date ? a.voucherNo.localeCompare(b.voucherNo) : a.date.localeCompare(b.date)));
|
return rows.sort((a, b) =>
|
||||||
|
a.date === b.date ? a.voucherNo.localeCompare(b.voucherNo) : a.date.localeCompare(b.date)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AverageBalanceRow {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
group: string;
|
||||||
|
type: string;
|
||||||
|
opening: number;
|
||||||
|
closing: number;
|
||||||
|
average: number;
|
||||||
|
peak: number;
|
||||||
|
lowest: number;
|
||||||
|
/** Days on which the account moved. */
|
||||||
|
activeDays: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccountBranchRow {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
group: string;
|
||||||
|
/** Closing balance per branch code, plus a `total`. */
|
||||||
|
byBranch: Record<string, number>;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DistributionRow {
|
||||||
|
group: string;
|
||||||
|
amount: number;
|
||||||
|
share: number;
|
||||||
|
accounts: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Calendar days in the range, inclusive. */
|
||||||
|
function daysInRange(range: GlDateRange): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
const cur = new Date(`${range.from}T00:00:00Z`);
|
||||||
|
const end = new Date(`${range.to}T00:00:00Z`);
|
||||||
|
while (cur <= end) {
|
||||||
|
out.push(cur.toISOString().slice(0, 10));
|
||||||
|
cur.setUTCDate(cur.getUTCDate() + 1);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Average daily balance per account across the period, plus the peak and lowest
|
||||||
|
* balance touched. Banks use average balances rather than closing snapshots for
|
||||||
|
* interest, CRR/SLR and profitability work, because a closing figure can be
|
||||||
|
* window-dressed on the last day.
|
||||||
|
*
|
||||||
|
* Computed by carrying a running balance forward day by day rather than
|
||||||
|
* recomputing a cumulative balance per day, which would be quadratic.
|
||||||
|
*/
|
||||||
|
export function averageBalances(range: GlDateRange, branchCode: string): AverageBalanceRow[] {
|
||||||
|
const opening = cumulativeBalances(previousDay(range.from), branchCode);
|
||||||
|
const days = daysInRange(range);
|
||||||
|
|
||||||
|
// Net movement per account per day, on the account's natural side.
|
||||||
|
const dailyDelta = new Map<string, Map<string, number>>();
|
||||||
|
const movedOn = new Map<string, Set<string>>();
|
||||||
|
for (const p of glPostings()) {
|
||||||
|
if (p.date < range.from || p.date > range.to || !matchesBranch(p, branchCode)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const account = glAccount(p.accountCode);
|
||||||
|
if (!account) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const delta = isDebitSide(account) ? p.debit - p.credit : p.credit - p.debit;
|
||||||
|
let perDay = dailyDelta.get(p.accountCode);
|
||||||
|
if (!perDay) {
|
||||||
|
perDay = new Map<string, number>();
|
||||||
|
dailyDelta.set(p.accountCode, perDay);
|
||||||
|
}
|
||||||
|
perDay.set(p.date, (perDay.get(p.date) ?? 0) + delta);
|
||||||
|
let seen = movedOn.get(p.accountCode);
|
||||||
|
if (!seen) {
|
||||||
|
seen = new Set<string>();
|
||||||
|
movedOn.set(p.accountCode, seen);
|
||||||
|
}
|
||||||
|
seen.add(p.date);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: AverageBalanceRow[] = [];
|
||||||
|
for (const account of GL_ACCOUNTS) {
|
||||||
|
const open = opening.get(account.code) ?? 0;
|
||||||
|
const perDay = dailyDelta.get(account.code);
|
||||||
|
if (!open && !perDay) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let running = open;
|
||||||
|
let total = 0;
|
||||||
|
let peak = open;
|
||||||
|
let lowest = open;
|
||||||
|
for (const d of days) {
|
||||||
|
running += perDay?.get(d) ?? 0;
|
||||||
|
total += running;
|
||||||
|
peak = Math.max(peak, running);
|
||||||
|
lowest = Math.min(lowest, running);
|
||||||
|
}
|
||||||
|
rows.push({
|
||||||
|
code: account.code,
|
||||||
|
name: account.name,
|
||||||
|
group: account.group,
|
||||||
|
type: account.type,
|
||||||
|
opening: open,
|
||||||
|
closing: running,
|
||||||
|
average: days.length ? total / days.length : 0,
|
||||||
|
peak,
|
||||||
|
lowest,
|
||||||
|
activeDays: movedOn.get(account.code)?.size ?? 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closing balance of every account broken out by branch. Answers "which branch is
|
||||||
|
* carrying this balance", which a single consolidated column cannot.
|
||||||
|
*/
|
||||||
|
export function accountWiseByBranch(range: GlDateRange, branchCodes: string[]): AccountBranchRow[] {
|
||||||
|
const perBranch = new Map<string, Map<string, number>>();
|
||||||
|
for (const b of branchCodes) {
|
||||||
|
perBranch.set(b, cumulativeBalances(range.to, b));
|
||||||
|
}
|
||||||
|
const rows: AccountBranchRow[] = [];
|
||||||
|
for (const account of GL_ACCOUNTS) {
|
||||||
|
const byBranch: Record<string, number> = {};
|
||||||
|
let total = 0;
|
||||||
|
let any = false;
|
||||||
|
for (const b of branchCodes) {
|
||||||
|
const v = perBranch.get(b)?.get(account.code) ?? 0;
|
||||||
|
byBranch[b] = v;
|
||||||
|
total += v;
|
||||||
|
if (v) {
|
||||||
|
any = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!any) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
rows.push({ code: account.code, name: account.name, group: account.group, byBranch, total });
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Share of the closing book held by each account group, for one side of the
|
||||||
|
* balance sheet. Used to see concentration at a glance.
|
||||||
|
*/
|
||||||
|
export function balanceDistribution(
|
||||||
|
range: GlDateRange,
|
||||||
|
branchCode: string,
|
||||||
|
side: 'ASSET' | 'LIABILITY'
|
||||||
|
): DistributionRow[] {
|
||||||
|
const closing = cumulativeBalances(range.to, branchCode);
|
||||||
|
const byGroup = new Map<string, { amount: number; accounts: number }>();
|
||||||
|
let grand = 0;
|
||||||
|
|
||||||
|
for (const account of GL_ACCOUNTS) {
|
||||||
|
if (account.type !== side) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const v = closing.get(account.code) ?? 0;
|
||||||
|
if (!v) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const entry = byGroup.get(account.group) ?? { amount: 0, accounts: 0 };
|
||||||
|
entry.amount += v;
|
||||||
|
entry.accounts += 1;
|
||||||
|
byGroup.set(account.group, entry);
|
||||||
|
grand += v;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...byGroup.entries()]
|
||||||
|
.map(
|
||||||
|
([
|
||||||
|
group,
|
||||||
|
e
|
||||||
|
]) => ({
|
||||||
|
group,
|
||||||
|
amount: e.amount,
|
||||||
|
accounts: e.accounts,
|
||||||
|
share: grand ? (e.amount / grand) * 100 : 0
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.sort((a, b) => b.amount - a.amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClientBalanceRow {
|
||||||
|
cif: string;
|
||||||
|
name: string;
|
||||||
|
branchCode: string;
|
||||||
|
accountNo: string;
|
||||||
|
accountCode: string;
|
||||||
|
product: string;
|
||||||
|
/** DEPOSIT or ADVANCE — which side of the relationship this is. */
|
||||||
|
side: 'DEPOSIT' | 'ADVANCE';
|
||||||
|
balance: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BankerHeadRow {
|
||||||
|
head: string;
|
||||||
|
opening: number;
|
||||||
|
debit: number;
|
||||||
|
credit: number;
|
||||||
|
closing: number;
|
||||||
|
accounts: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SundryItemRow {
|
||||||
|
date: string;
|
||||||
|
voucherNo: string;
|
||||||
|
branchCode: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
narration: string;
|
||||||
|
amount: number;
|
||||||
|
/** Days outstanding as at the period end. */
|
||||||
|
ageDays: number;
|
||||||
|
bucket: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VoucherRow {
|
||||||
|
voucherNo: string;
|
||||||
|
date: string;
|
||||||
|
branchCode: string;
|
||||||
|
narration: string;
|
||||||
|
debitAccounts: string;
|
||||||
|
creditAccounts: string;
|
||||||
|
amount: number;
|
||||||
|
legs: number;
|
||||||
|
/** False when debits and credits on the voucher do not agree. */
|
||||||
|
balanced: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-wise balances, derived from the GL rather than stored separately so the two
|
||||||
|
* can never disagree. Each customer's balance is its share of the branch's closing
|
||||||
|
* balance in the head it sits under.
|
||||||
|
*/
|
||||||
|
export function clientWiseBalances(range: GlDateRange, branchCode: string): ClientBalanceRow[] {
|
||||||
|
const rows: ClientBalanceRow[] = [];
|
||||||
|
const branchClosings = new Map<string, Map<string, number>>();
|
||||||
|
|
||||||
|
for (const c of glCustomers()) {
|
||||||
|
if (branchCode && c.branchCode !== branchCode) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let closing = branchClosings.get(c.branchCode);
|
||||||
|
if (!closing) {
|
||||||
|
closing = cumulativeBalances(range.to, c.branchCode);
|
||||||
|
branchClosings.set(c.branchCode, closing);
|
||||||
|
}
|
||||||
|
const headBalance = closing.get(c.accountCode) ?? 0;
|
||||||
|
const balance = headBalance * c.share;
|
||||||
|
if (Math.abs(balance) < 0.005) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const account = glAccount(c.accountCode);
|
||||||
|
rows.push({
|
||||||
|
cif: c.cif,
|
||||||
|
name: c.name,
|
||||||
|
branchCode: c.branchCode,
|
||||||
|
accountNo: c.accountNo,
|
||||||
|
accountCode: c.accountCode,
|
||||||
|
product: account?.name ?? c.accountCode,
|
||||||
|
side: c.accountCode.startsWith('13') ? 'ADVANCE' : 'DEPOSIT',
|
||||||
|
balance
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return rows.sort((a, b) => b.balance - a.balance);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Head-wise abstract: the trial balance rolled up to principal heads, the summary a
|
||||||
|
* banker reads before the detail. Opening, turnover and closing per head.
|
||||||
|
*/
|
||||||
|
export function bankerHeadSummary(range: GlDateRange, branchCode: string): BankerHeadRow[] {
|
||||||
|
const opening = cumulativeBalances(previousDay(range.from), branchCode);
|
||||||
|
const turnover = periodTurnover(range, branchCode);
|
||||||
|
const byHead = new Map<string, BankerHeadRow>();
|
||||||
|
|
||||||
|
for (const account of GL_ACCOUNTS) {
|
||||||
|
// Contingents are single-sided off-balance-sheet memoranda. Including them would
|
||||||
|
// make the abstract's debit and credit turnover disagree and stop it reconciling
|
||||||
|
// to the trial balance, which also excludes them. They have their own report.
|
||||||
|
if (account.type === 'CONTINGENT') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const open = opening.get(account.code) ?? 0;
|
||||||
|
const move = turnover.get(account.code);
|
||||||
|
if (!open && !move) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const debit = move?.debit ?? 0;
|
||||||
|
const credit = move?.credit ?? 0;
|
||||||
|
const closing = open + (isDebitSide(account) ? debit - credit : credit - debit);
|
||||||
|
|
||||||
|
const row = byHead.get(account.group) ?? {
|
||||||
|
head: account.group,
|
||||||
|
opening: 0,
|
||||||
|
debit: 0,
|
||||||
|
credit: 0,
|
||||||
|
closing: 0,
|
||||||
|
accounts: 0
|
||||||
|
};
|
||||||
|
row.opening += open;
|
||||||
|
row.debit += debit;
|
||||||
|
row.credit += credit;
|
||||||
|
row.closing += closing;
|
||||||
|
row.accounts += 1;
|
||||||
|
byHead.set(account.group, row);
|
||||||
|
}
|
||||||
|
return [...byHead.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUNDRY_CODES = [
|
||||||
|
'140104',
|
||||||
|
'230103',
|
||||||
|
'140199',
|
||||||
|
'230102'
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outstanding items in the sundry and suspense heads, aged from the posting date.
|
||||||
|
* These accounts are where unreconciled entries accumulate, so ageing is the point
|
||||||
|
* of the register.
|
||||||
|
*/
|
||||||
|
export function sundryRegister(range: GlDateRange, branchCode: string): SundryItemRow[] {
|
||||||
|
const wanted = new Set(SUNDRY_CODES);
|
||||||
|
const end = new Date(`${range.to}T00:00:00Z`).getTime();
|
||||||
|
const rows: SundryItemRow[] = [];
|
||||||
|
|
||||||
|
for (const p of glPostings()) {
|
||||||
|
if (p.date > range.to || !matchesBranch(p, branchCode) || !wanted.has(p.accountCode)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const ageDays = Math.max(0, Math.round((end - new Date(`${p.date}T00:00:00Z`).getTime()) / 86400000));
|
||||||
|
const bucket =
|
||||||
|
ageDays <= 30
|
||||||
|
? 'Up to 30 days'
|
||||||
|
: ageDays <= 90
|
||||||
|
? '31 to 90 days'
|
||||||
|
: ageDays <= 180
|
||||||
|
? '91 to 180 days'
|
||||||
|
: 'Over 180 days';
|
||||||
|
rows.push({
|
||||||
|
date: p.date,
|
||||||
|
voucherNo: p.voucherNo,
|
||||||
|
branchCode: p.branchCode,
|
||||||
|
code: p.accountCode,
|
||||||
|
name: glAccount(p.accountCode)?.name ?? p.accountCode,
|
||||||
|
narration: p.narration,
|
||||||
|
amount: p.debit || p.credit,
|
||||||
|
ageDays,
|
||||||
|
bucket
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return rows.sort((a, b) => b.ageDays - a.ageDays);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every voucher raised in the period with its debit and credit legs collapsed onto
|
||||||
|
* one line, plus a per-voucher double-entry check.
|
||||||
|
*/
|
||||||
|
export function voucherRegister(range: GlDateRange, branchCode: string): VoucherRow[] {
|
||||||
|
const grouped = new Map<string, GlPosting[]>();
|
||||||
|
for (const p of glPostings()) {
|
||||||
|
if (p.date < range.from || p.date > range.to || !matchesBranch(p, branchCode)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const list = grouped.get(p.voucherNo);
|
||||||
|
if (list) {
|
||||||
|
list.push(p);
|
||||||
|
} else {
|
||||||
|
grouped.set(p.voucherNo, [p]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: VoucherRow[] = [];
|
||||||
|
for (const [
|
||||||
|
voucherNo,
|
||||||
|
legs
|
||||||
|
] of grouped) {
|
||||||
|
const debits = legs.filter((l) => l.debit > 0);
|
||||||
|
const credits = legs.filter((l) => l.credit > 0);
|
||||||
|
const drTotal = debits.reduce((s, l) => s + l.debit, 0);
|
||||||
|
const crTotal = credits.reduce((s, l) => s + l.credit, 0);
|
||||||
|
const nameOf = (codes: GlPosting[], side: 'debit' | 'credit') =>
|
||||||
|
[...new Set(codes.map((l) => `${l.accountCode} ${glAccount(l.accountCode)?.name ?? ''}`.trim()))].join(', ');
|
||||||
|
|
||||||
|
rows.push({
|
||||||
|
voucherNo,
|
||||||
|
date: legs[0].date,
|
||||||
|
branchCode: legs[0].branchCode,
|
||||||
|
narration: legs[0].narration,
|
||||||
|
debitAccounts: nameOf(debits, 'debit') || '—',
|
||||||
|
creditAccounts: nameOf(credits, 'credit') || '—',
|
||||||
|
amount: Math.max(drTotal, crTotal),
|
||||||
|
legs: legs.length,
|
||||||
|
// Contingent and opening memoranda are single-sided by design.
|
||||||
|
balanced: voucherNo.startsWith('CNT/') || voucherNo.startsWith('OB/') || Math.round(drTotal - crTotal) === 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return rows.sort((a, b) =>
|
||||||
|
a.date === b.date ? a.voucherNo.localeCompare(b.voucherNo) : a.date.localeCompare(b.date)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Memoisation ──────────────────────────────────────────────────────────────
|
// ── Memoisation ──────────────────────────────────────────────────────────────
|
||||||
@@ -360,6 +789,41 @@ export function clampToLedger(range: GlDateRange): GlDateRange {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Value of a set of GL accounts for a period, used when an RBI return line item is
|
||||||
|
* mapped to the chart of accounts.
|
||||||
|
*
|
||||||
|
* Balance-sheet accounts contribute their closing balance as at the period end;
|
||||||
|
* income and expenditure accounts contribute their movement within the period.
|
||||||
|
* That mirrors how the two kinds of figure are read on a return.
|
||||||
|
*/
|
||||||
|
export function glValueForCodes(codes: string[], range: GlDateRange, branchCode = ''): number {
|
||||||
|
if (!codes.length) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const wanted = new Set(codes);
|
||||||
|
const closing = cumulativeBalances(range.to, branchCode);
|
||||||
|
const movement = periodTurnover(range, branchCode);
|
||||||
|
let total = 0;
|
||||||
|
|
||||||
|
for (const code of wanted) {
|
||||||
|
const account = glAccount(code);
|
||||||
|
if (!account) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (account.type === 'INCOME' || account.type === 'EXPENSE') {
|
||||||
|
const move = movement.get(code);
|
||||||
|
if (!move) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
total += account.type === 'INCOME' ? move.credit - move.debit : move.debit - move.credit;
|
||||||
|
} else {
|
||||||
|
total += closing.get(code) ?? 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
export function sum<T>(rows: T[], pick: (row: T) => number): number {
|
export function sum<T>(rows: T[], pick: (row: T) => number): number {
|
||||||
return rows.reduce((acc, r) => acc + pick(r), 0);
|
return rows.reduce((acc, r) => acc + pick(r), 0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,11 @@
|
|||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
@if (activeTab === 'holdings') {
|
||||||
|
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="generating" (click)="exportExcel()">
|
||||||
|
{{ generating ? 'Exporting…' : 'Export to Excel' }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -187,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"
|
||||||
@@ -216,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">
|
||||||
@@ -328,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>
|
||||||
@@ -349,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>
|
||||||
@@ -389,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
|
||||||
@@ -549,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
|
||||||
@@ -570,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>
|
||||||
@@ -594,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>
|
||||||
@@ -689,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>
|
||||||
@@ -703,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>
|
||||||
@@ -937,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>
|
||||||
@@ -953,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">
|
||||||
@@ -1038,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
|
||||||
@@ -1064,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>
|
||||||
@@ -1077,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,
|
||||||
@@ -43,6 +48,7 @@ import {
|
|||||||
yearsBetween,
|
yearsBetween,
|
||||||
todayDDMMYYYY
|
todayDDMMYYYY
|
||||||
} from '../investment-data';
|
} from '../investment-data';
|
||||||
|
import { exportWorkbook } from '../investment-export';
|
||||||
|
|
||||||
type BondType = 'GOVT_SECURITY' | 'NON_SLR' | 'BOND' | 'MUTUAL_FUND';
|
type BondType = 'GOVT_SECURITY' | 'NON_SLR' | 'BOND' | 'MUTUAL_FUND';
|
||||||
type BondTab =
|
type BondTab =
|
||||||
@@ -172,7 +178,8 @@ interface BondSellForm {
|
|||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
FormsModule
|
FormsModule,
|
||||||
|
DateDdmmyyyyPickerComponent
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class BondInvestmentComponent {
|
export class BondInvestmentComponent {
|
||||||
@@ -210,6 +217,7 @@ export class BondInvestmentComponent {
|
|||||||
readonly intTypeOptions = INT_TYPE_OPTIONS;
|
readonly intTypeOptions = INT_TYPE_OPTIONS;
|
||||||
readonly authorityOptions = AUTHORITY_OPTIONS;
|
readonly authorityOptions = AUTHORITY_OPTIONS;
|
||||||
|
|
||||||
|
generating = false;
|
||||||
toastMsg = '';
|
toastMsg = '';
|
||||||
toastVisible = false;
|
toastVisible = false;
|
||||||
private toastTimer: ReturnType<typeof setTimeout> | undefined;
|
private toastTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
@@ -222,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 = '';
|
||||||
@@ -645,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;
|
||||||
@@ -659,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 --------------------
|
||||||
@@ -1092,6 +1138,68 @@ export class BondInvestmentComponent {
|
|||||||
return `inv-badge inv-badge--${h.status.toLowerCase()}`;
|
return `inv-badge inv-badge--${h.status.toLowerCase()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------- Export to Excel (Holdings register) --------------------
|
||||||
|
async exportExcel(): Promise<void> {
|
||||||
|
this.generating = true;
|
||||||
|
try {
|
||||||
|
await exportWorkbook(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
sheetName: this.instrumentLabel(this.bondType),
|
||||||
|
title: `${this.instrumentLabel(this.bondType)} Holdings Register`,
|
||||||
|
subtitle: `As on ${this.asOnDate}`,
|
||||||
|
headers: [
|
||||||
|
'Cert No',
|
||||||
|
'Script',
|
||||||
|
'Party',
|
||||||
|
'Units',
|
||||||
|
'Face Value',
|
||||||
|
'Rate',
|
||||||
|
'Purchase Date',
|
||||||
|
'Maturity Date',
|
||||||
|
'Accrued Interest',
|
||||||
|
'Maturity Value',
|
||||||
|
'Status'
|
||||||
|
],
|
||||||
|
widths: [
|
||||||
|
14,
|
||||||
|
26,
|
||||||
|
22,
|
||||||
|
12,
|
||||||
|
16,
|
||||||
|
10,
|
||||||
|
14,
|
||||||
|
14,
|
||||||
|
16,
|
||||||
|
16,
|
||||||
|
12
|
||||||
|
],
|
||||||
|
rows: this.holdingsForType.map((h) => [
|
||||||
|
h.certNo,
|
||||||
|
h.scriptName,
|
||||||
|
h.party,
|
||||||
|
h.units,
|
||||||
|
h.faceValue,
|
||||||
|
h.interestRate,
|
||||||
|
h.purchaseDate,
|
||||||
|
h.maturityDate,
|
||||||
|
this.accrued(h),
|
||||||
|
this.maturity(h),
|
||||||
|
h.status
|
||||||
|
])
|
||||||
|
}
|
||||||
|
],
|
||||||
|
`${this.instrumentLabel(this.bondType)}-Holdings-${this.asOnDate}`
|
||||||
|
);
|
||||||
|
this.toast(`${this.instrumentLabel(this.bondType)} Holdings exported.`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Bond Holdings export failed', err);
|
||||||
|
this.toast('Could not generate the workbook — please retry.');
|
||||||
|
} finally {
|
||||||
|
this.generating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private toast(msg: string): void {
|
private toast(msg: string): void {
|
||||||
this.toastMsg = msg;
|
this.toastMsg = msg;
|
||||||
this.toastVisible = true;
|
this.toastVisible = true;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,13 @@
|
|||||||
Part-Payment entries.
|
Part-Payment entries.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
@if (activeTab === 'holdings') {
|
||||||
|
<div class="pg-toolbar">
|
||||||
|
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="generating" (click)="exportExcel()">
|
||||||
|
{{ generating ? 'Exporting…' : 'Export to Excel' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="cctr-tabs">
|
<div class="cctr-tabs">
|
||||||
@@ -172,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">
|
||||||
@@ -191,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>
|
||||||
@@ -210,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>
|
||||||
@@ -228,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"
|
||||||
@@ -320,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"
|
||||||
@@ -334,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>
|
||||||
}
|
}
|
||||||
@@ -369,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">
|
||||||
@@ -447,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>
|
||||||
@@ -460,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,15 +18,20 @@ 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,
|
||||||
todayDDMMYYYY
|
todayDDMMYYYY
|
||||||
} from '../investment-data';
|
} from '../investment-data';
|
||||||
|
import { exportWorkbook } from '../investment-export';
|
||||||
|
|
||||||
type FdTab = 'holdings' | 'purchase' | 'interest' | 'sell' | 'writeoff' | 'partpayment';
|
type FdTab = 'holdings' | 'purchase' | 'interest' | 'sell' | 'writeoff' | 'partpayment';
|
||||||
|
|
||||||
@@ -82,7 +88,8 @@ interface FdPurchaseForm {
|
|||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
FormsModule
|
FormsModule,
|
||||||
|
DateDdmmyyyyPickerComponent
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class FdInvestmentComponent {
|
export class FdInvestmentComponent {
|
||||||
@@ -97,12 +104,14 @@ 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',
|
||||||
'BRANCH 2'
|
'BRANCH 2'
|
||||||
];
|
];
|
||||||
|
|
||||||
|
generating = false;
|
||||||
toastMsg = '';
|
toastMsg = '';
|
||||||
toastVisible = false;
|
toastVisible = false;
|
||||||
private toastTimer: ReturnType<typeof setTimeout> | undefined;
|
private toastTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
@@ -112,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 = '';
|
||||||
@@ -311,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 {
|
||||||
@@ -326,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 = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -450,6 +489,68 @@ export class FdInvestmentComponent {
|
|||||||
return `inv-badge inv-badge--${h.status.toLowerCase()}`;
|
return `inv-badge inv-badge--${h.status.toLowerCase()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------- Export to Excel (Holdings register) --------------------
|
||||||
|
async exportExcel(): Promise<void> {
|
||||||
|
this.generating = true;
|
||||||
|
try {
|
||||||
|
await exportWorkbook(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
sheetName: 'FD Holdings',
|
||||||
|
title: 'FD Holdings Register',
|
||||||
|
subtitle: `As on ${this.asOnDate}`,
|
||||||
|
headers: [
|
||||||
|
'Cert No',
|
||||||
|
'Party',
|
||||||
|
'Type',
|
||||||
|
'Face Value',
|
||||||
|
'Rate',
|
||||||
|
'Frequency',
|
||||||
|
'Purchase Date',
|
||||||
|
'Maturity Date',
|
||||||
|
'Accrued Interest',
|
||||||
|
'Maturity Value',
|
||||||
|
'Status'
|
||||||
|
],
|
||||||
|
widths: [
|
||||||
|
14,
|
||||||
|
28,
|
||||||
|
18,
|
||||||
|
16,
|
||||||
|
10,
|
||||||
|
14,
|
||||||
|
14,
|
||||||
|
14,
|
||||||
|
16,
|
||||||
|
16,
|
||||||
|
12
|
||||||
|
],
|
||||||
|
rows: this.holdings.map((h) => [
|
||||||
|
h.certNo,
|
||||||
|
h.party,
|
||||||
|
h.interestFreq === 'CUMULATIVE' ? 'Cumulative' : 'Payout',
|
||||||
|
h.faceValue,
|
||||||
|
h.interestRate,
|
||||||
|
h.interestFreq,
|
||||||
|
h.purchaseDate,
|
||||||
|
h.maturityDate,
|
||||||
|
this.accrued(h),
|
||||||
|
this.maturity(h),
|
||||||
|
h.status
|
||||||
|
])
|
||||||
|
}
|
||||||
|
],
|
||||||
|
`FD-Holdings-${this.asOnDate}`
|
||||||
|
);
|
||||||
|
this.toast('FD Holdings exported.');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('FD Holdings export failed', err);
|
||||||
|
this.toast('Could not generate the workbook — please retry.');
|
||||||
|
} finally {
|
||||||
|
this.generating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private toast(msg: string): void {
|
private toast(msg: string): void {
|
||||||
this.toastMsg = msg;
|
this.toastMsg = msg;
|
||||||
this.toastVisible = true;
|
this.toastVisible = true;
|
||||||
|
|||||||
@@ -16,6 +16,11 @@
|
|||||||
{{ asOnDate }}.
|
{{ asOnDate }}.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="pg-toolbar">
|
||||||
|
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="generating" (click)="exportExcel()">
|
||||||
|
{{ generating ? 'Exporting…' : 'Export to Excel' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="inv-kpis">
|
<div class="inv-kpis">
|
||||||
@@ -78,6 +83,69 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="pg-card">
|
||||||
|
<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">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Instrument Type</th>
|
||||||
|
<th>Target Basis</th>
|
||||||
|
<th class="num">Target %</th>
|
||||||
|
<th class="num">Actual %</th>
|
||||||
|
<th class="num">Actual Value</th>
|
||||||
|
<th class="num">Variance</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (a of allocationVsTarget; track a.type) {
|
||||||
|
<tr>
|
||||||
|
<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.actualPct }}%</td>
|
||||||
|
<td class="num">₹{{ a.actualValue | number: '1.2-2' }}</td>
|
||||||
|
<td
|
||||||
|
class="num"
|
||||||
|
[class.col-out]="a.statusLabel !== 'Within Target'"
|
||||||
|
[class.col-in]="a.statusLabel === 'Within Target'"
|
||||||
|
>
|
||||||
|
{{ a.variancePct > 0 ? '+' : '' }}{{ a.variancePct }}%
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
class="inv-badge"
|
||||||
|
[class.inv-badge--active]="a.statusLabel === 'Within Target'"
|
||||||
|
[class.inv-badge--sold]="a.statusLabel === 'Over-Allocated'"
|
||||||
|
[class.inv-badge--written_off]="a.statusLabel === 'Under-Allocated'"
|
||||||
|
>
|
||||||
|
{{ a.statusLabel }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="cctr-total-row">
|
||||||
|
<td>Total</td>
|
||||||
|
<td class="num">100%</td>
|
||||||
|
<td class="num">100%</td>
|
||||||
|
<td class="num">₹{{ totalFaceValue | number: '1.2-2' }}</td>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="pg-card">
|
<div class="pg-card">
|
||||||
<div class="pos-section-label">Due-Date Diary — Maturing Within 90 Days</div>
|
<div class="pos-section-label">Due-Date Diary — Maturing Within 90 Days</div>
|
||||||
@if (maturingSoon.length === 0) {
|
@if (maturingSoon.length === 0) {
|
||||||
@@ -144,3 +212,5 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="inv-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
|
||||||
|
|||||||
@@ -11,13 +11,16 @@ 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,
|
||||||
|
TARGET_ALLOCATION_PCT,
|
||||||
accruedInterest,
|
accruedInterest,
|
||||||
daysToMaturity,
|
daysToMaturity,
|
||||||
maturityAmount
|
maturityAmount
|
||||||
} from '../investment-data';
|
} from '../investment-data';
|
||||||
|
import { exportWorkbook } from '../investment-export';
|
||||||
|
|
||||||
interface InstrumentSummary {
|
interface InstrumentSummary {
|
||||||
type: InstrumentType;
|
type: InstrumentType;
|
||||||
@@ -29,6 +32,19 @@ interface InstrumentSummary {
|
|||||||
maturityValue: number;
|
maturityValue: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AllocationRow {
|
||||||
|
type: InstrumentType;
|
||||||
|
label: string;
|
||||||
|
targetPct: number;
|
||||||
|
actualPct: number;
|
||||||
|
actualValue: number;
|
||||||
|
variancePct: number;
|
||||||
|
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
|
||||||
* hand-duplicated, so it can never drift from the per-instrument screens or Reports. */
|
* hand-duplicated, so it can never drift from the per-instrument screens or Reports. */
|
||||||
@Component({
|
@Component({
|
||||||
@@ -46,6 +62,10 @@ interface InstrumentSummary {
|
|||||||
})
|
})
|
||||||
export class InvestmentDashboardComponent {
|
export class InvestmentDashboardComponent {
|
||||||
asOnDate = '';
|
asOnDate = '';
|
||||||
|
generating = false;
|
||||||
|
toastMsg = '';
|
||||||
|
toastVisible = false;
|
||||||
|
private toastTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
private readonly types: Array<{ type: InstrumentType; route: string }> = [
|
private readonly types: Array<{ type: InstrumentType; route: string }> = [
|
||||||
{ type: 'FD', route: '/investment/fd' },
|
{ type: 'FD', route: '/investment/fd' },
|
||||||
{ type: 'GOVT_SECURITY', route: '/investment/bonds' },
|
{ type: 'GOVT_SECURITY', route: '/investment/bonds' },
|
||||||
@@ -79,6 +99,31 @@ export class InvestmentDashboardComponent {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Board-approved policy target % per instrument type vs the portfolio's actual allocation
|
||||||
|
* (by invested/face value) — a ±2 percentage-point band is treated as "Within Target" since
|
||||||
|
* a treasury desk can't rebalance to the exact decimal every day. */
|
||||||
|
get allocationVsTarget(): AllocationRow[] {
|
||||||
|
const total = this.totalFaceValue;
|
||||||
|
return this.types.map(({ type }) => {
|
||||||
|
const value = this.holdingsOf(type).reduce((s, h) => s + h.faceValue, 0);
|
||||||
|
const actualPct = total ? (value / total) * 100 : 0;
|
||||||
|
const targetPct = TARGET_ALLOCATION_PCT[type];
|
||||||
|
const variancePct = +(actualPct - targetPct).toFixed(1);
|
||||||
|
const statusLabel: AllocationRow['statusLabel'] =
|
||||||
|
Math.abs(variancePct) <= 2 ? 'Within Target' : variancePct > 0 ? 'Over-Allocated' : 'Under-Allocated';
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
label: INSTRUMENT_LABELS[type],
|
||||||
|
targetPct,
|
||||||
|
actualPct: +actualPct.toFixed(1),
|
||||||
|
actualValue: value,
|
||||||
|
variancePct,
|
||||||
|
statusLabel,
|
||||||
|
isRbiLinked: ALLOCATION_TARGET_IS_RBI_LINKED[type]
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
get totalHoldings(): number {
|
get totalHoldings(): number {
|
||||||
return ALL_HOLDINGS.length;
|
return ALL_HOLDINGS.length;
|
||||||
}
|
}
|
||||||
@@ -117,4 +162,88 @@ export class InvestmentDashboardComponent {
|
|||||||
instrumentLabel(type: InstrumentType): string {
|
instrumentLabel(type: InstrumentType): string {
|
||||||
return INSTRUMENT_LABELS[type];
|
return INSTRUMENT_LABELS[type];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async exportExcel(): Promise<void> {
|
||||||
|
this.generating = true;
|
||||||
|
try {
|
||||||
|
await exportWorkbook(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
sheetName: 'Instrument Breakup',
|
||||||
|
title: 'Investment Portfolio — Instrument-wise Break-up',
|
||||||
|
subtitle: `As on ${this.asOnDate}`,
|
||||||
|
headers: [
|
||||||
|
'Instrument Type',
|
||||||
|
'Holdings',
|
||||||
|
'Face Value',
|
||||||
|
'Accrued Interest',
|
||||||
|
'Maturity Value'
|
||||||
|
],
|
||||||
|
widths: [
|
||||||
|
26,
|
||||||
|
12,
|
||||||
|
18,
|
||||||
|
18,
|
||||||
|
18
|
||||||
|
],
|
||||||
|
rows: this.summaries.map((s) => [
|
||||||
|
s.label,
|
||||||
|
s.count,
|
||||||
|
s.faceValue,
|
||||||
|
s.accruedInterest,
|
||||||
|
s.maturityValue
|
||||||
|
])
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sheetName: 'Portfolio Allocation',
|
||||||
|
title: 'Portfolio Allocation — Target vs Actual',
|
||||||
|
subtitle: `As on ${this.asOnDate}`,
|
||||||
|
headers: [
|
||||||
|
'Instrument Type',
|
||||||
|
'Target Basis',
|
||||||
|
'Target %',
|
||||||
|
'Actual %',
|
||||||
|
'Actual Value',
|
||||||
|
'Variance %',
|
||||||
|
'Status'
|
||||||
|
],
|
||||||
|
widths: [
|
||||||
|
26,
|
||||||
|
20,
|
||||||
|
12,
|
||||||
|
12,
|
||||||
|
18,
|
||||||
|
12,
|
||||||
|
18
|
||||||
|
],
|
||||||
|
rows: this.allocationVsTarget.map((a) => [
|
||||||
|
a.label,
|
||||||
|
a.isRbiLinked ? 'RBI SLR / Non-SLR Norm' : 'Board Policy',
|
||||||
|
a.targetPct,
|
||||||
|
a.actualPct,
|
||||||
|
a.actualValue,
|
||||||
|
a.variancePct,
|
||||||
|
a.statusLabel
|
||||||
|
])
|
||||||
|
}
|
||||||
|
],
|
||||||
|
`Investment-Dashboard-${this.asOnDate}`
|
||||||
|
);
|
||||||
|
this.toast('Investment Dashboard exported.');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Investment Dashboard export failed', err);
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export type InterestFreq = 'MONTHLY' | 'QUARTERLY' | 'HALF_YEARLY' | 'YEARLY' |
|
|||||||
export type TxnType =
|
export type TxnType =
|
||||||
| 'PURCHASE'
|
| 'PURCHASE'
|
||||||
| 'INTEREST'
|
| 'INTEREST'
|
||||||
|
| 'INTEREST_RECEIVED'
|
||||||
| 'DIVIDEND'
|
| 'DIVIDEND'
|
||||||
| 'SELL'
|
| 'SELL'
|
||||||
| 'REDEEM'
|
| 'REDEEM'
|
||||||
@@ -38,6 +39,61 @@ export const INSTRUMENT_LABELS: Record<InstrumentType, string> = {
|
|||||||
SHARE: 'Share'
|
SHARE: 'Share'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** RBI investment-provisioning classification. A holding with no `provisionStatus` (or
|
||||||
|
* explicitly 'STANDARD') is performing normally. Once classified Sub-Standard/Doubtful/Loss —
|
||||||
|
* a Non-Performing Investment (NPI), the loan-book equivalent of an NPA — interest can no
|
||||||
|
* longer be recognised on accrual basis (see canAccrueInterest()) and a provision must be held
|
||||||
|
* against the investment (see provisionRequiredInv()). */
|
||||||
|
export type InvestmentProvisionStatus = 'STANDARD' | 'SUB_STANDARD' | 'DOUBTFUL' | 'LOSS';
|
||||||
|
|
||||||
|
export const PROVISION_STATUS_LABELS: Record<InvestmentProvisionStatus, string> = {
|
||||||
|
STANDARD: 'Standard',
|
||||||
|
SUB_STANDARD: 'Sub-Standard (NPI)',
|
||||||
|
DOUBTFUL: 'Doubtful (NPI)',
|
||||||
|
LOSS: 'Loss (NPI)'
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 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> = {
|
||||||
|
GOVT_SECURITY: 55,
|
||||||
|
BOND: 18,
|
||||||
|
FD: 15,
|
||||||
|
NON_SLR: 7,
|
||||||
|
MUTUAL_FUND: 3,
|
||||||
|
SHARE: 2
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 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 {
|
||||||
certNo: string;
|
certNo: string;
|
||||||
instrumentType: InstrumentType;
|
instrumentType: InstrumentType;
|
||||||
@@ -57,6 +113,16 @@ export interface InvestmentHolding {
|
|||||||
crToAc: string;
|
crToAc: string;
|
||||||
brokerCode: string;
|
brokerCode: string;
|
||||||
status: HoldingStatus;
|
status: HoldingStatus;
|
||||||
|
/** Absent/'STANDARD' for a healthy holding. Drives canAccrueInterest() and provisioning. */
|
||||||
|
provisionStatus?: InvestmentProvisionStatus;
|
||||||
|
/** Cumulative interest posted (booked as an accrual receivable/income entry) to date. */
|
||||||
|
interestPosted?: number;
|
||||||
|
/** Cumulative interest actually received in cash to date. */
|
||||||
|
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 {
|
||||||
@@ -67,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 --------------------
|
||||||
@@ -109,6 +179,75 @@ export function daysToMaturity(h: InvestmentHolding, asOnStr: string): number {
|
|||||||
return Math.round((mat - asOn) / (24 * 3600 * 1000));
|
return Math.round((mat - asOn) / (24 * 3600 * 1000));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------- Provisioning & interest-lifecycle helpers --------------------
|
||||||
|
|
||||||
|
export function isNpi(h: InvestmentHolding): boolean {
|
||||||
|
return (h.provisionStatus ?? 'STANDARD') !== 'STANDARD';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function provisionRatePctInv(status: InvestmentProvisionStatus): number {
|
||||||
|
switch (status) {
|
||||||
|
case 'STANDARD':
|
||||||
|
return 0;
|
||||||
|
case 'SUB_STANDARD':
|
||||||
|
return 25;
|
||||||
|
case 'DOUBTFUL':
|
||||||
|
return 50;
|
||||||
|
case 'LOSS':
|
||||||
|
return 100;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Provision required against the investment itself (principal), same idea as NPA provisioning
|
||||||
|
* on a loan book — computed on face value since that is this module's carrying-value figure. */
|
||||||
|
export function provisionRequiredInv(h: InvestmentHolding): number {
|
||||||
|
return Math.round(h.faceValue * (provisionRatePctInv(h.provisionStatus ?? 'STANDARD') / 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Interest can only be recognised on an accrual basis (posted as a receivable/income entry)
|
||||||
|
* while the investment is Standard. Once it is classified Non-Performing (Sub-Standard/
|
||||||
|
* Doubtful/Loss), RBI income-recognition norms require interest to move to cash basis — i.e.
|
||||||
|
* it is recognised only when actually received, never posted as an accrual receivable. */
|
||||||
|
export function canAccrueInterest(h: InvestmentHolding): boolean {
|
||||||
|
return !isNpi(h);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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
|
||||||
|
* posted as a receivable on one. */
|
||||||
|
export function interestReceivableBalance(h: InvestmentHolding): number {
|
||||||
|
if (!canAccrueInterest(h)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return Math.max(0, (h.interestPosted ?? 0) - (h.interestReceived ?? 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** For an NPI holding, interest keeps accruing notionally but is held in memo/suspense — never
|
||||||
|
* posted as an asset or recognised as income — until it is actually collected in cash. */
|
||||||
|
export function suspenseInterest(h: InvestmentHolding, asOnStr: string): number {
|
||||||
|
if (canAccrueInterest(h)) {
|
||||||
|
return 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()}`;
|
||||||
}
|
}
|
||||||
@@ -208,7 +347,9 @@ export const FD_HOLDINGS: InvestmentHolding[] = [
|
|||||||
glName: 'FIXED DEPO. CO-OPER. BANK MAIN',
|
glName: 'FIXED DEPO. CO-OPER. BANK MAIN',
|
||||||
crToAc: '310070',
|
crToAc: '310070',
|
||||||
brokerCode: '',
|
brokerCode: '',
|
||||||
status: 'ACTIVE'
|
status: 'ACTIVE',
|
||||||
|
interestPosted: 42000,
|
||||||
|
interestReceived: 36000
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
certNo: '15624',
|
certNo: '15624',
|
||||||
@@ -268,7 +409,10 @@ export const FD_HOLDINGS: InvestmentHolding[] = [
|
|||||||
glName: 'FIXED DEPO. CO-OPER. BANK MAIN',
|
glName: 'FIXED DEPO. CO-OPER. BANK MAIN',
|
||||||
crToAc: '310070',
|
crToAc: '310070',
|
||||||
brokerCode: '',
|
brokerCode: '',
|
||||||
status: 'ACTIVE'
|
status: 'ACTIVE',
|
||||||
|
provisionStatus: 'DOUBTFUL',
|
||||||
|
interestPosted: 115500,
|
||||||
|
interestReceived: 50000
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
certNo: '15627',
|
certNo: '15627',
|
||||||
@@ -289,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'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -311,7 +517,9 @@ export const GOVT_SECURITY_HOLDINGS: InvestmentHolding[] = [
|
|||||||
glName: 'INVESTMENT IN GOVT. SECURITIES',
|
glName: 'INVESTMENT IN GOVT. SECURITIES',
|
||||||
crToAc: '310070',
|
crToAc: '310070',
|
||||||
brokerCode: 'BRK-002',
|
brokerCode: 'BRK-002',
|
||||||
status: 'ACTIVE'
|
status: 'ACTIVE',
|
||||||
|
interestPosted: 181500,
|
||||||
|
interestReceived: 150000
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
certNo: 'GSEC-2002',
|
certNo: 'GSEC-2002',
|
||||||
@@ -351,7 +559,9 @@ export const GOVT_SECURITY_HOLDINGS: InvestmentHolding[] = [
|
|||||||
glName: 'INVESTMENT IN GOVT. SECURITIES',
|
glName: 'INVESTMENT IN GOVT. SECURITIES',
|
||||||
crToAc: '310070',
|
crToAc: '310070',
|
||||||
brokerCode: 'BRK-002',
|
brokerCode: 'BRK-002',
|
||||||
status: 'ACTIVE'
|
status: 'ACTIVE',
|
||||||
|
interestPosted: 67900,
|
||||||
|
interestReceived: 67900
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
certNo: 'GSEC-2004',
|
certNo: 'GSEC-2004',
|
||||||
@@ -412,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'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -514,6 +764,48 @@ export const NON_SLR_HOLDINGS: InvestmentHolding[] = [
|
|||||||
glName: 'INVESTMENT IN NON SLR SECURITIES',
|
glName: 'INVESTMENT IN NON SLR SECURITIES',
|
||||||
crToAc: '310070',
|
crToAc: '310070',
|
||||||
brokerCode: 'BRK-003',
|
brokerCode: 'BRK-003',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
interestPosted: 89400,
|
||||||
|
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'
|
status: 'ACTIVE'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
@@ -597,7 +889,10 @@ export const BOND_HOLDINGS: InvestmentHolding[] = [
|
|||||||
glName: 'INVESTMENT IN CORPORATE BONDS',
|
glName: 'INVESTMENT IN CORPORATE BONDS',
|
||||||
crToAc: '310070',
|
crToAc: '310070',
|
||||||
brokerCode: 'BRK-006',
|
brokerCode: 'BRK-006',
|
||||||
status: 'ACTIVE'
|
status: 'ACTIVE',
|
||||||
|
provisionStatus: 'SUB_STANDARD',
|
||||||
|
interestPosted: 66000,
|
||||||
|
interestReceived: 40000
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
certNo: 'BOND-4005',
|
certNo: 'BOND-4005',
|
||||||
@@ -618,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'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -721,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'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -824,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'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -952,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;
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Shared "Export to Excel" builder for every listing/report screen in the Investment module
|
||||||
|
// (Dashboard, Reports, Interest, Holdings, Masters) — centralised so the ExcelJS boilerplate
|
||||||
|
// (title/meta/header rows, number formatting, safe sheet names, Blob download) is written once
|
||||||
|
// and every screen's export always behaves and looks the same.
|
||||||
|
|
||||||
|
import * as ExcelJS from 'exceljs';
|
||||||
|
|
||||||
|
export interface ExportSheetSpec {
|
||||||
|
/** Worksheet tab name — sanitised for Excel's forbidden characters (\ / ? * [ ]) and length. */
|
||||||
|
sheetName: string;
|
||||||
|
/** Bold title row, merged across all columns. */
|
||||||
|
title: string;
|
||||||
|
/** Optional italic subtitle row (e.g. scope/date), merged across all columns. */
|
||||||
|
subtitle?: string;
|
||||||
|
headers: string[];
|
||||||
|
rows: (string | number | null)[][];
|
||||||
|
widths?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Excel worksheet names can't contain \ / ? * [ ] or exceed 31 chars. */
|
||||||
|
function safeSheetName(name: string): string {
|
||||||
|
return name.replace(/[\\/?*[\]]/g, '-').slice(0, 31);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSheet(wb: ExcelJS.Workbook, spec: ExportSheetSpec): void {
|
||||||
|
const sheet = wb.addWorksheet(safeSheetName(spec.sheetName));
|
||||||
|
sheet.columns = (spec.widths || spec.headers.map(() => 18)).map((w) => ({ width: w }));
|
||||||
|
|
||||||
|
const title = sheet.addRow([spec.title]);
|
||||||
|
sheet.mergeCells(title.number, 1, title.number, spec.headers.length);
|
||||||
|
title.font = { bold: true, size: 14, color: { argb: 'FFFFFFFF' } };
|
||||||
|
title.alignment = { horizontal: 'center', vertical: 'middle' };
|
||||||
|
title.height = 24;
|
||||||
|
title.getCell(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF0F4F3C' } };
|
||||||
|
|
||||||
|
if (spec.subtitle) {
|
||||||
|
const meta = sheet.addRow([spec.subtitle]);
|
||||||
|
sheet.mergeCells(meta.number, 1, meta.number, spec.headers.length);
|
||||||
|
meta.font = { italic: true, size: 10 };
|
||||||
|
}
|
||||||
|
sheet.addRow([]);
|
||||||
|
|
||||||
|
const head = sheet.addRow(spec.headers);
|
||||||
|
head.font = { bold: true };
|
||||||
|
head.alignment = { wrapText: true, horizontal: 'center' };
|
||||||
|
head.eachCell((c) => {
|
||||||
|
c.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE2E8F0' } };
|
||||||
|
c.border = { bottom: { style: 'thin' } };
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const r of spec.rows) {
|
||||||
|
const row = sheet.addRow(r);
|
||||||
|
r.forEach((v, idx) => {
|
||||||
|
if (typeof v === 'number') {
|
||||||
|
row.getCell(idx + 1).numFmt = '#,##0.00';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
sheet.views = [{ state: 'frozen', ySplit: spec.subtitle ? 4 : 3 }];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds a (possibly multi-sheet) workbook and triggers a browser download. */
|
||||||
|
export async function exportWorkbook(sheets: ExportSheetSpec[], fileName: string): Promise<void> {
|
||||||
|
const wb = new ExcelJS.Workbook();
|
||||||
|
wb.creator = 'CBSNexora';
|
||||||
|
wb.created = new Date();
|
||||||
|
sheets.forEach((s) => buildSheet(wb, s));
|
||||||
|
|
||||||
|
const buffer = await wb.xlsx.writeBuffer();
|
||||||
|
const blob = new Blob([buffer], {
|
||||||
|
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||||
|
});
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${fileName.replace(/[^\w]+/g, '-')}.xlsx`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
@@ -12,20 +12,33 @@
|
|||||||
<div class="pg-eyebrow">TREASURY · INVESTMENT · INTEREST</div>
|
<div class="pg-eyebrow">TREASURY · INVESTMENT · INTEREST</div>
|
||||||
<h1 class="pg-title">Investment Interest</h1>
|
<h1 class="pg-title">Investment Interest</h1>
|
||||||
<p class="pg-sub">
|
<p class="pg-sub">
|
||||||
FD and Govt./Bond interest calculation batch runs, plus the Government Bond Interest, FD Interest and FD
|
Interest Receivable, FD and Govt./Bond interest calculation-and-post batch runs, Interest Receive, plus the
|
||||||
registers.
|
Government Bond Interest, FD Interest and FD registers.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="pg-toolbar">
|
||||||
|
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="generating" (click)="exportExcel()">
|
||||||
|
{{ generating ? 'Exporting…' : 'Export to Excel' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="cctr-tabs">
|
<div class="cctr-tabs">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--ghost"
|
||||||
|
[class.pg-btn--active]="activeTab === 'receivable'"
|
||||||
|
(click)="setTab('receivable')"
|
||||||
|
>
|
||||||
|
Interest Receivable
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="pg-btn pg-btn--ghost"
|
class="pg-btn pg-btn--ghost"
|
||||||
[class.pg-btn--active]="activeTab === 'fdCalc'"
|
[class.pg-btn--active]="activeTab === 'fdCalc'"
|
||||||
(click)="setTab('fdCalc')"
|
(click)="setTab('fdCalc')"
|
||||||
>
|
>
|
||||||
01 FD Interest Calculation
|
01 FD Interest Calculation & Post
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -33,7 +46,15 @@
|
|||||||
[class.pg-btn--active]="activeTab === 'bondCalc'"
|
[class.pg-btn--active]="activeTab === 'bondCalc'"
|
||||||
(click)="setTab('bondCalc')"
|
(click)="setTab('bondCalc')"
|
||||||
>
|
>
|
||||||
02 Govt./Bond Int. Calculation
|
02 Govt./Bond Int. Calculation & Post
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--ghost"
|
||||||
|
[class.pg-btn--active]="activeTab === 'receive'"
|
||||||
|
(click)="setTab('receive')"
|
||||||
|
>
|
||||||
|
Interest Receive
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -61,14 +82,113 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- INTEREST RECEIVABLE -->
|
||||||
|
@if (activeTab === 'receivable') {
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">
|
||||||
|
Interest Receivable — Posted but Not Yet Collected (₹{{ totalReceivable | number: '1.2-2' }})
|
||||||
|
</div>
|
||||||
|
<table class="pg-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Cert No</th>
|
||||||
|
<th>Script / Party</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th class="num">Interest Posted</th>
|
||||||
|
<th class="num">Interest Received</th>
|
||||||
|
<th class="num col-in">Receivable Balance</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (h of receivableRows; track h.certNo) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span class="pg-chip">{{ h.certNo }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ h.scriptName || h.party }}</td>
|
||||||
|
<td>{{ instrumentLabel(h.instrumentType) }}</td>
|
||||||
|
<td>
|
||||||
|
<span [class]="provisionBadgeClass(h)">{{ provisionLabel(h) }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="num">₹{{ posted(h) | number: '1.2-2' }}</td>
|
||||||
|
<td class="num">₹{{ received(h) | number: '1.2-2' }}</td>
|
||||||
|
<td class="num col-in">₹{{ receivableBalance(h) | number: '1.2-2' }}</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="pg-empty">No outstanding interest receivable.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="cctr-total-row">
|
||||||
|
<td colspan="6">Total Receivable</td>
|
||||||
|
<td class="num">₹{{ totalReceivable | number: '1.2-2' }}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (suspenseRows.length > 0) {
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">
|
||||||
|
Non-Performing Investments — Interest Held in Suspense (₹{{ totalSuspense | number: '1.2-2' }})
|
||||||
|
</div>
|
||||||
|
<p class="pg-sub" style="padding: 0 16px">
|
||||||
|
These holdings are classified Non-Performing (Sub-Standard/Doubtful/Loss). Per RBI income-recognition norms,
|
||||||
|
interest is not posted as an accrual receivable — it accrues in memo only, and is recognised as income solely
|
||||||
|
on actual cash receipt via the Interest Receive tab.
|
||||||
|
</p>
|
||||||
|
<table class="pg-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Cert No</th>
|
||||||
|
<th>Script / Party</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th class="num">Provision Required</th>
|
||||||
|
<th class="num col-out">Interest in Suspense</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (h of suspenseRows; track h.certNo) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span class="pg-chip">{{ h.certNo }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ h.scriptName || h.party }}</td>
|
||||||
|
<td>{{ instrumentLabel(h.instrumentType) }}</td>
|
||||||
|
<td>
|
||||||
|
<span [class]="provisionBadgeClass(h)">{{ provisionLabel(h) }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="num">₹{{ provisionRequired(h) | number: '1.2-2' }}</td>
|
||||||
|
<td class="num col-out">₹{{ suspense(h) | number: '1.2-2' }}</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="cctr-total-row">
|
||||||
|
<td colspan="5">Total Suspense</td>
|
||||||
|
<td class="num">₹{{ totalSuspense | number: '1.2-2' }}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
<!-- 01 FD INTEREST CALCULATION -->
|
<!-- 01 FD INTEREST CALCULATION -->
|
||||||
@if (activeTab === 'fdCalc') {
|
@if (activeTab === 'fdCalc') {
|
||||||
<div class="pg-card">
|
<div class="pg-card">
|
||||||
<div class="pos-section-label">Investment - FD Interest Calculation</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">
|
||||||
@@ -81,13 +201,21 @@
|
|||||||
<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
|
||||||
/></label>
|
/></label>
|
||||||
</div>
|
</div>
|
||||||
<div class="inv-form-actions">
|
<div class="inv-form-actions">
|
||||||
<button type="button" class="pg-btn pg-btn--primary" (click)="processFdInterest()">Process</button>
|
<button type="button" class="pg-btn pg-btn--primary" (click)="processFdInterest()">Calculate & Post</button>
|
||||||
</div>
|
</div>
|
||||||
<table class="pg-table">
|
<table class="pg-table">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -125,20 +253,29 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
|
@if (fdCalcExcludedNpi.length > 0) {
|
||||||
|
<p class="pg-sub" style="padding: 12px 16px 16px">
|
||||||
|
{{ fdCalcExcludedNpi.length }} certificate(s) excluded — classified Non-Performing, interest is not posted on
|
||||||
|
accrual basis (see Interest Receivable tab for suspense amount).
|
||||||
|
</p>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- 02 GOVT./BOND INT. CALCULATION -->
|
<!-- 02 GOVT./BOND INT. CALCULATION -->
|
||||||
@if (activeTab === 'bondCalc') {
|
@if (activeTab === 'bondCalc') {
|
||||||
<div class="pg-card">
|
<div class="pg-card">
|
||||||
<div class="pos-section-label">Investment - Govt./Bond Interest Calculation</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>
|
||||||
@@ -158,13 +295,23 @@
|
|||||||
<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
|
||||||
/></label>
|
/></label>
|
||||||
</div>
|
</div>
|
||||||
<div class="inv-form-actions">
|
<div class="inv-form-actions">
|
||||||
<button type="button" class="pg-btn pg-btn--primary" (click)="processBondInterest()">Process</button>
|
<button type="button" class="pg-btn pg-btn--primary" (click)="processBondInterest()">
|
||||||
|
Calculate & Post
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<table class="pg-table">
|
<table class="pg-table">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -204,6 +351,112 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
|
@if (bondCalcExcludedNpi.length > 0) {
|
||||||
|
<p class="pg-sub" style="padding: 12px 16px 16px">
|
||||||
|
{{ bondCalcExcludedNpi.length }} holding(s) excluded — classified Non-Performing, interest is not posted on
|
||||||
|
accrual basis (see Interest Receivable tab for suspense amount).
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- INTEREST RECEIVE -->
|
||||||
|
@if (activeTab === 'receive') {
|
||||||
|
<div class="pg-card">
|
||||||
|
<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">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Cert No</th>
|
||||||
|
<th>Script / Party</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th class="num">Outstanding</th>
|
||||||
|
<th class="num">Amount to Receive</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (h of receiveTargets; track h.certNo) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span class="pg-chip">{{ h.certNo }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ h.scriptName || h.party }}</td>
|
||||||
|
<td>{{ instrumentLabel(h.instrumentType) }}</td>
|
||||||
|
<td>
|
||||||
|
<span [class]="provisionBadgeClass(h)">{{ provisionLabel(h) }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="num">₹{{ outstandingFor(h) | number: '1.2-2' }}</td>
|
||||||
|
<td class="num">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
[(ngModel)]="receiveDrafts[h.certNo]"
|
||||||
|
[max]="outstandingFor(h)"
|
||||||
|
style="width: 120px; text-align: right"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button type="button" class="pg-btn pg-btn--ghost" (click)="receiveInterest(h)">Receive</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="pg-empty">No interest outstanding for collection.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">Interest Received Log</div>
|
||||||
|
<table class="pg-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Voucher No</th>
|
||||||
|
<th>Cert No</th>
|
||||||
|
<th>Particulars</th>
|
||||||
|
<th>GL Credited</th>
|
||||||
|
<th class="num col-in">Amount Received</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (t of receivedLog; track t.voucherNo) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ t.txnDate }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="pg-chip">{{ t.voucherNo }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ t.certNo }}</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>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="pg-empty">No interest received yet.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="cctr-total-row">
|
||||||
|
<td colspan="5">Total Received</td>
|
||||||
|
<td class="num">₹{{ receivedLogTotal | number: '1.2-2' }}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,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>
|
||||||
@@ -232,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>
|
||||||
@@ -261,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>
|
||||||
@@ -273,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,25 +17,50 @@ 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,
|
||||||
|
InvestmentProvisionStatus,
|
||||||
INSTRUMENT_LABELS,
|
INSTRUMENT_LABELS,
|
||||||
|
PROVISION_STATUS_LABELS,
|
||||||
accruedInterest,
|
accruedInterest,
|
||||||
|
accruedInterestBetween,
|
||||||
|
addPeriod,
|
||||||
daysToMaturity,
|
daysToMaturity,
|
||||||
maturityAmount,
|
maturityAmount,
|
||||||
todayDDMMYYYY
|
todayDDMMYYYY,
|
||||||
|
isNpi,
|
||||||
|
canAccrueInterest,
|
||||||
|
interestReceivableBalance,
|
||||||
|
suspenseInterest,
|
||||||
|
provisionRequiredInv
|
||||||
} from '../investment-data';
|
} from '../investment-data';
|
||||||
|
import { exportWorkbook, ExportSheetSpec } from '../investment-export';
|
||||||
|
|
||||||
type InterestTab = 'fdCalc' | 'bondCalc' | 'bondIntRegister' | 'fdIntRegister' | 'fdRegister';
|
type InterestTab =
|
||||||
|
| 'receivable'
|
||||||
|
| 'fdCalc'
|
||||||
|
| 'bondCalc'
|
||||||
|
| 'receive'
|
||||||
|
| 'bondIntRegister'
|
||||||
|
| 'fdIntRegister'
|
||||||
|
| 'fdRegister';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The legacy "Interest" top-menu for Investment — 5 screens:
|
* The legacy "Interest" top-menu for Investment — plus the full accrual lifecycle: Interest
|
||||||
* 01 FD Interest Calculation, 02 Govt./Bond Int. Calculation (both batch "Process" runs),
|
* Receivable, 01 FD Interest Calculation, 02 Govt./Bond Int. Calculation, Interest Receive,
|
||||||
* 03 Government Bond Int Register, 04 FD Interest Register, 05 FD Register (listings).
|
* 03 Government Bond Int Register, 04 FD Interest Register, 05 FD Register.
|
||||||
*
|
*
|
||||||
* Interest figures are derived from the same holdings used by the Entry screens and Reports,
|
* Interest figures are derived from the same holdings used by the Entry screens and Reports,
|
||||||
* so a register here always agrees with what a Purchase/Interest entry recorded.
|
* so a register here always agrees with what a Purchase/Interest entry recorded.
|
||||||
|
*
|
||||||
|
* Provisioning: a holding classified Non-Performing (Sub-Standard/Doubtful/Loss) can never have
|
||||||
|
* interest calculated/posted on accrual basis (canAccrueInterest() blocks it) — only actual cash
|
||||||
|
* receipts are recognised for it, straight through Interest Receive, matching RBI's income
|
||||||
|
* recognition norms for Non-Performing Investments.
|
||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'mifosx-investment-interest',
|
selector: 'mifosx-investment-interest',
|
||||||
@@ -46,11 +72,12 @@ type InterestTab = 'fdCalc' | 'bondCalc' | 'bondIntRegister' | 'fdIntRegister' |
|
|||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
FormsModule
|
FormsModule,
|
||||||
|
DateDdmmyyyyPickerComponent
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class InvestmentInterestComponent {
|
export class InvestmentInterestComponent {
|
||||||
activeTab: InterestTab = 'fdCalc';
|
activeTab: InterestTab = 'receivable';
|
||||||
asOnDate = todayDDMMYYYY();
|
asOnDate = todayDDMMYYYY();
|
||||||
|
|
||||||
fdHoldings: InvestmentHolding[] = [...FD_HOLDINGS];
|
fdHoldings: InvestmentHolding[] = [...FD_HOLDINGS];
|
||||||
@@ -62,22 +89,35 @@ 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. */
|
||||||
|
receiveDrafts: Record<string, number | null> = {};
|
||||||
|
|
||||||
|
generating = false;
|
||||||
toastMsg = '';
|
toastMsg = '';
|
||||||
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 = '';
|
||||||
@@ -103,55 +143,121 @@ export class InvestmentInterestComponent {
|
|||||||
return daysToMaturity(h, this.asOnDate);
|
return daysToMaturity(h, this.asOnDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get allInterestHoldings(): InvestmentHolding[] {
|
||||||
|
return [
|
||||||
|
...this.fdHoldings,
|
||||||
|
...this.bondHoldings
|
||||||
|
].filter((h) => h.status === 'ACTIVE');
|
||||||
|
}
|
||||||
|
|
||||||
|
isNpiHolding(h: InvestmentHolding): boolean {
|
||||||
|
return isNpi(h);
|
||||||
|
}
|
||||||
|
|
||||||
|
provisionLabel(h: InvestmentHolding): string {
|
||||||
|
return PROVISION_STATUS_LABELS[h.provisionStatus ?? 'STANDARD'];
|
||||||
|
}
|
||||||
|
|
||||||
|
provisionBadgeClass(h: InvestmentHolding): string {
|
||||||
|
const status: InvestmentProvisionStatus = h.provisionStatus ?? 'STANDARD';
|
||||||
|
return `inv-badge inv-badge--${status.toLowerCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
posted(h: InvestmentHolding): number {
|
||||||
|
return h.interestPosted ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
received(h: InvestmentHolding): number {
|
||||||
|
return h.interestReceived ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
receivableBalance(h: InvestmentHolding): number {
|
||||||
|
return interestReceivableBalance(h);
|
||||||
|
}
|
||||||
|
|
||||||
|
suspense(h: InvestmentHolding): number {
|
||||||
|
return suspenseInterest(h, this.asOnDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
provisionRequired(h: InvestmentHolding): number {
|
||||||
|
return provisionRequiredInv(h);
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------- 01 FD Interest Calculation --------------------
|
// -------------------- 01 FD Interest Calculation --------------------
|
||||||
get fdCalcTargets(): InvestmentHolding[] {
|
get fdCalcTargets(): InvestmentHolding[] {
|
||||||
|
const base = this.fdHoldings.filter((h) => h.status === 'ACTIVE' && canAccrueInterest(h));
|
||||||
if (this.fdCalcAll) {
|
if (this.fdCalcAll) {
|
||||||
return this.fdHoldings.filter((h) => h.status === 'ACTIVE');
|
return base;
|
||||||
}
|
}
|
||||||
return this.fdHoldings.filter(
|
return base.filter((h) => !this.fdCalcCertNo || h.certNo === this.fdCalcCertNo);
|
||||||
(h) => h.status === 'ACTIVE' && (!this.fdCalcCertNo || h.certNo === this.fdCalcCertNo)
|
}
|
||||||
);
|
|
||||||
|
/** NPI FD certificates excluded from calculation/posting — interest recognised on cash basis
|
||||||
|
* only, via the Interest Receive tab. */
|
||||||
|
get fdCalcExcludedNpi(): InvestmentHolding[] {
|
||||||
|
return this.fdHoldings.filter((h) => h.status === 'ACTIVE' && isNpi(h));
|
||||||
}
|
}
|
||||||
|
|
||||||
fdInterest(h: InvestmentHolding): number {
|
fdInterest(h: InvestmentHolding): number {
|
||||||
return accruedInterest(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;
|
const targets = this.fdCalcTargets.filter((h) => this.fdInterest(h) > 0);
|
||||||
if (targets.length === 0) {
|
if (targets.length === 0) {
|
||||||
this.toast('No FD holdings match the selected filters.');
|
this.toast('No FD holdings have interest due to be posted for this period.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const newTxns: InvestmentTxn[] = targets.map((h, i) => ({
|
const toDate = this.fdCalcToDate || this.asOnDate;
|
||||||
certNo: h.certNo,
|
const fromDate = this.fdCalcFromDate || toDate;
|
||||||
instrumentType: 'FD',
|
const glCode = this.fdCalcGlCode;
|
||||||
txnType: 'INTEREST',
|
const glName = this.glName(glCode);
|
||||||
voucherNo: `INV/FD-INT/${(this.fdCalcDate || this.asOnDate).replace(/-/g, '')}-${i + 1}`,
|
const newTxns: InvestmentTxn[] = targets.map((h, i) => {
|
||||||
txnDate: this.fdCalcDate || this.asOnDate,
|
const amt = this.fdInterest(h);
|
||||||
amount: this.fdInterest(h),
|
h.interestPosted = this.posted(h) + amt;
|
||||||
particulars: `FD interest calculated - ${h.party}`
|
h.interestPostedTillDate = toDate;
|
||||||
}));
|
return {
|
||||||
|
certNo: h.certNo,
|
||||||
|
instrumentType: 'FD',
|
||||||
|
txnType: 'INTEREST',
|
||||||
|
voucherNo: `INV/FD-INT/${toDate.replace(/-/g, '')}-${i + 1}`,
|
||||||
|
txnDate: toDate,
|
||||||
|
amount: amt,
|
||||||
|
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);
|
||||||
this.txns = [
|
this.txns = [
|
||||||
...newTxns,
|
...newTxns,
|
||||||
...this.txns
|
...this.txns
|
||||||
];
|
];
|
||||||
this.toast(
|
this.toast(
|
||||||
`FD interest processed for ${targets.length} certificate(s). Total ₹${this.fdCalcTotal.toLocaleString('en-IN')}.`
|
`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 --------------------
|
||||||
get bondCalcTargets(): InvestmentHolding[] {
|
get bondCalcTargets(): InvestmentHolding[] {
|
||||||
|
const base = this.bondHoldings.filter((h) => h.status === 'ACTIVE' && canAccrueInterest(h));
|
||||||
if (this.bondCalcAll) {
|
if (this.bondCalcAll) {
|
||||||
return this.bondHoldings.filter((h) => h.status === 'ACTIVE');
|
return base;
|
||||||
}
|
}
|
||||||
return this.bondHoldings.filter((h) => {
|
return base.filter((h) => {
|
||||||
if (h.status !== 'ACTIVE') return false;
|
|
||||||
if (this.bondCalcType && h.instrumentType !== this.bondCalcType) return false;
|
if (this.bondCalcType && h.instrumentType !== this.bondCalcType) return false;
|
||||||
if (this.bondCalcScript && h.scriptCode !== this.bondCalcScript) return false;
|
if (this.bondCalcScript && h.scriptCode !== this.bondCalcScript) return false;
|
||||||
if (this.bondCalcDealNo && h.certNo !== this.bondCalcDealNo.trim()) return false;
|
if (this.bondCalcDealNo && h.certNo !== this.bondCalcDealNo.trim()) return false;
|
||||||
@@ -159,38 +265,142 @@ export class InvestmentInterestComponent {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** NPI Govt/Bond holdings excluded from calculation/posting — interest recognised on cash
|
||||||
|
* basis only, via the Interest Receive tab. */
|
||||||
|
get bondCalcExcludedNpi(): InvestmentHolding[] {
|
||||||
|
return this.bondHoldings.filter((h) => h.status === 'ACTIVE' && isNpi(h));
|
||||||
|
}
|
||||||
|
|
||||||
bondInterest(h: InvestmentHolding): number {
|
bondInterest(h: InvestmentHolding): number {
|
||||||
return accruedInterest(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;
|
const targets = this.bondCalcTargets.filter((h) => this.bondInterest(h) > 0);
|
||||||
if (targets.length === 0) {
|
if (targets.length === 0) {
|
||||||
this.toast('No Govt/Bond holdings match the selected filters.');
|
this.toast('No Govt/Bond holdings have interest due to be posted for this period.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const newTxns: InvestmentTxn[] = targets.map((h, i) => ({
|
const toDate = this.bondCalcToDate || this.asOnDate;
|
||||||
certNo: h.certNo,
|
const fromDate = this.bondCalcFromDate || toDate;
|
||||||
instrumentType: h.instrumentType,
|
const glCode = this.bondCalcGlCode;
|
||||||
txnType: 'INTEREST',
|
const glName = this.glName(glCode);
|
||||||
voucherNo: `INV/BOND-INT/${(this.bondCalcDate || this.asOnDate).replace(/-/g, '')}-${i + 1}`,
|
const newTxns: InvestmentTxn[] = targets.map((h, i) => {
|
||||||
txnDate: this.bondCalcDate || this.asOnDate,
|
const amt = this.bondInterest(h);
|
||||||
amount: this.bondInterest(h),
|
h.interestPosted = this.posted(h) + amt;
|
||||||
particulars: `Coupon/interest calculated - ${h.scriptName}`
|
h.interestPostedTillDate = toDate;
|
||||||
}));
|
return {
|
||||||
|
certNo: h.certNo,
|
||||||
|
instrumentType: h.instrumentType,
|
||||||
|
txnType: 'INTEREST',
|
||||||
|
voucherNo: `INV/BOND-INT/${toDate.replace(/-/g, '')}-${i + 1}`,
|
||||||
|
txnDate: toDate,
|
||||||
|
amount: amt,
|
||||||
|
particulars: `Coupon/interest posted (accrual) for period ${fromDate} to ${toDate} - ${h.scriptName}`,
|
||||||
|
glCode,
|
||||||
|
glName
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const total = newTxns.reduce((s, t) => s + t.amount, 0);
|
||||||
this.txns = [
|
this.txns = [
|
||||||
...newTxns,
|
...newTxns,
|
||||||
...this.txns
|
...this.txns
|
||||||
];
|
];
|
||||||
|
this.bondCalcFromDate = toDate;
|
||||||
this.toast(
|
this.toast(
|
||||||
`Interest processed for ${targets.length} holding(s). Total ₹${this.bondCalcTotal.toLocaleString('en-IN')}.`
|
`Interest posted for ${targets.length} holding(s) to ${glCode} - ${glName}. Total ₹${total.toLocaleString('en-IN')}.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------- Interest Receivable (all interest-bearing types) --------------------
|
||||||
|
/** Standard holdings with a posted-but-uncollected balance — a genuine "Interest Receivable"
|
||||||
|
* GL asset. */
|
||||||
|
get receivableRows(): InvestmentHolding[] {
|
||||||
|
return this.allInterestHoldings.filter((h) => canAccrueInterest(h) && this.receivableBalance(h) > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** NPI holdings with interest still accruing in memo/suspense — not a GL asset, shown
|
||||||
|
* separately so the treasury desk can see what cash-basis income is outstanding. */
|
||||||
|
get suspenseRows(): InvestmentHolding[] {
|
||||||
|
return this.allInterestHoldings.filter((h) => isNpi(h) && this.suspense(h) > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
get totalReceivable(): number {
|
||||||
|
return this.receivableRows.reduce((s, h) => s + this.receivableBalance(h), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
get totalSuspense(): number {
|
||||||
|
return this.suspenseRows.reduce((s, h) => s + this.suspense(h), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------- Interest Receive (cash receipt, all interest-bearing types) --------------------
|
||||||
|
/** Everything with money outstanding to collect — Standard holdings against their posted
|
||||||
|
* receivable, and NPI holdings against their notional suspense (cash-basis recognition). */
|
||||||
|
get receiveTargets(): InvestmentHolding[] {
|
||||||
|
return this.allInterestHoldings.filter((h) =>
|
||||||
|
canAccrueInterest(h) ? this.receivableBalance(h) > 0 : this.suspense(h) > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
outstandingFor(h: InvestmentHolding): number {
|
||||||
|
return canAccrueInterest(h) ? this.receivableBalance(h) : this.suspense(h);
|
||||||
|
}
|
||||||
|
|
||||||
|
get receivedLog(): InvestmentTxn[] {
|
||||||
|
return this.txns.filter((t) => t.txnType === 'INTEREST_RECEIVED');
|
||||||
|
}
|
||||||
|
|
||||||
|
get receivedLogTotal(): number {
|
||||||
|
return this.receivedLog.reduce((s, t) => s + t.amount, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
receiveInterest(h: InvestmentHolding): void {
|
||||||
|
const draft = this.receiveDrafts[h.certNo];
|
||||||
|
if (!draft || draft <= 0) {
|
||||||
|
this.toast('Enter a valid amount to receive.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let amt = draft;
|
||||||
|
let particulars: string;
|
||||||
|
if (canAccrueInterest(h)) {
|
||||||
|
amt = Math.min(draft, this.receivableBalance(h));
|
||||||
|
h.interestReceived = this.received(h) + amt;
|
||||||
|
particulars = `Interest received against posted receivable - ${h.scriptName || h.party}`;
|
||||||
|
} else {
|
||||||
|
h.interestReceived = this.received(h) + amt;
|
||||||
|
particulars = `Interest received on cash basis (${this.provisionLabel(h)}) - ${h.scriptName || h.party}`;
|
||||||
|
}
|
||||||
|
const glCode = this.receiveGlCode;
|
||||||
|
const glName = this.glName(glCode);
|
||||||
|
this.txns = [
|
||||||
|
{
|
||||||
|
certNo: h.certNo,
|
||||||
|
instrumentType: h.instrumentType,
|
||||||
|
txnType: 'INTEREST_RECEIVED',
|
||||||
|
voucherNo: `INV/INT-RCV/${h.certNo}-${this.receivedLog.length + 1}`,
|
||||||
|
txnDate: this.asOnDate,
|
||||||
|
amount: amt,
|
||||||
|
particulars: `${particulars} (Dr. ${glCode} - ${glName})`,
|
||||||
|
glCode,
|
||||||
|
glName
|
||||||
|
},
|
||||||
|
...this.txns
|
||||||
|
];
|
||||||
|
this.toast(`₹${amt.toLocaleString('en-IN')} interest received into ${glCode} - ${glName} for ${h.certNo}.`);
|
||||||
|
delete this.receiveDrafts[h.certNo];
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------- 03 Government Bond Int Register --------------------
|
// -------------------- 03 Government Bond Int Register --------------------
|
||||||
get bondIntRegister(): InvestmentTxn[] {
|
get bondIntRegister(): InvestmentTxn[] {
|
||||||
return this.txns.filter(
|
return this.txns.filter(
|
||||||
@@ -236,6 +446,309 @@ export class InvestmentInterestComponent {
|
|||||||
return this.fdRegister.reduce((s, h) => s + this.accrued(h), 0);
|
return this.fdRegister.reduce((s, h) => s + this.accrued(h), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------- Export to Excel --------------------
|
||||||
|
private txnExportRow(t: InvestmentTxn): (string | number | null)[] {
|
||||||
|
return [
|
||||||
|
t.txnDate,
|
||||||
|
t.voucherNo,
|
||||||
|
t.certNo,
|
||||||
|
t.instrumentType,
|
||||||
|
t.particulars,
|
||||||
|
t.amount,
|
||||||
|
t.glCode ?? '',
|
||||||
|
t.glName ?? ''
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private exportSheets(): ExportSheetSpec[] {
|
||||||
|
const subtitle = `As on ${this.asOnDate}`;
|
||||||
|
const txnHeaders = [
|
||||||
|
'Date',
|
||||||
|
'Voucher No',
|
||||||
|
'Cert No',
|
||||||
|
'Type',
|
||||||
|
'Particulars',
|
||||||
|
'Amount',
|
||||||
|
'GL Code',
|
||||||
|
'GL Name'
|
||||||
|
];
|
||||||
|
const txnWidths = [
|
||||||
|
14,
|
||||||
|
22,
|
||||||
|
14,
|
||||||
|
14,
|
||||||
|
42,
|
||||||
|
16,
|
||||||
|
12,
|
||||||
|
28
|
||||||
|
];
|
||||||
|
const one = (
|
||||||
|
sheetName: string,
|
||||||
|
title: string,
|
||||||
|
headers: string[],
|
||||||
|
widths: number[],
|
||||||
|
rows: (string | number | null)[][]
|
||||||
|
): ExportSheetSpec[] => [
|
||||||
|
{ sheetName, title, subtitle, headers, widths, rows }];
|
||||||
|
|
||||||
|
switch (this.activeTab) {
|
||||||
|
case 'receivable': {
|
||||||
|
const sheets: ExportSheetSpec[] = [
|
||||||
|
{
|
||||||
|
sheetName: 'Interest Receivable',
|
||||||
|
title: 'Interest Receivable — Posted but Not Yet Collected',
|
||||||
|
subtitle,
|
||||||
|
headers: [
|
||||||
|
'Cert No',
|
||||||
|
'Script / Party',
|
||||||
|
'Type',
|
||||||
|
'Status',
|
||||||
|
'Interest Posted',
|
||||||
|
'Interest Received',
|
||||||
|
'Receivable Balance'
|
||||||
|
],
|
||||||
|
widths: [
|
||||||
|
14,
|
||||||
|
30,
|
||||||
|
16,
|
||||||
|
16,
|
||||||
|
16,
|
||||||
|
16,
|
||||||
|
18
|
||||||
|
],
|
||||||
|
rows: this.receivableRows.map((h) => [
|
||||||
|
h.certNo,
|
||||||
|
h.scriptName || h.party,
|
||||||
|
this.instrumentLabel(h.instrumentType),
|
||||||
|
this.provisionLabel(h),
|
||||||
|
this.posted(h),
|
||||||
|
this.received(h),
|
||||||
|
this.receivableBalance(h)
|
||||||
|
])
|
||||||
|
}
|
||||||
|
];
|
||||||
|
if (this.suspenseRows.length > 0) {
|
||||||
|
sheets.push({
|
||||||
|
sheetName: 'NPI Suspense',
|
||||||
|
title: 'Non-Performing Investments — Interest Held in Suspense',
|
||||||
|
subtitle,
|
||||||
|
headers: [
|
||||||
|
'Cert No',
|
||||||
|
'Script / Party',
|
||||||
|
'Type',
|
||||||
|
'Status',
|
||||||
|
'Provision Required',
|
||||||
|
'Interest in Suspense'
|
||||||
|
],
|
||||||
|
widths: [
|
||||||
|
14,
|
||||||
|
30,
|
||||||
|
16,
|
||||||
|
16,
|
||||||
|
16,
|
||||||
|
18
|
||||||
|
],
|
||||||
|
rows: this.suspenseRows.map((h) => [
|
||||||
|
h.certNo,
|
||||||
|
h.scriptName || h.party,
|
||||||
|
this.instrumentLabel(h.instrumentType),
|
||||||
|
this.provisionLabel(h),
|
||||||
|
this.provisionRequired(h),
|
||||||
|
this.suspense(h)
|
||||||
|
])
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return sheets;
|
||||||
|
}
|
||||||
|
case 'fdCalc':
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
sheetName: 'FD Interest Calc',
|
||||||
|
title: 'FD Interest Calculation & Post',
|
||||||
|
subtitle: `Period ${this.fdCalcFromDate || '—'} to ${this.fdCalcToDate || this.asOnDate} · Post to ${this.fdCalcGlCode} - ${this.glName(this.fdCalcGlCode)}`,
|
||||||
|
headers: [
|
||||||
|
'Cert No',
|
||||||
|
'Party',
|
||||||
|
'Face Value',
|
||||||
|
'Rate',
|
||||||
|
'Frequency',
|
||||||
|
'Interest'
|
||||||
|
],
|
||||||
|
widths: [
|
||||||
|
14,
|
||||||
|
30,
|
||||||
|
16,
|
||||||
|
10,
|
||||||
|
14,
|
||||||
|
16
|
||||||
|
],
|
||||||
|
rows: this.fdCalcTargets.map((h) => [
|
||||||
|
h.certNo,
|
||||||
|
h.party,
|
||||||
|
h.faceValue,
|
||||||
|
h.interestRate,
|
||||||
|
h.interestFreq,
|
||||||
|
this.fdInterest(h)
|
||||||
|
])
|
||||||
|
}
|
||||||
|
];
|
||||||
|
case 'bondCalc':
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
sheetName: 'Bond Interest Calc',
|
||||||
|
title: 'Govt./Bond Interest Calculation & Post',
|
||||||
|
subtitle: `Period ${this.bondCalcFromDate || '—'} to ${this.bondCalcToDate || this.asOnDate} · Post to ${this.bondCalcGlCode} - ${this.glName(this.bondCalcGlCode)}`,
|
||||||
|
headers: [
|
||||||
|
'Cert No',
|
||||||
|
'Script',
|
||||||
|
'Type',
|
||||||
|
'Face Value',
|
||||||
|
'Rate',
|
||||||
|
'Frequency',
|
||||||
|
'Interest'
|
||||||
|
],
|
||||||
|
widths: [
|
||||||
|
14,
|
||||||
|
30,
|
||||||
|
16,
|
||||||
|
16,
|
||||||
|
10,
|
||||||
|
14,
|
||||||
|
16
|
||||||
|
],
|
||||||
|
rows: this.bondCalcTargets.map((h) => [
|
||||||
|
h.certNo,
|
||||||
|
h.scriptName,
|
||||||
|
this.instrumentLabel(h.instrumentType),
|
||||||
|
h.faceValue,
|
||||||
|
h.interestRate,
|
||||||
|
h.interestFreq,
|
||||||
|
this.bondInterest(h)
|
||||||
|
])
|
||||||
|
}
|
||||||
|
];
|
||||||
|
case 'receive': {
|
||||||
|
const sheets: ExportSheetSpec[] = [
|
||||||
|
{
|
||||||
|
sheetName: 'Receive Targets',
|
||||||
|
title: 'Interest Receive — Outstanding for Collection',
|
||||||
|
subtitle: `${subtitle} · Receive into ${this.receiveGlCode} - ${this.glName(this.receiveGlCode)}`,
|
||||||
|
headers: [
|
||||||
|
'Cert No',
|
||||||
|
'Script / Party',
|
||||||
|
'Type',
|
||||||
|
'Status',
|
||||||
|
'Outstanding'
|
||||||
|
],
|
||||||
|
widths: [
|
||||||
|
14,
|
||||||
|
30,
|
||||||
|
16,
|
||||||
|
16,
|
||||||
|
16
|
||||||
|
],
|
||||||
|
rows: this.receiveTargets.map((h) => [
|
||||||
|
h.certNo,
|
||||||
|
h.scriptName || h.party,
|
||||||
|
this.instrumentLabel(h.instrumentType),
|
||||||
|
this.provisionLabel(h),
|
||||||
|
this.outstandingFor(h)
|
||||||
|
])
|
||||||
|
}
|
||||||
|
];
|
||||||
|
if (this.receivedLog.length > 0) {
|
||||||
|
sheets.push({
|
||||||
|
sheetName: 'Received Log',
|
||||||
|
title: 'Interest Received Log',
|
||||||
|
subtitle,
|
||||||
|
headers: txnHeaders,
|
||||||
|
widths: txnWidths,
|
||||||
|
rows: this.receivedLog.map((t) => this.txnExportRow(t))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return sheets;
|
||||||
|
}
|
||||||
|
case 'bondIntRegister':
|
||||||
|
return one(
|
||||||
|
'Govt Bond Int Register',
|
||||||
|
'Government Bond Interest Register',
|
||||||
|
txnHeaders,
|
||||||
|
txnWidths,
|
||||||
|
this.bondIntRegister.map((t) => this.txnExportRow(t))
|
||||||
|
);
|
||||||
|
case 'fdIntRegister':
|
||||||
|
return one(
|
||||||
|
'FD Interest Register',
|
||||||
|
'FD Interest Register',
|
||||||
|
txnHeaders,
|
||||||
|
txnWidths,
|
||||||
|
this.fdIntRegister.map((t) => this.txnExportRow(t))
|
||||||
|
);
|
||||||
|
case 'fdRegister':
|
||||||
|
return one(
|
||||||
|
'FD Register',
|
||||||
|
'FD Register',
|
||||||
|
[
|
||||||
|
'Cert No',
|
||||||
|
'Party',
|
||||||
|
'Sub Type',
|
||||||
|
'Face Value',
|
||||||
|
'Rate',
|
||||||
|
'Frequency',
|
||||||
|
'Deposit Date',
|
||||||
|
'Maturity Date',
|
||||||
|
'Days Left',
|
||||||
|
'Accrued Interest',
|
||||||
|
'Maturity Value',
|
||||||
|
'Status'
|
||||||
|
],
|
||||||
|
[
|
||||||
|
14,
|
||||||
|
26,
|
||||||
|
16,
|
||||||
|
16,
|
||||||
|
10,
|
||||||
|
14,
|
||||||
|
14,
|
||||||
|
14,
|
||||||
|
12,
|
||||||
|
16,
|
||||||
|
16,
|
||||||
|
12
|
||||||
|
],
|
||||||
|
this.fdRegister.map((h) => [
|
||||||
|
h.certNo,
|
||||||
|
h.party,
|
||||||
|
h.subType,
|
||||||
|
h.faceValue,
|
||||||
|
h.interestRate,
|
||||||
|
h.interestFreq,
|
||||||
|
h.purchaseDate,
|
||||||
|
h.maturityDate,
|
||||||
|
this.daysLeft(h),
|
||||||
|
this.accrued(h),
|
||||||
|
this.maturity(h),
|
||||||
|
h.status
|
||||||
|
])
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return one('Interest', 'Investment Interest', [], [], []);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async exportExcel(): Promise<void> {
|
||||||
|
this.generating = true;
|
||||||
|
try {
|
||||||
|
await exportWorkbook(this.exportSheets(), `Investment-Interest-${this.activeTab}-${this.asOnDate}`);
|
||||||
|
this.toast('Exported.');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Investment Interest export failed', err);
|
||||||
|
this.toast('Could not generate the workbook — please retry.');
|
||||||
|
} finally {
|
||||||
|
this.generating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private toast(msg: string): void {
|
private toast(msg: string): void {
|
||||||
this.toastMsg = msg;
|
this.toastMsg = msg;
|
||||||
this.toastVisible = true;
|
this.toastVisible = true;
|
||||||
|
|||||||
@@ -16,6 +16,11 @@
|
|||||||
Market Detail masters.
|
Market Detail masters.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="pg-toolbar">
|
||||||
|
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="generating" (click)="exportExcel()">
|
||||||
|
{{ generating ? 'Exporting…' : 'Export to Excel' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="cctr-tabs">
|
<div class="cctr-tabs">
|
||||||
@@ -262,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>
|
||||||
@@ -344,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,
|
||||||
@@ -32,6 +33,7 @@ import {
|
|||||||
LienReason,
|
LienReason,
|
||||||
InstrumentType
|
InstrumentType
|
||||||
} from '../investment-data';
|
} from '../investment-data';
|
||||||
|
import { exportWorkbook, ExportSheetSpec } from '../investment-export';
|
||||||
|
|
||||||
type MasterTab =
|
type MasterTab =
|
||||||
| 'party'
|
| 'party'
|
||||||
@@ -59,7 +61,8 @@ type MasterTab =
|
|||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
FormsModule
|
FormsModule,
|
||||||
|
DateDdmmyyyyPickerComponent
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class InvestmentMastersComponent {
|
export class InvestmentMastersComponent {
|
||||||
@@ -85,6 +88,20 @@ export class InvestmentMastersComponent {
|
|||||||
'SHARE'
|
'SHARE'
|
||||||
];
|
];
|
||||||
|
|
||||||
|
private readonly masterLabels: Record<MasterTab, string> = {
|
||||||
|
party: 'Bank / Party Master',
|
||||||
|
script: 'Script Master',
|
||||||
|
subtype: 'SubType Master',
|
||||||
|
broker: 'Broker Master',
|
||||||
|
control: 'Control Parameter',
|
||||||
|
dailyscript: 'Daily Script Master',
|
||||||
|
lien: 'LIEN Reason Master',
|
||||||
|
mfproduct: 'Mutual Fund Product Master',
|
||||||
|
sglbank: 'SGL Bank Master',
|
||||||
|
marketdetail: 'Script Market Detail'
|
||||||
|
};
|
||||||
|
|
||||||
|
generating = false;
|
||||||
toastMsg = '';
|
toastMsg = '';
|
||||||
toastVisible = false;
|
toastVisible = false;
|
||||||
private toastTimer: ReturnType<typeof setTimeout> | undefined;
|
private toastTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
@@ -251,6 +268,244 @@ export class InvestmentMastersComponent {
|
|||||||
this.toast('Daily script rate added.');
|
this.toast('Daily script rate added.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------- Export to Excel --------------------
|
||||||
|
private exportSheets(): ExportSheetSpec[] {
|
||||||
|
const title = this.masterLabels[this.activeTab];
|
||||||
|
const one = (headers: string[], widths: number[], rows: (string | number | null)[][]): ExportSheetSpec[] => [
|
||||||
|
{ sheetName: title, title, headers, widths, rows }];
|
||||||
|
|
||||||
|
switch (this.activeTab) {
|
||||||
|
case 'party':
|
||||||
|
return one(
|
||||||
|
[
|
||||||
|
'Party Code',
|
||||||
|
'Party Name',
|
||||||
|
'Type',
|
||||||
|
'PAN No'
|
||||||
|
],
|
||||||
|
[
|
||||||
|
12,
|
||||||
|
30,
|
||||||
|
16,
|
||||||
|
14
|
||||||
|
],
|
||||||
|
this.parties.map((p) => [
|
||||||
|
p.partyCode,
|
||||||
|
p.partyName,
|
||||||
|
p.partyType,
|
||||||
|
p.panNo
|
||||||
|
])
|
||||||
|
);
|
||||||
|
case 'script':
|
||||||
|
return one(
|
||||||
|
[
|
||||||
|
'Script Code',
|
||||||
|
'Script Name',
|
||||||
|
'Instrument',
|
||||||
|
'ISIN',
|
||||||
|
'Face Value/Unit'
|
||||||
|
],
|
||||||
|
[
|
||||||
|
16,
|
||||||
|
30,
|
||||||
|
16,
|
||||||
|
16,
|
||||||
|
16
|
||||||
|
],
|
||||||
|
this.scripts.map((s) => [
|
||||||
|
s.scriptCode,
|
||||||
|
s.scriptName,
|
||||||
|
s.instrumentType,
|
||||||
|
s.isin,
|
||||||
|
s.faceValuePerUnit
|
||||||
|
])
|
||||||
|
);
|
||||||
|
case 'subtype':
|
||||||
|
return one(
|
||||||
|
[
|
||||||
|
'Code',
|
||||||
|
'Sub Type',
|
||||||
|
'Instrument'
|
||||||
|
],
|
||||||
|
[
|
||||||
|
10,
|
||||||
|
24,
|
||||||
|
16
|
||||||
|
],
|
||||||
|
this.subTypes.map((s) => [
|
||||||
|
s.subTypeCode,
|
||||||
|
s.subTypeName,
|
||||||
|
s.instrumentType
|
||||||
|
])
|
||||||
|
);
|
||||||
|
case 'broker':
|
||||||
|
return one(
|
||||||
|
[
|
||||||
|
'Broker Code',
|
||||||
|
'Broker Name',
|
||||||
|
'Contact',
|
||||||
|
'Empanelled On',
|
||||||
|
'Status'
|
||||||
|
],
|
||||||
|
[
|
||||||
|
14,
|
||||||
|
30,
|
||||||
|
16,
|
||||||
|
16,
|
||||||
|
12
|
||||||
|
],
|
||||||
|
this.brokers.map((b) => [
|
||||||
|
b.brokerCode,
|
||||||
|
b.brokerName,
|
||||||
|
b.contactNo,
|
||||||
|
b.empanelledOn,
|
||||||
|
b.status
|
||||||
|
])
|
||||||
|
);
|
||||||
|
case 'control':
|
||||||
|
return one(
|
||||||
|
[
|
||||||
|
'Parameter',
|
||||||
|
'Value',
|
||||||
|
'Description'
|
||||||
|
],
|
||||||
|
[
|
||||||
|
26,
|
||||||
|
14,
|
||||||
|
40
|
||||||
|
],
|
||||||
|
this.controlParams.map((c) => [
|
||||||
|
c.paramName,
|
||||||
|
c.paramValue,
|
||||||
|
c.description
|
||||||
|
])
|
||||||
|
);
|
||||||
|
case 'dailyscript':
|
||||||
|
return one(
|
||||||
|
[
|
||||||
|
'Rate Date',
|
||||||
|
'Script Code',
|
||||||
|
'Script Name',
|
||||||
|
'Open',
|
||||||
|
'High',
|
||||||
|
'Low',
|
||||||
|
'Close'
|
||||||
|
],
|
||||||
|
[
|
||||||
|
14,
|
||||||
|
16,
|
||||||
|
26,
|
||||||
|
12,
|
||||||
|
12,
|
||||||
|
12,
|
||||||
|
12
|
||||||
|
],
|
||||||
|
this.dailyScriptRates.map((r) => [
|
||||||
|
r.rateDate,
|
||||||
|
r.scriptCode,
|
||||||
|
r.scriptName,
|
||||||
|
r.openRate,
|
||||||
|
r.highRate,
|
||||||
|
r.lowRate,
|
||||||
|
r.closeRate
|
||||||
|
])
|
||||||
|
);
|
||||||
|
case 'lien':
|
||||||
|
return one(
|
||||||
|
[
|
||||||
|
'Reason Code',
|
||||||
|
'Description'
|
||||||
|
],
|
||||||
|
[
|
||||||
|
14,
|
||||||
|
40
|
||||||
|
],
|
||||||
|
this.lienReasons.map((l) => [
|
||||||
|
l.reasonCode,
|
||||||
|
l.reasonDescription
|
||||||
|
])
|
||||||
|
);
|
||||||
|
case 'mfproduct':
|
||||||
|
return one(
|
||||||
|
[
|
||||||
|
'Product Code',
|
||||||
|
'Product Name',
|
||||||
|
'AMC',
|
||||||
|
'Category',
|
||||||
|
'Risk'
|
||||||
|
],
|
||||||
|
[
|
||||||
|
14,
|
||||||
|
30,
|
||||||
|
24,
|
||||||
|
20,
|
||||||
|
12
|
||||||
|
],
|
||||||
|
this.mfProducts.map((m) => [
|
||||||
|
m.productCode,
|
||||||
|
m.productName,
|
||||||
|
m.amcName,
|
||||||
|
m.category,
|
||||||
|
m.riskLevel
|
||||||
|
])
|
||||||
|
);
|
||||||
|
case 'sglbank':
|
||||||
|
return one(
|
||||||
|
[
|
||||||
|
'SGL Code',
|
||||||
|
'Bank Name',
|
||||||
|
'Branch'
|
||||||
|
],
|
||||||
|
[
|
||||||
|
14,
|
||||||
|
30,
|
||||||
|
24
|
||||||
|
],
|
||||||
|
this.sglBanks.map((s) => [
|
||||||
|
s.sglCode,
|
||||||
|
s.bankName,
|
||||||
|
s.branch
|
||||||
|
])
|
||||||
|
);
|
||||||
|
case 'marketdetail':
|
||||||
|
return one(
|
||||||
|
[
|
||||||
|
'Script Code',
|
||||||
|
'Market Price',
|
||||||
|
'As On',
|
||||||
|
'YTM %'
|
||||||
|
],
|
||||||
|
[
|
||||||
|
16,
|
||||||
|
16,
|
||||||
|
14,
|
||||||
|
12
|
||||||
|
],
|
||||||
|
this.marketDetails.map((m) => [
|
||||||
|
m.scriptCode,
|
||||||
|
m.marketPrice,
|
||||||
|
m.asOnDate,
|
||||||
|
m.ytm
|
||||||
|
])
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return one([], [], []);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async exportExcel(): Promise<void> {
|
||||||
|
this.generating = true;
|
||||||
|
try {
|
||||||
|
await exportWorkbook(this.exportSheets(), `Investment-${this.masterLabels[this.activeTab]}`);
|
||||||
|
this.toast(`${this.masterLabels[this.activeTab]} exported.`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Investment Masters export failed', err);
|
||||||
|
this.toast('Could not generate the workbook — please retry.');
|
||||||
|
} finally {
|
||||||
|
this.generating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private toast(msg: string): void {
|
private toast(msg: string): void {
|
||||||
this.toastMsg = msg;
|
this.toastMsg = msg;
|
||||||
this.toastVisible = true;
|
this.toastVisible = true;
|
||||||
|
|||||||
@@ -16,6 +16,11 @@
|
|||||||
same holdings/transactions used by the Entry screens.
|
same holdings/transactions used by the Entry screens.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="pg-toolbar">
|
||||||
|
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="generating" (click)="exportExcel()">
|
||||||
|
{{ generating ? 'Exporting…' : 'Export to Excel' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="cctr-tabs">
|
<div class="cctr-tabs">
|
||||||
@@ -692,3 +697,5 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="inv-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
daysToMaturity,
|
daysToMaturity,
|
||||||
todayDDMMYYYY
|
todayDDMMYYYY
|
||||||
} from '../investment-data';
|
} from '../investment-data';
|
||||||
|
import { exportWorkbook, ExportSheetSpec } from '../investment-export';
|
||||||
|
|
||||||
type ReportGroup = 'balance' | 'ledger' | 'maturity' | 'portfolio';
|
type ReportGroup = 'balance' | 'ledger' | 'maturity' | 'portfolio';
|
||||||
|
|
||||||
@@ -50,6 +51,11 @@ export class InvestmentReportsComponent {
|
|||||||
activeReport = 'govtSecBalance';
|
activeReport = 'govtSecBalance';
|
||||||
asOnDate = todayDDMMYYYY();
|
asOnDate = todayDDMMYYYY();
|
||||||
|
|
||||||
|
generating = false;
|
||||||
|
toastMsg = '';
|
||||||
|
toastVisible = false;
|
||||||
|
private toastTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
setGroup(group: ReportGroup, defaultReport: string): void {
|
setGroup(group: ReportGroup, defaultReport: string): void {
|
||||||
this.activeGroup = group;
|
this.activeGroup = group;
|
||||||
this.activeReport = defaultReport;
|
this.activeReport = defaultReport;
|
||||||
@@ -244,4 +250,364 @@ export class InvestmentReportsComponent {
|
|||||||
get portfolioTotalMaturity(): number {
|
get portfolioTotalMaturity(): number {
|
||||||
return this.portfolioSummary.reduce((s, r) => s + r.maturityValue, 0);
|
return this.portfolioSummary.reduce((s, r) => s + r.maturityValue, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------- Export to Excel --------------------
|
||||||
|
private readonly holdingsExportHeaders = [
|
||||||
|
'Cert No',
|
||||||
|
'Script / Party',
|
||||||
|
'Sub Type',
|
||||||
|
'Units',
|
||||||
|
'Face Value',
|
||||||
|
'Purchase Rate',
|
||||||
|
'Interest Rate',
|
||||||
|
'Purchase Date',
|
||||||
|
'Maturity Date',
|
||||||
|
'Accrued Interest',
|
||||||
|
'Maturity Value',
|
||||||
|
'Status'
|
||||||
|
];
|
||||||
|
private readonly holdingsExportWidths = [
|
||||||
|
14,
|
||||||
|
30,
|
||||||
|
16,
|
||||||
|
12,
|
||||||
|
16,
|
||||||
|
14,
|
||||||
|
12,
|
||||||
|
14,
|
||||||
|
14,
|
||||||
|
16,
|
||||||
|
16,
|
||||||
|
12
|
||||||
|
];
|
||||||
|
private holdingsExportRow(h: InvestmentHolding): (string | number | null)[] {
|
||||||
|
return [
|
||||||
|
h.certNo,
|
||||||
|
h.scriptName || h.party,
|
||||||
|
h.subType,
|
||||||
|
h.units,
|
||||||
|
h.faceValue,
|
||||||
|
h.purchaseRate,
|
||||||
|
h.interestRate,
|
||||||
|
h.purchaseDate,
|
||||||
|
h.maturityDate,
|
||||||
|
this.accrued(h),
|
||||||
|
this.maturity(h),
|
||||||
|
h.status
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly txnExportHeaders = [
|
||||||
|
'Date',
|
||||||
|
'Voucher No',
|
||||||
|
'Cert No',
|
||||||
|
'Type',
|
||||||
|
'Particulars',
|
||||||
|
'Amount'
|
||||||
|
];
|
||||||
|
private readonly txnExportWidths = [
|
||||||
|
14,
|
||||||
|
22,
|
||||||
|
14,
|
||||||
|
14,
|
||||||
|
42,
|
||||||
|
16
|
||||||
|
];
|
||||||
|
private txnExportRow(t: InvestmentTxn): (string | number | null)[] {
|
||||||
|
return [
|
||||||
|
t.txnDate,
|
||||||
|
t.voucherNo,
|
||||||
|
t.certNo,
|
||||||
|
t.txnType,
|
||||||
|
t.particulars,
|
||||||
|
t.amount
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private exportSheets(): ExportSheetSpec[] {
|
||||||
|
const subtitle = `As on ${this.asOnDate}`;
|
||||||
|
const one = (
|
||||||
|
sheetName: string,
|
||||||
|
title: string,
|
||||||
|
headers: string[],
|
||||||
|
widths: number[],
|
||||||
|
rows: (string | number | null)[][]
|
||||||
|
): ExportSheetSpec[] => [
|
||||||
|
{ sheetName, title, subtitle, headers, widths, rows }];
|
||||||
|
|
||||||
|
switch (this.activeReport) {
|
||||||
|
case 'govtSecBalance':
|
||||||
|
return one(
|
||||||
|
'Govt Sec Balance',
|
||||||
|
'Govt Security Balance Report',
|
||||||
|
this.holdingsExportHeaders,
|
||||||
|
this.holdingsExportWidths,
|
||||||
|
this.govtSecHoldings.map((h) => this.holdingsExportRow(h))
|
||||||
|
);
|
||||||
|
case 'shareBalance':
|
||||||
|
return one(
|
||||||
|
'Share Balance',
|
||||||
|
'Share Balance Report',
|
||||||
|
this.holdingsExportHeaders,
|
||||||
|
this.holdingsExportWidths,
|
||||||
|
this.shareHoldings.map((h) => this.holdingsExportRow(h))
|
||||||
|
);
|
||||||
|
case 'fdrBalance':
|
||||||
|
return one(
|
||||||
|
'FDR Balance',
|
||||||
|
'FDR Balance Report',
|
||||||
|
this.holdingsExportHeaders,
|
||||||
|
this.holdingsExportWidths,
|
||||||
|
this.fdHoldings.map((h) => this.holdingsExportRow(h))
|
||||||
|
);
|
||||||
|
case 'bankInvList':
|
||||||
|
return one(
|
||||||
|
'Bank Investment List',
|
||||||
|
'Bank Investment List',
|
||||||
|
[
|
||||||
|
'Party',
|
||||||
|
'Holdings',
|
||||||
|
'Total Face Value'
|
||||||
|
],
|
||||||
|
[
|
||||||
|
30,
|
||||||
|
14,
|
||||||
|
18
|
||||||
|
],
|
||||||
|
this.bankInvestmentList.map((r) => [
|
||||||
|
r.party,
|
||||||
|
r.count,
|
||||||
|
r.totalFaceValue
|
||||||
|
])
|
||||||
|
);
|
||||||
|
case 'fdLedger':
|
||||||
|
return one(
|
||||||
|
'FD Ledger',
|
||||||
|
'FD Ledger',
|
||||||
|
this.txnExportHeaders,
|
||||||
|
this.txnExportWidths,
|
||||||
|
this.fdTxns.map((t) => this.txnExportRow(t))
|
||||||
|
);
|
||||||
|
case 'bondLedger':
|
||||||
|
return one(
|
||||||
|
'Govt-Bond Ledger',
|
||||||
|
'Govt/Bond Ledger',
|
||||||
|
this.txnExportHeaders,
|
||||||
|
this.txnExportWidths,
|
||||||
|
this.bondTxns.map((t) => this.txnExportRow(t))
|
||||||
|
);
|
||||||
|
case 'shareLedger':
|
||||||
|
return one(
|
||||||
|
'Share Ledger',
|
||||||
|
'Share Ledger',
|
||||||
|
this.txnExportHeaders,
|
||||||
|
this.txnExportWidths,
|
||||||
|
this.shareTxns.map((t) => this.txnExportRow(t))
|
||||||
|
);
|
||||||
|
case 'amortizeLedger':
|
||||||
|
return one(
|
||||||
|
'Amortize Ledger',
|
||||||
|
'Amortize Ledger',
|
||||||
|
this.txnExportHeaders,
|
||||||
|
this.txnExportWidths,
|
||||||
|
this.amortizeTxns.map((t) => this.txnExportRow(t))
|
||||||
|
);
|
||||||
|
case 'dealSlip':
|
||||||
|
return one(
|
||||||
|
'Deal Slip',
|
||||||
|
'Deal Slip Register',
|
||||||
|
this.txnExportHeaders,
|
||||||
|
this.txnExportWidths,
|
||||||
|
this.dealSlips.map((t) => this.txnExportRow(t))
|
||||||
|
);
|
||||||
|
case 'intDueRegister':
|
||||||
|
return one(
|
||||||
|
'Interest Due Register',
|
||||||
|
'Interest Due Register (Non-Cumulative Holdings)',
|
||||||
|
[
|
||||||
|
...this.holdingsExportHeaders,
|
||||||
|
'Due Amount'
|
||||||
|
],
|
||||||
|
[
|
||||||
|
...this.holdingsExportWidths,
|
||||||
|
16
|
||||||
|
],
|
||||||
|
this.interestDueRegister.map((h) => [
|
||||||
|
...this.holdingsExportRow(h),
|
||||||
|
h.dueAmount
|
||||||
|
])
|
||||||
|
);
|
||||||
|
case 'slrRegister':
|
||||||
|
return one(
|
||||||
|
'SLR Bond Register',
|
||||||
|
'SLR Bond Register',
|
||||||
|
this.holdingsExportHeaders,
|
||||||
|
this.holdingsExportWidths,
|
||||||
|
this.slrBondRegister.map((h) => this.holdingsExportRow(h))
|
||||||
|
);
|
||||||
|
case 'pendingReceived':
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
sheetName: 'Received Interest',
|
||||||
|
title: 'Received Interest / Dividend',
|
||||||
|
subtitle,
|
||||||
|
headers: this.txnExportHeaders,
|
||||||
|
widths: this.txnExportWidths,
|
||||||
|
rows: this.receivedInterestTxns.map((t) => this.txnExportRow(t))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sheetName: 'Pending Interest',
|
||||||
|
title: 'Pending Interest (Not Yet Posted)',
|
||||||
|
subtitle,
|
||||||
|
headers: this.holdingsExportHeaders,
|
||||||
|
widths: this.holdingsExportWidths,
|
||||||
|
rows: this.pendingInterestHoldings.map((h) => this.holdingsExportRow(h))
|
||||||
|
}
|
||||||
|
];
|
||||||
|
case 'maturedFd':
|
||||||
|
return one(
|
||||||
|
'Matured FD',
|
||||||
|
'List of Matured FD',
|
||||||
|
this.holdingsExportHeaders,
|
||||||
|
this.holdingsExportWidths,
|
||||||
|
this.maturedFdList.map((h) => this.holdingsExportRow(h))
|
||||||
|
);
|
||||||
|
case 'maturingList':
|
||||||
|
return one(
|
||||||
|
'Maturity List',
|
||||||
|
'Govt Security / FD Maturity List',
|
||||||
|
this.holdingsExportHeaders,
|
||||||
|
this.holdingsExportWidths,
|
||||||
|
this.maturingList.map((h) => this.holdingsExportRow(h))
|
||||||
|
);
|
||||||
|
case 'maturityPattern':
|
||||||
|
return one(
|
||||||
|
'Maturity Pattern',
|
||||||
|
'Maturity Pattern Register',
|
||||||
|
[
|
||||||
|
'Bucket',
|
||||||
|
'Holdings',
|
||||||
|
'Face Value'
|
||||||
|
],
|
||||||
|
[
|
||||||
|
22,
|
||||||
|
14,
|
||||||
|
18
|
||||||
|
],
|
||||||
|
this.maturityBuckets.map((b) => [
|
||||||
|
b.label,
|
||||||
|
b.count,
|
||||||
|
b.faceValue
|
||||||
|
])
|
||||||
|
);
|
||||||
|
case 'govtSecList':
|
||||||
|
return one(
|
||||||
|
'Govt Security List',
|
||||||
|
'Govt Security List',
|
||||||
|
this.holdingsExportHeaders,
|
||||||
|
this.holdingsExportWidths,
|
||||||
|
this.govtSecList.map((h) => this.holdingsExportRow(h))
|
||||||
|
);
|
||||||
|
case 'fdSecList':
|
||||||
|
return one(
|
||||||
|
'FD Security List',
|
||||||
|
'FD Security List',
|
||||||
|
this.holdingsExportHeaders,
|
||||||
|
this.holdingsExportWidths,
|
||||||
|
this.fdSecList.map((h) => this.holdingsExportRow(h))
|
||||||
|
);
|
||||||
|
case 'shareSecList':
|
||||||
|
return one(
|
||||||
|
'Share Security List',
|
||||||
|
'Share Security List',
|
||||||
|
this.holdingsExportHeaders,
|
||||||
|
this.holdingsExportWidths,
|
||||||
|
this.shareSecList.map((h) => this.holdingsExportRow(h))
|
||||||
|
);
|
||||||
|
case 'mfList':
|
||||||
|
return one(
|
||||||
|
'Mutual Fund List',
|
||||||
|
'Mutual Fund List',
|
||||||
|
this.holdingsExportHeaders,
|
||||||
|
this.holdingsExportWidths,
|
||||||
|
this.mutualFundList.map((h) => this.holdingsExportRow(h))
|
||||||
|
);
|
||||||
|
case 'shiftingReport':
|
||||||
|
return one(
|
||||||
|
'Shifting Report',
|
||||||
|
'Shifting Report — SLR Market Deviation ≥ 1%',
|
||||||
|
[
|
||||||
|
'Cert No',
|
||||||
|
'Script',
|
||||||
|
'Purchase Rate',
|
||||||
|
'Market Price',
|
||||||
|
'Deviation %'
|
||||||
|
],
|
||||||
|
[
|
||||||
|
14,
|
||||||
|
30,
|
||||||
|
16,
|
||||||
|
16,
|
||||||
|
14
|
||||||
|
],
|
||||||
|
this.shiftingCandidates.map((x) => [
|
||||||
|
x.certNo,
|
||||||
|
x.scriptName,
|
||||||
|
x.purchaseRate,
|
||||||
|
x.marketPrice,
|
||||||
|
x.deviationPct
|
||||||
|
])
|
||||||
|
);
|
||||||
|
case 'portfolio':
|
||||||
|
return one(
|
||||||
|
'Portfolio',
|
||||||
|
'Investment Portfolio — Consolidated Summary',
|
||||||
|
[
|
||||||
|
'Instrument Type',
|
||||||
|
'Holdings',
|
||||||
|
'Face Value',
|
||||||
|
'Accrued Interest',
|
||||||
|
'Maturity Value'
|
||||||
|
],
|
||||||
|
[
|
||||||
|
24,
|
||||||
|
12,
|
||||||
|
18,
|
||||||
|
18,
|
||||||
|
18
|
||||||
|
],
|
||||||
|
this.portfolioSummary.map((p) => [
|
||||||
|
p.label,
|
||||||
|
p.count,
|
||||||
|
p.faceValue,
|
||||||
|
p.accrued,
|
||||||
|
p.maturityValue
|
||||||
|
])
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return one('Report', 'Investment Report', this.holdingsExportHeaders, this.holdingsExportWidths, []);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async exportExcel(): Promise<void> {
|
||||||
|
this.generating = true;
|
||||||
|
try {
|
||||||
|
await exportWorkbook(this.exportSheets(), `Investment-${this.activeReport}-${this.asOnDate}`);
|
||||||
|
this.toast('Report exported.');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Investment report export failed', err);
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,6 +128,32 @@
|
|||||||
color: #b91c1c;
|
color: #b91c1c;
|
||||||
border: 1px solid #fecaca;
|
border: 1px solid #fecaca;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Investment-provisioning classification (Non-Performing Investment) — same severity ramp as
|
||||||
|
// the NPA module's stage badges, kept local since it colours a different status field here.
|
||||||
|
&--standard {
|
||||||
|
background: #f0fdf4;
|
||||||
|
color: #15803d;
|
||||||
|
border: 1px solid #bbf7d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--sub_standard {
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #dc2626;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--doubtful {
|
||||||
|
background: #fdf2f8;
|
||||||
|
color: #9d174d;
|
||||||
|
border: 1px solid #fbcfe8;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--loss {
|
||||||
|
background: #1f2937;
|
||||||
|
color: #f9fafb;
|
||||||
|
border: 1px solid #1f2937;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.inv-form-grid {
|
.inv-form-grid {
|
||||||
|
|||||||
@@ -15,6 +15,13 @@
|
|||||||
Equity and cooperative-society share holdings — Purchase, Dividend, Sell and Write-off entries.
|
Equity and cooperative-society share holdings — Purchase, Dividend, Sell and Write-off entries.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
@if (activeTab === 'holdings') {
|
||||||
|
<div class="pg-toolbar">
|
||||||
|
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="generating" (click)="exportExcel()">
|
||||||
|
{{ generating ? 'Exporting…' : 'Export to Excel' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="cctr-tabs">
|
<div class="cctr-tabs">
|
||||||
@@ -131,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,7 +9,9 @@
|
|||||||
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';
|
||||||
|
|
||||||
type ShareTab = 'holdings' | 'purchase' | 'dividend' | 'sell' | 'writeoff';
|
type ShareTab = 'holdings' | 'purchase' | 'dividend' | 'sell' | 'writeoff';
|
||||||
|
|
||||||
@@ -40,7 +42,8 @@ interface SharePurchaseForm {
|
|||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
FormsModule
|
FormsModule,
|
||||||
|
DateDdmmyyyyPickerComponent
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class ShareInvestmentComponent {
|
export class ShareInvestmentComponent {
|
||||||
@@ -50,6 +53,7 @@ export class ShareInvestmentComponent {
|
|||||||
holdings: InvestmentHolding[] = [...SHARE_HOLDINGS];
|
holdings: InvestmentHolding[] = [...SHARE_HOLDINGS];
|
||||||
txns: InvestmentTxn[] = ALL_TXNS.filter((t) => t.instrumentType === 'SHARE');
|
txns: InvestmentTxn[] = ALL_TXNS.filter((t) => t.instrumentType === 'SHARE');
|
||||||
|
|
||||||
|
generating = false;
|
||||||
toastMsg = '';
|
toastMsg = '';
|
||||||
toastVisible = false;
|
toastVisible = false;
|
||||||
private toastTimer: ReturnType<typeof setTimeout> | undefined;
|
private toastTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
@@ -252,6 +256,59 @@ export class ShareInvestmentComponent {
|
|||||||
return `inv-badge inv-badge--${h.status.toLowerCase()}`;
|
return `inv-badge inv-badge--${h.status.toLowerCase()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------- Export to Excel (Holdings register) --------------------
|
||||||
|
async exportExcel(): Promise<void> {
|
||||||
|
this.generating = true;
|
||||||
|
try {
|
||||||
|
await exportWorkbook(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
sheetName: 'Share Holdings',
|
||||||
|
title: 'Share Holdings Register',
|
||||||
|
subtitle: `As on ${this.asOnDate}`,
|
||||||
|
headers: [
|
||||||
|
'Cert No',
|
||||||
|
'Script',
|
||||||
|
'Party',
|
||||||
|
'Units',
|
||||||
|
'Purchase Rate',
|
||||||
|
'Face Value',
|
||||||
|
'Purchase Date',
|
||||||
|
'Status'
|
||||||
|
],
|
||||||
|
widths: [
|
||||||
|
14,
|
||||||
|
24,
|
||||||
|
22,
|
||||||
|
12,
|
||||||
|
14,
|
||||||
|
16,
|
||||||
|
14,
|
||||||
|
12
|
||||||
|
],
|
||||||
|
rows: this.holdings.map((h) => [
|
||||||
|
h.certNo,
|
||||||
|
h.scriptName,
|
||||||
|
h.party,
|
||||||
|
h.units,
|
||||||
|
h.purchaseRate,
|
||||||
|
h.faceValue,
|
||||||
|
h.purchaseDate,
|
||||||
|
h.status
|
||||||
|
])
|
||||||
|
}
|
||||||
|
],
|
||||||
|
`Share-Holdings-${this.asOnDate}`
|
||||||
|
);
|
||||||
|
this.toast('Share Holdings exported.');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Share Holdings export failed', err);
|
||||||
|
this.toast('Could not generate the workbook — please retry.');
|
||||||
|
} finally {
|
||||||
|
this.generating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private toast(msg: string): void {
|
private toast(msg: string): void {
|
||||||
this.toastMsg = msg;
|
this.toastMsg = msg;
|
||||||
this.toastVisible = true;
|
this.toastVisible = true;
|
||||||
|
|||||||
@@ -0,0 +1,878 @@
|
|||||||
|
<!--
|
||||||
|
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="pg">
|
||||||
|
@if (application; as a) {
|
||||||
|
<header class="pg-head">
|
||||||
|
<div>
|
||||||
|
<div class="pg-eyebrow">CREDIT · LOAN ORIGINATION · {{ a.branchName | uppercase }}</div>
|
||||||
|
<h1 class="pg-title">
|
||||||
|
{{ a.applicationNo }} — {{ a.applicantName }}
|
||||||
|
<span [class]="badgeClass(a)">{{ statusLabels[a.overallStatus] }}</span>
|
||||||
|
</h1>
|
||||||
|
<p class="pg-sub npa-report-note">
|
||||||
|
{{ a.productName }} · ₹{{ a.requestedAmount | number: '1.0-0' }} · Applied {{ a.applicationDate }} ·
|
||||||
|
{{ progress(a) }}% through the sanctioning workflow.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="pg-toolbar">
|
||||||
|
<button type="button" class="pg-btn pg-btn--ghost" (click)="goBack()">‹ Back to Pipeline</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="pg-card">
|
||||||
|
<dl class="loan-kv-grid">
|
||||||
|
<div class="loan-kv-field">
|
||||||
|
<dt>Branch</dt>
|
||||||
|
<dd>{{ a.branchName }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="loan-kv-field">
|
||||||
|
<dt>Channel</dt>
|
||||||
|
<dd>
|
||||||
|
{{ a.channel === 'BC' ? a.bcAgentName : a.channel === 'SELF_APPLY' ? 'Self-Apply (Digital)' : 'Branch' }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="loan-kv-field">
|
||||||
|
<dt>Customer Type</dt>
|
||||||
|
<dd>{{ a.customerType === 'EXISTING' ? 'Existing Customer' : 'New Customer' }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="loan-kv-field">
|
||||||
|
<dt>Tenure / Rate</dt>
|
||||||
|
<dd>{{ a.tenureMonths }} months @ {{ a.interestRate }}%</dd>
|
||||||
|
</div>
|
||||||
|
<div class="loan-kv-field">
|
||||||
|
<dt>Monthly Income / Eligible Amount</dt>
|
||||||
|
<dd>₹{{ a.monthlyIncome | number: '1.0-0' }} / ₹{{ a.eligibleLoanAmount | number: '1.0-0' }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="loan-kv-field">
|
||||||
|
<dt>Sanctioning Authority</dt>
|
||||||
|
<dd>{{ sanctioningAuthorityOf(a) }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="loan-kv-field">
|
||||||
|
<dt>Share Member</dt>
|
||||||
|
<dd>
|
||||||
|
@if (a.isShareMember) {
|
||||||
|
Yes — {{ a.shareCertNo }} (since {{ a.shareMemberSince }})
|
||||||
|
} @else {
|
||||||
|
<span class="loan-badge loan-badge--hold">Not a Member</span>
|
||||||
|
<button type="button" class="pg-btn pg-btn--ghost" (click)="allotMembership(a)">Allot Shares</button>
|
||||||
|
}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
@if (a.coBorrowerRequired) {
|
||||||
|
<div class="loan-kv-field">
|
||||||
|
<dt>Co-Borrower</dt>
|
||||||
|
<dd>{{ a.coBorrowerName }}</dd>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (a.guarantorRequired) {
|
||||||
|
<div class="loan-kv-field">
|
||||||
|
<dt>Guarantor</dt>
|
||||||
|
<dd>{{ a.guarantorName }}</dd>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">Sanctioning Workflow Progress</div>
|
||||||
|
<div class="loan-stepper">
|
||||||
|
@for (s of stageOrder; track s) {
|
||||||
|
<div [class]="stepStatusClass(a, s)">
|
||||||
|
<span class="loan-step__connector"></span>
|
||||||
|
<span class="loan-step__dot">{{ stepMarker(a, s) }}</span>
|
||||||
|
<span class="loan-step__label">{{ stageLabels[s] }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (a.overallStatus === 'REJECTED') {
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">Application Rejected — at {{ stageLabels[a.currentStage] }}</div>
|
||||||
|
<p class="pg-sub npa-report-note">See the approval summary below for the recorded reason.</p>
|
||||||
|
</div>
|
||||||
|
} @else if (a.overallStatus === 'ON_HOLD') {
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">Personal Discussion — {{ discrepancyLabels[a.discrepancyType] }}</div>
|
||||||
|
<div class="loan-action-card">
|
||||||
|
<p>{{ a.personalDiscussionNotes }}</p>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Resolution Notes</span>
|
||||||
|
<input type="text" [(ngModel)]="actionRemarks" placeholder="How was this resolved?" />
|
||||||
|
</label>
|
||||||
|
<div class="loan-action-row">
|
||||||
|
<button type="button" class="pg-btn pg-btn--success" (click)="resolveDiscrepancy(a)">
|
||||||
|
Resolve & Proceed to Loan Committee
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @else if (a.overallStatus === 'DISBURSED') {
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">Disbursed</div>
|
||||||
|
<dl class="loan-kv-grid">
|
||||||
|
<div class="loan-kv-field">
|
||||||
|
<dt>Disbursement Date</dt>
|
||||||
|
<dd>{{ a.disbursementDate }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="loan-kv-field">
|
||||||
|
<dt>Mode</dt>
|
||||||
|
<dd>{{ a.disbursementMode }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="loan-kv-field">
|
||||||
|
<dt>ACH Confirmation</dt>
|
||||||
|
<dd>{{ a.achConfirmationRef }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="loan-kv-field">
|
||||||
|
<dt>EMI Activation Confirmation</dt>
|
||||||
|
<dd>{{ a.emiActivationConfirmed ? 'Confirmed on ' + a.emiActivationDate : 'Pending' }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">EMI Repayment Schedule</div>
|
||||||
|
@if (isRevolving(a)) {
|
||||||
|
<p class="pg-sub npa-report-note">
|
||||||
|
{{ a.productName }} is a revolving/renewable limit — repaid on demand and renewed annually, not on a fixed
|
||||||
|
EMI schedule.
|
||||||
|
</p>
|
||||||
|
} @else {
|
||||||
|
<div class="inv-kpis npa-kpis--ratio">
|
||||||
|
<div class="inv-kpi">
|
||||||
|
<span class="inv-kpi__label">Loan Amount</span>
|
||||||
|
<span class="inv-kpi__value">₹{{ a.requestedAmount | number: '1.0-0' }}</span>
|
||||||
|
<span class="inv-kpi__sub">{{ a.tenureMonths }} months @ {{ a.interestRate }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="inv-kpi">
|
||||||
|
<span class="inv-kpi__label">Monthly EMI</span>
|
||||||
|
<span class="inv-kpi__value">₹{{ emiAmountFor(a) | number: '1.0-0' }}</span>
|
||||||
|
<span class="inv-kpi__sub">Reducing balance</span>
|
||||||
|
</div>
|
||||||
|
<div class="inv-kpi">
|
||||||
|
<span class="inv-kpi__label">Total Interest Payable</span>
|
||||||
|
<span class="inv-kpi__value">₹{{ totalInterestFor(a) | number: '1.0-0' }}</span>
|
||||||
|
<span class="inv-kpi__sub">over the full tenure</span>
|
||||||
|
</div>
|
||||||
|
<div class="inv-kpi">
|
||||||
|
<span class="inv-kpi__label">Total Amount Payable</span>
|
||||||
|
<span class="inv-kpi__value">₹{{ totalPayableFor(a) | number: '1.0-0' }}</span>
|
||||||
|
<span class="inv-kpi__sub">principal + interest</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pg-table-scroll loan-emi-table-scroll">
|
||||||
|
<table class="pg-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="num">Inst. No</th>
|
||||||
|
<th>Due Date</th>
|
||||||
|
<th class="num">Opening Balance</th>
|
||||||
|
<th class="num">EMI</th>
|
||||||
|
<th class="num">Principal</th>
|
||||||
|
<th class="num col-out">Interest</th>
|
||||||
|
<th class="num">Closing Balance</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (row of emiScheduleFor(a); track row.installmentNo) {
|
||||||
|
<tr>
|
||||||
|
<td class="num">{{ row.installmentNo }}</td>
|
||||||
|
<td>{{ row.dueDate }}</td>
|
||||||
|
<td class="num">₹{{ row.openingBalance | number: '1.0-0' }}</td>
|
||||||
|
<td class="num">₹{{ row.emiAmount | number: '1.0-0' }}</td>
|
||||||
|
<td class="num col-in">₹{{ row.principalComponent | number: '1.0-0' }}</td>
|
||||||
|
<td class="num col-out">₹{{ row.interestComponent | number: '1.0-0' }}</td>
|
||||||
|
<td class="num">₹{{ row.closingBalance | number: '1.0-0' }}</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<!-- ===================== APPLICATION ===================== -->
|
||||||
|
@if (a.currentStage === 'APPLICATION') {
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">Application Received</div>
|
||||||
|
<div class="loan-action-card">
|
||||||
|
<p>
|
||||||
|
Received via
|
||||||
|
{{ a.channel === 'BC' ? a.bcAgentName : a.channel === 'SELF_APPLY' ? 'Self-Apply (Digital)' : 'Branch' }}
|
||||||
|
on {{ a.applicationDate }}.
|
||||||
|
</p>
|
||||||
|
<div class="loan-action-row">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--primary"
|
||||||
|
(click)="proceed(a, 'Application logged and forwarded for KYC.')"
|
||||||
|
>
|
||||||
|
Proceed to KYC & Document Collection
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- ===================== KYC & DOCUMENTS ===================== -->
|
||||||
|
@if (a.currentStage === 'KYC_DOCUMENTS') {
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">KYC & Document Collection — OVD (RBI KYC Master Direction)</div>
|
||||||
|
<div class="loan-action-card">
|
||||||
|
<div class="loan-checklist">
|
||||||
|
<label class="loan-checklist-item">
|
||||||
|
<input type="checkbox" [(ngModel)]="a.aadhaarVerified" /> Aadhaar Verified (OVD)
|
||||||
|
</label>
|
||||||
|
<label class="loan-checklist-item">
|
||||||
|
<input type="checkbox" [(ngModel)]="a.panVerified" /> PAN Verified
|
||||||
|
</label>
|
||||||
|
<label class="loan-checklist-item">
|
||||||
|
<input type="checkbox" [(ngModel)]="a.incomeProofSubmitted" /> Income Proof Submitted
|
||||||
|
</label>
|
||||||
|
<label class="loan-checklist-item">
|
||||||
|
<input type="checkbox" [(ngModel)]="a.bankStatementSubmitted" /> Bank Statement Submitted
|
||||||
|
</label>
|
||||||
|
@if (a.secured) {
|
||||||
|
<label class="loan-checklist-item">
|
||||||
|
<input type="checkbox" [(ngModel)]="a.propertyDetailsSubmitted" /> Property Details Submitted
|
||||||
|
</label>
|
||||||
|
<label class="loan-checklist-item">
|
||||||
|
<input type="checkbox" [(ngModel)]="a.taxReceiptSubmitted" /> Property Tax Receipt Submitted
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
<label class="loan-checklist-item">
|
||||||
|
<input type="checkbox" [(ngModel)]="a.electricityBillSubmitted" /> Electricity Bill (Address Proof)
|
||||||
|
Submitted
|
||||||
|
</label>
|
||||||
|
<label class="loan-checklist-item">
|
||||||
|
<input type="checkbox" [(ngModel)]="a.aadhaarFingerprintCaptured" /> Aadhaar Fingerprint Captured
|
||||||
|
(e-KYC)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
@if (a.monthlyIncome === 0) {
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Monthly Income (₹)</span>
|
||||||
|
<input type="number" [(ngModel)]="monthlyIncomeInput" placeholder="e.g. 45000" />
|
||||||
|
</label>
|
||||||
|
<p class="pg-sub npa-report-note">
|
||||||
|
Eligible Loan Amount preview: ₹{{ eligiblePreview(a) | number: '1.0-0' }}
|
||||||
|
</p>
|
||||||
|
<div class="loan-action-row">
|
||||||
|
<button type="button" class="pg-btn pg-btn--ghost" (click)="setIncomeNow(a)">
|
||||||
|
Assess Income & Calculate Eligible Amount
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<dl class="loan-kv-grid" style="padding: 0 0 14px">
|
||||||
|
<div class="loan-kv-field">
|
||||||
|
<dt>Monthly Income</dt>
|
||||||
|
<dd>₹{{ a.monthlyIncome | number: '1.0-0' }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="loan-kv-field">
|
||||||
|
<dt>Eligible Loan Amount</dt>
|
||||||
|
<dd>₹{{ a.eligibleLoanAmount | number: '1.0-0' }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
}
|
||||||
|
@if (a.coBorrowerRequired) {
|
||||||
|
<p class="pg-sub npa-report-note">Co-Borrower Required — {{ a.coBorrowerName }}</p>
|
||||||
|
}
|
||||||
|
<div class="loan-action-row">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--primary"
|
||||||
|
[disabled]="!allKycDone(a)"
|
||||||
|
(click)="proceed(a, 'KYC/OVD documents verified and collected.')"
|
||||||
|
>
|
||||||
|
Proceed to CIC / Credit Bureau Check
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- ===================== CIC / CREDIT BUREAU CHECK ===================== -->
|
||||||
|
@if (a.currentStage === 'CIC_CHECK') {
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">CIC / Credit Bureau Check — {{ a.cicBureau }}</div>
|
||||||
|
<div class="loan-action-card">
|
||||||
|
@if (a.cibilScore === 0) {
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Bureau</span>
|
||||||
|
<select [(ngModel)]="a.cicBureau">
|
||||||
|
@for (bureau of cicBureaus; track bureau) {
|
||||||
|
<option [value]="bureau">{{ bureau }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div class="loan-action-row">
|
||||||
|
<button type="button" class="pg-btn pg-btn--primary" (click)="checkCreditScoreNow(a)">
|
||||||
|
Check Credit Score
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<dl class="loan-kv-grid" style="padding: 0 0 14px">
|
||||||
|
<div class="loan-kv-field">
|
||||||
|
<dt>Bureau</dt>
|
||||||
|
<dd>{{ a.cicBureau }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="loan-kv-field">
|
||||||
|
<dt>Credit Score</dt>
|
||||||
|
<dd>{{ a.cibilScore }} ({{ cibilBandOf(a.cibilScore) }})</dd>
|
||||||
|
</div>
|
||||||
|
<div class="loan-kv-field">
|
||||||
|
<dt>Report</dt>
|
||||||
|
<dd>{{ a.cicReportUploaded ? a.cicReportFileName : 'Not uploaded yet' }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="loan-kv-field">
|
||||||
|
<dt>Field Inquiry</dt>
|
||||||
|
<dd>
|
||||||
|
{{
|
||||||
|
a.fiRequestPushed
|
||||||
|
? 'Pushed to ' + a.fiAgencyAssigned + ' (' + a.fiRequestDate + ')'
|
||||||
|
: 'Not pushed yet'
|
||||||
|
}}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
}
|
||||||
|
@if (a.cibilScore > 0) {
|
||||||
|
@if (!a.cicReportUploaded) {
|
||||||
|
<div class="loan-action-row">
|
||||||
|
<label class="pg-btn pg-btn--ghost" style="cursor: pointer">
|
||||||
|
Upload {{ a.cicBureau }} Report
|
||||||
|
<input type="file" hidden accept=".pdf,.jpg,.jpeg,.png" (change)="onCicFileSelected($event, a)" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
} @else if (!a.fiRequestPushed) {
|
||||||
|
<div class="loan-action-row">
|
||||||
|
<button type="button" class="pg-btn pg-btn--ghost" (click)="pushFieldInquiryNow(a)">
|
||||||
|
Push Field Inquiry to Agency
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="loan-action-row">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--primary"
|
||||||
|
(click)="
|
||||||
|
proceed(
|
||||||
|
a,
|
||||||
|
a.cicBureau +
|
||||||
|
' report reviewed — score ' +
|
||||||
|
a.cibilScore +
|
||||||
|
' (' +
|
||||||
|
cibilBandOf(a.cibilScore) +
|
||||||
|
').'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Proceed to Field Investigation
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- ===================== FIELD INVESTIGATION ===================== -->
|
||||||
|
@if (a.currentStage === 'FIELD_INVESTIGATION') {
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">Field Investigation (FI) — Agent Report</div>
|
||||||
|
<div class="loan-action-card">
|
||||||
|
<div class="inv-form-grid">
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Address Verification</span>
|
||||||
|
<select [(ngModel)]="a.addressVerification.status">
|
||||||
|
<option value="PENDING">Pending</option>
|
||||||
|
<option value="VERIFIED">Verified</option>
|
||||||
|
<option value="FAILED">Failed</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Office / Employment Verification</span>
|
||||||
|
<select [(ngModel)]="a.officeVerification.status">
|
||||||
|
<option value="PENDING">Pending</option>
|
||||||
|
<option value="VERIFIED">Verified</option>
|
||||||
|
<option value="FAILED">Failed</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
@if (a.secured) {
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Property Verification / Valuation</span>
|
||||||
|
<select [(ngModel)]="a.propertyVerification.status">
|
||||||
|
<option value="PENDING">Pending</option>
|
||||||
|
<option value="VERIFIED">Verified</option>
|
||||||
|
<option value="FAILED">Failed</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Property Valuation (₹)</span>
|
||||||
|
<input type="number" [(ngModel)]="a.propertyValuation" />
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="loan-kv-grid" style="padding: 0 0 14px">
|
||||||
|
<div class="loan-kv-field">
|
||||||
|
<dt>Agent Report</dt>
|
||||||
|
<dd>{{ a.agentReportUploaded ? a.agentReportFileName : 'Not uploaded yet' }}</dd>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if (!a.agentReportUploaded) {
|
||||||
|
<div class="loan-action-row">
|
||||||
|
<label class="pg-btn pg-btn--ghost" style="cursor: pointer">
|
||||||
|
Upload Agent Report
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
hidden
|
||||||
|
accept=".pdf,.jpg,.jpeg,.png"
|
||||||
|
(change)="onAgentReportFileSelected($event, a)"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="loan-action-row">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--primary"
|
||||||
|
[disabled]="!fiComplete(a) || !a.agentReportUploaded"
|
||||||
|
(click)="completeFi(a)"
|
||||||
|
>
|
||||||
|
Record FI & Proceed to Personal Discussion
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- ===================== PERSONAL DISCUSSION ===================== -->
|
||||||
|
@if (a.currentStage === 'PERSONAL_DISCUSSION') {
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">Personal Discussion with Manager</div>
|
||||||
|
<div class="loan-action-card">
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Discrepancy (if any)</span>
|
||||||
|
<select [(ngModel)]="discrepancySelection">
|
||||||
|
<option value="NONE">No Discrepancy</option>
|
||||||
|
<option value="DOCUMENT_DISCREPANCY">Document Discrepancy</option>
|
||||||
|
<option value="NAME_MISMATCH">Name Mismatch</option>
|
||||||
|
<option value="OFFICE_VERIFICATION_FAILED">Office Verification Failed</option>
|
||||||
|
<option value="PROPERTY_DISCREPANCY">Property Discrepancy</option>
|
||||||
|
<option value="FI_REPORT_DISCREPANCY">FI Report Discrepancy</option>
|
||||||
|
<option value="FAKE_DECLARATION">Fake Declaration Suspected</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Discussion Notes</span>
|
||||||
|
<input type="text" [(ngModel)]="actionRemarks" placeholder="Notes from discussion with manager" />
|
||||||
|
</label>
|
||||||
|
<div class="loan-action-row">
|
||||||
|
@if (discrepancySelection === 'NONE') {
|
||||||
|
<button type="button" class="pg-btn pg-btn--success" (click)="clearPersonalDiscussion(a)">
|
||||||
|
Clear, Take Declaration & Proceed to Loan Committee
|
||||||
|
</button>
|
||||||
|
} @else {
|
||||||
|
<button type="button" class="pg-btn pg-btn--warn" (click)="flagPersonalDiscussion(a)">
|
||||||
|
Flag Discrepancy — Hold Application
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- ===================== LOAN COMMITTEE APPROVAL ===================== -->
|
||||||
|
@if (a.currentStage === 'CREDIT_COMMITTEE') {
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">Loan Committee Approval — {{ sanctioningAuthorityOf(a) }}</div>
|
||||||
|
<div class="loan-action-card">
|
||||||
|
<p class="pg-sub npa-report-note">Reports Required — present below reports, then complete.</p>
|
||||||
|
<div class="loan-checklist">
|
||||||
|
<label class="loan-checklist-item">
|
||||||
|
<input type="checkbox" [ngModel]="a.cicReportUploaded" disabled /> CIBIL / Credit Bureau Report
|
||||||
|
</label>
|
||||||
|
<label class="loan-checklist-item">
|
||||||
|
<input type="checkbox" [(ngModel)]="a.backgroundCheckDone" /> Background Check
|
||||||
|
</label>
|
||||||
|
@if (a.secured) {
|
||||||
|
<label class="loan-checklist-item">
|
||||||
|
<input type="checkbox" [(ngModel)]="a.cersaiSearchDone" /> CSE — CERSAI Search Enquiry
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
<label class="loan-checklist-item">
|
||||||
|
<input type="checkbox" [ngModel]="a.aadhaarVerified && a.panVerified" disabled /> OVD
|
||||||
|
</label>
|
||||||
|
<label class="loan-checklist-item">
|
||||||
|
<input type="checkbox" [ngModel]="a.incomeProofSubmitted" disabled /> Income Proof
|
||||||
|
</label>
|
||||||
|
<label class="loan-checklist-item">
|
||||||
|
<input type="checkbox" [ngModel]="fiComplete(a)" disabled /> FI Report
|
||||||
|
</label>
|
||||||
|
<label class="loan-checklist-item">
|
||||||
|
<input type="checkbox" [(ngModel)]="a.managerMeetingDone" /> Manager Meeting
|
||||||
|
</label>
|
||||||
|
<label class="loan-checklist-item">
|
||||||
|
<input type="checkbox" [ngModel]="a.isShareMember" disabled /> Share Member Approval
|
||||||
|
</label>
|
||||||
|
@if (a.secured) {
|
||||||
|
<label class="loan-checklist-item">
|
||||||
|
<input type="checkbox" [ngModel]="a.propertyValuation > 0" disabled /> Property Valuation
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Committee Remarks</span>
|
||||||
|
<input type="text" [(ngModel)]="actionRemarks" placeholder="Remarks" />
|
||||||
|
</label>
|
||||||
|
<div class="loan-action-row">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--success"
|
||||||
|
[disabled]="!committeeReportsReady(a)"
|
||||||
|
(click)="approveStage(a, 'creditCommitteeApproval', 'Sanctioning Authority')"
|
||||||
|
>
|
||||||
|
Approve
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--danger"
|
||||||
|
(click)="rejectStage(a, 'creditCommitteeApproval', 'Sanctioning Authority')"
|
||||||
|
>
|
||||||
|
Reject
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- ===================== RISK APPROVAL ===================== -->
|
||||||
|
@if (a.currentStage === 'RISK_APPROVAL') {
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">Risk Approval — Verification Checklist</div>
|
||||||
|
<div class="loan-action-card">
|
||||||
|
<div class="loan-checklist">
|
||||||
|
<label class="loan-checklist-item">
|
||||||
|
<input type="checkbox" [ngModel]="a.addressVerification.status === 'VERIFIED'" disabled /> ID / Address
|
||||||
|
Verified
|
||||||
|
</label>
|
||||||
|
<label class="loan-checklist-item">
|
||||||
|
<input type="checkbox" [ngModel]="a.incomeProofSubmitted" disabled /> Income Proof Verified
|
||||||
|
</label>
|
||||||
|
<label class="loan-checklist-item">
|
||||||
|
<input type="checkbox" [(ngModel)]="a.previousLoanDeclarationDone" /> Previous Loan Declaration
|
||||||
|
</label>
|
||||||
|
@if (a.secured) {
|
||||||
|
<label class="loan-checklist-item">
|
||||||
|
<input type="checkbox" [(ngModel)]="a.propertySearchReportDone" /> Property Search Report
|
||||||
|
</label>
|
||||||
|
<label class="loan-checklist-item">
|
||||||
|
<input type="checkbox" [(ngModel)]="a.valuationReportConfirmed" /> Valuation Report
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
<label class="loan-checklist-item">
|
||||||
|
<input type="checkbox" [ngModel]="fiComplete(a)" disabled /> FI Report
|
||||||
|
</label>
|
||||||
|
<label class="loan-checklist-item">
|
||||||
|
<input type="checkbox" [ngModel]="a.backgroundCheckDone" disabled /> Personal Background Check
|
||||||
|
</label>
|
||||||
|
<label class="loan-checklist-item">
|
||||||
|
<input type="checkbox" [(ngModel)]="a.guarantorRequired" /> Guarantor Required
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
@if (a.guarantorRequired) {
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Guarantor Name</span>
|
||||||
|
<input type="text" [(ngModel)]="a.guarantorName" />
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Risk Remarks</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="actionRemarks"
|
||||||
|
placeholder="ID/address, income adequacy, previous-loan declaration"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div class="loan-action-row">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--success"
|
||||||
|
[disabled]="!riskChecklistReady(a)"
|
||||||
|
(click)="approveStage(a, 'riskApproval', 'Risk Approval')"
|
||||||
|
>
|
||||||
|
Approve
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--danger"
|
||||||
|
(click)="rejectStage(a, 'riskApproval', 'Risk Approval')"
|
||||||
|
>
|
||||||
|
Reject
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- ===================== LEGAL APPROVAL ===================== -->
|
||||||
|
@if (a.currentStage === 'LEGAL_APPROVAL') {
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">Legal Approval — Fees & A/c Opened By</div>
|
||||||
|
<div class="loan-action-card">
|
||||||
|
<div class="loan-checklist">
|
||||||
|
<label class="loan-checklist-item">
|
||||||
|
<input type="checkbox" [(ngModel)]="a.feesAndAccountOpened" /> Fees & A/c Opening Done
|
||||||
|
</label>
|
||||||
|
@if (a.secured) {
|
||||||
|
<label class="loan-checklist-item">
|
||||||
|
<input type="checkbox" [(ngModel)]="a.mortgageCreated" /> Mortgage Created
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
<label class="loan-checklist-item">
|
||||||
|
<input type="checkbox" [(ngModel)]="a.legalStampDone" /> Legal Stamp Done
|
||||||
|
</label>
|
||||||
|
<label class="loan-checklist-item">
|
||||||
|
<input type="checkbox" [(ngModel)]="a.fileFilingDone" /> File Filing Done
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Agreement Signing Slot</span>
|
||||||
|
<input type="text" [(ngModel)]="a.agreementSigningSlot" placeholder="DD-MM-YYYY" />
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Legal Remarks</span>
|
||||||
|
<input type="text" [(ngModel)]="actionRemarks" placeholder="Remarks" />
|
||||||
|
</label>
|
||||||
|
<div class="loan-action-row">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--success"
|
||||||
|
[disabled]="!legalChecklistDone(a)"
|
||||||
|
(click)="approveStage(a, 'legalApproval', 'Legal Approval')"
|
||||||
|
>
|
||||||
|
Approve & Allot Signing Slot
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--danger"
|
||||||
|
(click)="rejectStage(a, 'legalApproval', 'Legal Approval')"
|
||||||
|
>
|
||||||
|
Reject
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- ===================== CUSTOMER SIGNING SLOT ===================== -->
|
||||||
|
@if (a.currentStage === 'CUSTOMER_SIGNING') {
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">Customer Signing Slot — {{ a.agreementSigningSlot }}</div>
|
||||||
|
<div class="loan-action-card">
|
||||||
|
<p>Loan Agreement and Sanction Letter ready for the customer's acknowledgement and signature.</p>
|
||||||
|
<div class="loan-action-row">
|
||||||
|
<button type="button" class="pg-btn pg-btn--primary" (click)="markSigned(a)">
|
||||||
|
Mark Signed by Customer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- ===================== CHEQUE RECEIVED / ACH SIGN ===================== -->
|
||||||
|
@if (a.currentStage === 'CHEQUE_ACH') {
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">Cheque Received / ACH Sign</div>
|
||||||
|
<div class="loan-action-card">
|
||||||
|
<div class="loan-checklist">
|
||||||
|
<label class="loan-checklist-item">
|
||||||
|
<input type="checkbox" [(ngModel)]="a.chequeReceived" /> Cheque Received
|
||||||
|
</label>
|
||||||
|
<label class="loan-checklist-item">
|
||||||
|
<input type="checkbox" [(ngModel)]="a.achSigned" /> ACH Mandate Signed
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="loan-action-row">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--primary"
|
||||||
|
[disabled]="!a.chequeReceived || !a.achSigned"
|
||||||
|
(click)="confirmChequeAndProceed(a)"
|
||||||
|
>
|
||||||
|
Confirm & Proceed to Post-Disbursement Check
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- ===================== POST-DISBURSEMENT CHECK ===================== -->
|
||||||
|
@if (a.currentStage === 'POST_CHECK') {
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">Post-Disbursement Check — Final Compliance Gate</div>
|
||||||
|
<div class="loan-action-card">
|
||||||
|
<p class="pg-sub npa-report-note">Document Signing by:</p>
|
||||||
|
<div class="loan-checklist">
|
||||||
|
<label class="loan-checklist-item"> <input type="checkbox" [(ngModel)]="a.signedByAgent" /> Agent </label>
|
||||||
|
<label class="loan-checklist-item">
|
||||||
|
<input type="checkbox" [(ngModel)]="a.signedByManager" /> Manager
|
||||||
|
</label>
|
||||||
|
<label class="loan-checklist-item">
|
||||||
|
<input type="checkbox" [(ngModel)]="a.signedByCommittee" /> Committee
|
||||||
|
</label>
|
||||||
|
<label class="loan-checklist-item">
|
||||||
|
<input type="checkbox" [(ngModel)]="a.signedByRiskApproval" /> Risk Approval
|
||||||
|
</label>
|
||||||
|
<label class="loan-checklist-item">
|
||||||
|
<input type="checkbox" [(ngModel)]="a.signedByLegalApproval" /> Legal Approval
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Remarks</span>
|
||||||
|
<input type="text" [(ngModel)]="actionRemarks" placeholder="Remarks" />
|
||||||
|
</label>
|
||||||
|
<div class="loan-action-row">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--success"
|
||||||
|
[disabled]="!postCheckSignaturesDone(a)"
|
||||||
|
(click)="approveStage(a, 'postCheckApproval', 'Post-Disbursement Check')"
|
||||||
|
>
|
||||||
|
Clear for Disbursement
|
||||||
|
</button>
|
||||||
|
<button type="button" class="pg-btn pg-btn--warn" (click)="exceptionApprove(a)">
|
||||||
|
Approve on Exception
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--danger"
|
||||||
|
(click)="rejectStage(a, 'postCheckApproval', 'Post-Disbursement Check')"
|
||||||
|
>
|
||||||
|
Reject
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- ===================== DISBURSEMENT ===================== -->
|
||||||
|
@if (a.currentStage === 'DISBURSED') {
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">Ready for Disbursement</div>
|
||||||
|
<div class="loan-action-card">
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Disbursement Mode</span>
|
||||||
|
<select [(ngModel)]="disbursementMode">
|
||||||
|
<option value="Account Credit">Account Credit</option>
|
||||||
|
<option value="NEFT">NEFT</option>
|
||||||
|
<option value="RTGS">RTGS</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<p class="pg-sub npa-report-note">
|
||||||
|
Disbursing also confirms EMI Activation for this account in the same step.
|
||||||
|
</p>
|
||||||
|
<div class="loan-action-row">
|
||||||
|
<button type="button" class="pg-btn pg-btn--success" (click)="disburseNow(a)">
|
||||||
|
Disburse Now & Confirm EMI Activation
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">Approval Summary</div>
|
||||||
|
<div class="loan-approval-summary">
|
||||||
|
<div
|
||||||
|
class="loan-approval-tile"
|
||||||
|
[class.loan-approval-tile--approved]="
|
||||||
|
a.creditCommitteeApproval.decision === 'APPROVED' ||
|
||||||
|
a.creditCommitteeApproval.decision === 'EXCEPTION_APPROVED'
|
||||||
|
"
|
||||||
|
[class.loan-approval-tile--rejected]="a.creditCommitteeApproval.decision === 'REJECTED'"
|
||||||
|
[class.loan-approval-tile--pending]="a.creditCommitteeApproval.decision === 'PENDING'"
|
||||||
|
>
|
||||||
|
<span class="loan-approval-tile__role">Loan Committee</span>
|
||||||
|
<span class="loan-approval-tile__decision">{{ a.creditCommitteeApproval.decision }}</span>
|
||||||
|
<span>{{ a.creditCommitteeApproval.approverName }} · {{ a.creditCommitteeApproval.date }}</span>
|
||||||
|
<span>{{ a.creditCommitteeApproval.remarks }}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="loan-approval-tile"
|
||||||
|
[class.loan-approval-tile--approved]="
|
||||||
|
a.riskApproval.decision === 'APPROVED' || a.riskApproval.decision === 'EXCEPTION_APPROVED'
|
||||||
|
"
|
||||||
|
[class.loan-approval-tile--rejected]="a.riskApproval.decision === 'REJECTED'"
|
||||||
|
[class.loan-approval-tile--pending]="a.riskApproval.decision === 'PENDING'"
|
||||||
|
>
|
||||||
|
<span class="loan-approval-tile__role">Risk Approval</span>
|
||||||
|
<span class="loan-approval-tile__decision">{{ a.riskApproval.decision }}</span>
|
||||||
|
<span>{{ a.riskApproval.approverName }} · {{ a.riskApproval.date }}</span>
|
||||||
|
<span>{{ a.riskApproval.remarks }}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="loan-approval-tile"
|
||||||
|
[class.loan-approval-tile--approved]="
|
||||||
|
a.legalApproval.decision === 'APPROVED' || a.legalApproval.decision === 'EXCEPTION_APPROVED'
|
||||||
|
"
|
||||||
|
[class.loan-approval-tile--rejected]="a.legalApproval.decision === 'REJECTED'"
|
||||||
|
[class.loan-approval-tile--pending]="a.legalApproval.decision === 'PENDING'"
|
||||||
|
>
|
||||||
|
<span class="loan-approval-tile__role">Legal Approval</span>
|
||||||
|
<span class="loan-approval-tile__decision">{{ a.legalApproval.decision }}</span>
|
||||||
|
<span>{{ a.legalApproval.approverName }} · {{ a.legalApproval.date }}</span>
|
||||||
|
<span>{{ a.legalApproval.remarks }}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="loan-approval-tile"
|
||||||
|
[class.loan-approval-tile--approved]="
|
||||||
|
a.postCheckApproval.decision === 'APPROVED' || a.postCheckApproval.decision === 'EXCEPTION_APPROVED'
|
||||||
|
"
|
||||||
|
[class.loan-approval-tile--rejected]="a.postCheckApproval.decision === 'REJECTED'"
|
||||||
|
[class.loan-approval-tile--pending]="a.postCheckApproval.decision === 'PENDING'"
|
||||||
|
>
|
||||||
|
<span class="loan-approval-tile__role">Post-Disbursement Check</span>
|
||||||
|
<span class="loan-approval-tile__decision">{{ a.postCheckApproval.decision }}</span>
|
||||||
|
<span>{{ a.postCheckApproval.approverName }} · {{ a.postCheckApproval.date }}</span>
|
||||||
|
<span>{{ a.postCheckApproval.remarks }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">Application Journey — Date-wise Audit Trail</div>
|
||||||
|
<div class="npa-timeline" style="padding: 4px 18px 18px">
|
||||||
|
@for (h of a.stageHistory; track h.date + h.stage + h.outcome) {
|
||||||
|
<div [class]="historyToneClass(h.outcome)">
|
||||||
|
<span class="npa-timeline-item__icon">{{ stepMarker(a, h.stage) }}</span>
|
||||||
|
<div class="npa-timeline-item__body">
|
||||||
|
<div class="npa-timeline-item__row">
|
||||||
|
<span class="npa-timeline-item__type">{{ stageLabels[h.stage] }} — {{ h.outcome }}</span>
|
||||||
|
<span class="npa-timeline-item__date">{{ h.date }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="npa-timeline-item__remarks">{{ h.remarks }}</p>
|
||||||
|
<span class="npa-timeline-item__channel">{{ h.actionBy }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @empty {
|
||||||
|
<p class="pg-empty">No activity recorded yet.</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">Application Not Found</div>
|
||||||
|
<div class="loan-action-card">
|
||||||
|
<button type="button" class="pg-btn pg-btn--ghost" (click)="goBack()">‹ Back to Pipeline</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,418 @@
|
|||||||
|
/**
|
||||||
|
* 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, OnDestroy, OnInit, inject } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||||
|
import { Subscription } from 'rxjs';
|
||||||
|
import { AuthenticationService } from '../../core/authentication/authentication.service';
|
||||||
|
import {
|
||||||
|
ApprovalField,
|
||||||
|
BRANCHES,
|
||||||
|
BranchMaster,
|
||||||
|
DiscrepancyType,
|
||||||
|
DISCREPANCY_LABELS,
|
||||||
|
EmiScheduleRow,
|
||||||
|
LoanApplication,
|
||||||
|
LOAN_APPLICATIONS,
|
||||||
|
OriginationStage,
|
||||||
|
STAGE_LABELS,
|
||||||
|
STAGE_ORDER,
|
||||||
|
STATUS_LABELS,
|
||||||
|
allotShareMembership,
|
||||||
|
buildEmiSchedule,
|
||||||
|
calcEligibleAmount,
|
||||||
|
calculateEmi,
|
||||||
|
checkCreditScore,
|
||||||
|
cibilBand,
|
||||||
|
CIC_BUREAUS,
|
||||||
|
clearPersonalDiscussionAndProceed,
|
||||||
|
confirmChequeAch,
|
||||||
|
decideApproval,
|
||||||
|
disburseApplication,
|
||||||
|
flagDiscrepancy,
|
||||||
|
isRevolvingProduct,
|
||||||
|
markCustomerSigned,
|
||||||
|
progressPct,
|
||||||
|
pushFieldInquiry,
|
||||||
|
resolveDiscrepancyAndProceed,
|
||||||
|
sanctioningAuthority,
|
||||||
|
setMonthlyIncome,
|
||||||
|
stageBadgeClass,
|
||||||
|
stageIndex,
|
||||||
|
proceedToNextStage,
|
||||||
|
recordVerification,
|
||||||
|
todayDDMMYYYY,
|
||||||
|
uploadAgentReport,
|
||||||
|
uploadCicReport
|
||||||
|
} from '../loan-origination-data';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single application's full sanctioning journey — the stage stepper, the current stage's
|
||||||
|
* action card (checklist / CIC upload / Field Investigation / Committee-Risk-Legal approval /
|
||||||
|
* signing / disbursement), and the complete date-wise audit trail. Every action here mutates the
|
||||||
|
* SAME LoanApplication object the Pipeline list reads, so the two screens can never disagree.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-application-detail',
|
||||||
|
templateUrl: './application-detail.component.html',
|
||||||
|
styleUrls: [
|
||||||
|
'../../cashier/cashier-shared.scss',
|
||||||
|
'../../investment/investment-shared.scss',
|
||||||
|
'../../npa/npa-shared.scss',
|
||||||
|
'../loan-origination-shared.scss'
|
||||||
|
],
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
RouterModule
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class ApplicationDetailComponent implements OnInit, OnDestroy {
|
||||||
|
readonly stageOrder = STAGE_ORDER;
|
||||||
|
readonly stageLabels = STAGE_LABELS;
|
||||||
|
readonly statusLabels = STATUS_LABELS;
|
||||||
|
readonly discrepancyLabels = DISCREPANCY_LABELS;
|
||||||
|
asOnDate = todayDDMMYYYY();
|
||||||
|
|
||||||
|
application: LoanApplication | undefined;
|
||||||
|
|
||||||
|
/** Shared remarks box for whichever action card is currently on screen. */
|
||||||
|
actionRemarks = '';
|
||||||
|
discrepancySelection: DiscrepancyType = 'NONE';
|
||||||
|
disbursementMode = 'Account Credit';
|
||||||
|
/** KYC stage's Monthly Income entry field — blank until the officer keys it in. */
|
||||||
|
monthlyIncomeInput: number | null = null;
|
||||||
|
|
||||||
|
currentUserName = 'Branch User';
|
||||||
|
|
||||||
|
/** True for a Head Office login, which can open any branch's application — a branch-level
|
||||||
|
* login is locked to its own branch, the same split every other report in the app uses. */
|
||||||
|
isHeadOffice = false;
|
||||||
|
private ownBranchCode: string | null = null;
|
||||||
|
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
private readonly auth = inject(AuthenticationService);
|
||||||
|
private sub?: Subscription;
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
const creds = this.auth.getCredentials();
|
||||||
|
this.currentUserName = creds?.username || 'Branch User';
|
||||||
|
this.isHeadOffice = this.auth.isHeadOfficeUser();
|
||||||
|
if (!this.isHeadOffice) {
|
||||||
|
const officeName = creds?.officeName || '';
|
||||||
|
this.ownBranchCode =
|
||||||
|
BRANCHES.find((b: BranchMaster) => officeName.toLowerCase().includes(b.name.toLowerCase()))?.code || null;
|
||||||
|
}
|
||||||
|
this.sub = this.route.paramMap.subscribe((params) => {
|
||||||
|
const applicationNo = params.get('applicationNo');
|
||||||
|
const found = LOAN_APPLICATIONS.find((a) => a.applicationNo === applicationNo);
|
||||||
|
// A branch-level login can only open applications belonging to its own branch — treated
|
||||||
|
// exactly like "not found" rather than a distinct "access denied", so it doesn't reveal
|
||||||
|
// that a differently-branched application exists at all.
|
||||||
|
this.application = found && !this.isHeadOffice && found.branchCode !== this.ownBranchCode ? undefined : found;
|
||||||
|
this.actionRemarks = '';
|
||||||
|
this.discrepancySelection = 'NONE';
|
||||||
|
this.monthlyIncomeInput = this.application?.monthlyIncome || null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.sub?.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
goBack(): void {
|
||||||
|
this.router.navigate(['/loan-origination/pipeline']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------- Stepper --------------------
|
||||||
|
stepStatusClass(a: LoanApplication, stage: OriginationStage): string {
|
||||||
|
const idx = stageIndex(stage);
|
||||||
|
const curIdx = stageIndex(a.currentStage);
|
||||||
|
if (stage === a.currentStage && a.overallStatus === 'REJECTED') {
|
||||||
|
return 'loan-step loan-step--rejected';
|
||||||
|
}
|
||||||
|
if (stage === a.currentStage && a.overallStatus === 'ON_HOLD') {
|
||||||
|
return 'loan-step loan-step--hold';
|
||||||
|
}
|
||||||
|
if (idx < curIdx || (idx === curIdx && a.overallStatus === 'DISBURSED')) {
|
||||||
|
return 'loan-step loan-step--done';
|
||||||
|
}
|
||||||
|
if (idx === curIdx) {
|
||||||
|
return 'loan-step loan-step--current';
|
||||||
|
}
|
||||||
|
return 'loan-step';
|
||||||
|
}
|
||||||
|
|
||||||
|
stepMarker(a: LoanApplication, stage: OriginationStage): string {
|
||||||
|
const idx = stageIndex(stage);
|
||||||
|
const curIdx = stageIndex(a.currentStage);
|
||||||
|
if (idx < curIdx || (idx === curIdx && a.overallStatus === 'DISBURSED')) {
|
||||||
|
return '✓';
|
||||||
|
}
|
||||||
|
return String(idx + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
progress(a: LoanApplication): number {
|
||||||
|
return progressPct(a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Colours an audit-trail entry's icon badge by what actually happened — a rejection/hold
|
||||||
|
* reads as an alert, everything else (approvals, verifications, uploads, signings) reads
|
||||||
|
* as positive progress. */
|
||||||
|
historyToneClass(outcome: string): string {
|
||||||
|
if (/reject|hold/i.test(outcome)) {
|
||||||
|
return 'npa-timeline-item npa-timeline-item--alert';
|
||||||
|
}
|
||||||
|
return 'npa-timeline-item npa-timeline-item--positive';
|
||||||
|
}
|
||||||
|
|
||||||
|
badgeClass(a: LoanApplication): string {
|
||||||
|
return stageBadgeClass(a);
|
||||||
|
}
|
||||||
|
|
||||||
|
cibilBandOf(score: number): string {
|
||||||
|
return cibilBand(score);
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly cicBureaus = CIC_BUREAUS;
|
||||||
|
|
||||||
|
checkCreditScoreNow(a: LoanApplication): void {
|
||||||
|
checkCreditScore(a, this.currentUserName);
|
||||||
|
}
|
||||||
|
|
||||||
|
sanctioningAuthorityOf(a: LoanApplication): string {
|
||||||
|
return sanctioningAuthority(a.requestedAmount);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------- Generic stage-advance actions --------------------
|
||||||
|
proceed(a: LoanApplication, remarks: string): void {
|
||||||
|
proceedToNextStage(a, this.currentUserName, remarks);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------- KYC & Documents --------------------
|
||||||
|
/** Live preview of what Eligible Loan Amount would be for whatever's currently typed into the
|
||||||
|
* Monthly Income box — before the officer actually commits it via "Assess Income". */
|
||||||
|
eligiblePreview(a: LoanApplication): number {
|
||||||
|
return calcEligibleAmount(this.monthlyIncomeInput || 0, a.productCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
setIncomeNow(a: LoanApplication): void {
|
||||||
|
if (!this.monthlyIncomeInput || this.monthlyIncomeInput <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMonthlyIncome(a, this.monthlyIncomeInput, this.currentUserName);
|
||||||
|
}
|
||||||
|
|
||||||
|
allKycDone(a: LoanApplication): boolean {
|
||||||
|
const base =
|
||||||
|
a.aadhaarVerified &&
|
||||||
|
a.panVerified &&
|
||||||
|
a.aadhaarFingerprintCaptured &&
|
||||||
|
a.incomeProofSubmitted &&
|
||||||
|
a.bankStatementSubmitted &&
|
||||||
|
a.electricityBillSubmitted &&
|
||||||
|
a.monthlyIncome > 0;
|
||||||
|
return a.secured ? base && a.propertyDetailsSubmitted && a.taxReceiptSubmitted : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------- Field Investigation --------------------
|
||||||
|
fiComplete(a: LoanApplication): boolean {
|
||||||
|
const addressOk = a.addressVerification.status === 'VERIFIED';
|
||||||
|
const officeOk = a.officeVerification.status === 'VERIFIED';
|
||||||
|
const propertyOk = !a.secured || a.propertyVerification.status === 'VERIFIED';
|
||||||
|
return addressOk && officeOk && propertyOk;
|
||||||
|
}
|
||||||
|
|
||||||
|
completeFi(a: LoanApplication): void {
|
||||||
|
const agent = 'Field Verification Team';
|
||||||
|
const keys: Array<'addressVerification' | 'officeVerification' | 'propertyVerification'> = [
|
||||||
|
'addressVerification',
|
||||||
|
'officeVerification',
|
||||||
|
'propertyVerification'
|
||||||
|
];
|
||||||
|
for (const key of keys) {
|
||||||
|
if (a[key].status !== 'PENDING') {
|
||||||
|
recordVerification(
|
||||||
|
a,
|
||||||
|
key,
|
||||||
|
a[key].status,
|
||||||
|
agent,
|
||||||
|
a[key].remarks === '—' ? `${key} recorded on FI visit.` : a[key].remarks
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.proceed(a, 'Field Investigation completed — Agent Report filed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------- Personal Discussion --------------------
|
||||||
|
clearPersonalDiscussion(a: LoanApplication): void {
|
||||||
|
clearPersonalDiscussionAndProceed(
|
||||||
|
a,
|
||||||
|
this.currentUserName,
|
||||||
|
this.actionRemarks.trim() || 'No discrepancy found — cleared to proceed to Loan Committee.'
|
||||||
|
);
|
||||||
|
this.actionRemarks = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
flagPersonalDiscussion(a: LoanApplication): void {
|
||||||
|
const notes =
|
||||||
|
this.actionRemarks.trim() ||
|
||||||
|
`${this.discrepancyLabels[this.discrepancySelection]} flagged during personal discussion — held pending resolution.`;
|
||||||
|
flagDiscrepancy(a, this.discrepancySelection, this.currentUserName, notes);
|
||||||
|
this.actionRemarks = '';
|
||||||
|
this.discrepancySelection = 'NONE';
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveDiscrepancy(a: LoanApplication): void {
|
||||||
|
resolveDiscrepancyAndProceed(
|
||||||
|
a,
|
||||||
|
this.currentUserName,
|
||||||
|
this.actionRemarks.trim() || 'Discrepancy resolved on further review — cleared to proceed.'
|
||||||
|
);
|
||||||
|
this.actionRemarks = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------- Share Membership --------------------
|
||||||
|
allotMembership(a: LoanApplication): void {
|
||||||
|
allotShareMembership(a, this.currentUserName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------- Approvals (Credit Committee / Risk / Legal / Post-Check) --------------------
|
||||||
|
approveStage(a: LoanApplication, field: ApprovalField, approverRole: string): void {
|
||||||
|
decideApproval(
|
||||||
|
a,
|
||||||
|
field,
|
||||||
|
'APPROVED',
|
||||||
|
this.currentUserName,
|
||||||
|
approverRole,
|
||||||
|
this.actionRemarks.trim() || 'Reviewed and approved.'
|
||||||
|
);
|
||||||
|
this.actionRemarks = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
rejectStage(a: LoanApplication, field: ApprovalField, approverRole: string): void {
|
||||||
|
decideApproval(
|
||||||
|
a,
|
||||||
|
field,
|
||||||
|
'REJECTED',
|
||||||
|
this.currentUserName,
|
||||||
|
approverRole,
|
||||||
|
this.actionRemarks.trim() || 'Declined on review.'
|
||||||
|
);
|
||||||
|
this.actionRemarks = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
exceptionApprove(a: LoanApplication): void {
|
||||||
|
decideApproval(
|
||||||
|
a,
|
||||||
|
'postCheckApproval',
|
||||||
|
'EXCEPTION_APPROVED',
|
||||||
|
this.currentUserName,
|
||||||
|
'Post-Disbursement Check',
|
||||||
|
this.actionRemarks.trim() || 'Approved on exception — minor gap noted and accepted.'
|
||||||
|
);
|
||||||
|
this.actionRemarks = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
legalChecklistDone(a: LoanApplication): boolean {
|
||||||
|
return a.feesAndAccountOpened && (!a.secured || a.mortgageCreated) && a.legalStampDone && a.fileFilingDone;
|
||||||
|
}
|
||||||
|
|
||||||
|
committeeReportsReady(a: LoanApplication): boolean {
|
||||||
|
return (
|
||||||
|
a.cicReportUploaded &&
|
||||||
|
a.backgroundCheckDone &&
|
||||||
|
(!a.secured || a.cersaiSearchDone) &&
|
||||||
|
a.aadhaarVerified &&
|
||||||
|
a.panVerified &&
|
||||||
|
a.incomeProofSubmitted &&
|
||||||
|
this.fiComplete(a) &&
|
||||||
|
a.managerMeetingDone
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
riskChecklistReady(a: LoanApplication): boolean {
|
||||||
|
return (
|
||||||
|
a.addressVerification.status === 'VERIFIED' &&
|
||||||
|
a.incomeProofSubmitted &&
|
||||||
|
a.previousLoanDeclarationDone &&
|
||||||
|
(!a.secured || (a.propertySearchReportDone && a.valuationReportConfirmed)) &&
|
||||||
|
this.fiComplete(a) &&
|
||||||
|
a.backgroundCheckDone
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
postCheckSignaturesDone(a: LoanApplication): boolean {
|
||||||
|
return (
|
||||||
|
a.signedByAgent && a.signedByManager && a.signedByCommittee && a.signedByRiskApproval && a.signedByLegalApproval
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------- CIC Upload --------------------
|
||||||
|
onCicFileSelected(event: Event, a: LoanApplication): void {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const file = input.files?.[0];
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
uploadCicReport(a, file.name, this.currentUserName);
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
pushFieldInquiryNow(a: LoanApplication): void {
|
||||||
|
pushFieldInquiry(a, 'Field Verification Team', this.currentUserName);
|
||||||
|
}
|
||||||
|
|
||||||
|
onAgentReportFileSelected(event: Event, a: LoanApplication): void {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const file = input.files?.[0];
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
uploadAgentReport(a, file.name, this.currentUserName);
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------- Signing / Cheque-ACH / Disbursement --------------------
|
||||||
|
markSigned(a: LoanApplication): void {
|
||||||
|
markCustomerSigned(a, a.applicantName);
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmChequeAndProceed(a: LoanApplication): void {
|
||||||
|
confirmChequeAch(a, this.currentUserName);
|
||||||
|
}
|
||||||
|
|
||||||
|
disburseNow(a: LoanApplication): void {
|
||||||
|
disburseApplication(a, this.currentUserName, this.disbursementMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------- EMI Repayment Schedule (once disbursed) --------------------
|
||||||
|
isRevolving(a: LoanApplication): boolean {
|
||||||
|
return isRevolvingProduct(a.productCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
emiAmountFor(a: LoanApplication): number {
|
||||||
|
return calculateEmi(a.requestedAmount, a.interestRate, a.tenureMonths);
|
||||||
|
}
|
||||||
|
|
||||||
|
emiScheduleFor(a: LoanApplication): EmiScheduleRow[] {
|
||||||
|
return buildEmiSchedule(a.requestedAmount, a.interestRate, a.tenureMonths, a.disbursementDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
totalInterestFor(a: LoanApplication): number {
|
||||||
|
return this.emiScheduleFor(a).reduce((s, r) => s + r.interestComponent, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
totalPayableFor(a: LoanApplication): number {
|
||||||
|
return a.requestedAmount + this.totalInterestFor(a);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* 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 { NgModule } from '@angular/core';
|
||||||
|
import { Routes, RouterModule } from '@angular/router';
|
||||||
|
|
||||||
|
import { Route } from '../core/route/route.service';
|
||||||
|
import { LoanPipelineComponent } from './loan-pipeline/loan-pipeline.component';
|
||||||
|
import { ApplicationDetailComponent } from './application-detail/application-detail.component';
|
||||||
|
|
||||||
|
const routes: Routes = [
|
||||||
|
Route.withShell([
|
||||||
|
{
|
||||||
|
path: 'loan-origination',
|
||||||
|
data: { title: 'Loan Origination', breadcrumb: 'Loan Origination' },
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
redirectTo: 'pipeline',
|
||||||
|
pathMatch: 'full'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'pipeline',
|
||||||
|
component: LoanPipelineComponent,
|
||||||
|
data: { title: 'Loan Origination Pipeline', breadcrumb: 'Pipeline' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'application/:applicationNo',
|
||||||
|
component: ApplicationDetailComponent,
|
||||||
|
data: { title: 'Loan Application', breadcrumb: 'Application' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
])
|
||||||
|
];
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
imports: [RouterModule.forChild(routes)],
|
||||||
|
exports: [RouterModule]
|
||||||
|
})
|
||||||
|
export class LoanOriginationRoutingModule {}
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Loan Origination-specific styling layered on top of ../cashier/cashier-shared.scss (pg-*,
|
||||||
|
// cctr-*) and ../npa/npa-shared.scss (npa-timeline-* — reused as-is for the application's stage
|
||||||
|
// history/audit trail, since it's the same generic "date-led event log" primitive already built
|
||||||
|
// for the NPA Recovery Journey popup, not NPA-specific).
|
||||||
|
|
||||||
|
// EMI Repayment Schedule can run to 240 rows (a 20-year Housing Loan) — scrolls within a fixed
|
||||||
|
// height with the header pinned, rather than pushing the rest of the page far down.
|
||||||
|
.loan-emi-table-scroll {
|
||||||
|
max-height: 420px;
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
|
thead th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loan-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&--progress {
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #1d4ed8;
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--hold {
|
||||||
|
background: #fffbeb;
|
||||||
|
color: #b45309;
|
||||||
|
border: 1px solid #fde68a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--rejected {
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #dc2626;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--disbursed {
|
||||||
|
background: #f0fdf4;
|
||||||
|
color: #15803d;
|
||||||
|
border: 1px solid #bbf7d0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pg-btn--success {
|
||||||
|
background: linear-gradient(135deg, #22c55e, #15803d);
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pg-btn--danger {
|
||||||
|
background: #fff;
|
||||||
|
border-color: #fecaca;
|
||||||
|
color: #dc2626;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #fef2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pg-btn--warn {
|
||||||
|
background: #fff;
|
||||||
|
border-color: #fde68a;
|
||||||
|
color: #b45309;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #fffbeb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Stage stepper — the 12-stage sanctioning pipeline, drawn as a connected row of
|
||||||
|
// dots so an application's progress reads at a glance.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.loan-stepper {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 22px 18px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loan-step {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 90px;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&__connector {
|
||||||
|
position: absolute;
|
||||||
|
top: 15px;
|
||||||
|
left: -50%;
|
||||||
|
width: 100%;
|
||||||
|
height: 2px;
|
||||||
|
background: #e2e8f0;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:first-child &__connector {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__dot {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 999px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #94a3b8;
|
||||||
|
border: 2px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 10.5px;
|
||||||
|
text-align: center;
|
||||||
|
color: #64748b;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.3;
|
||||||
|
max-width: 92px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--done {
|
||||||
|
.loan-step__dot {
|
||||||
|
background: #15803d;
|
||||||
|
border-color: #15803d;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loan-step__connector {
|
||||||
|
background: #86efac;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loan-step__label {
|
||||||
|
color: #15803d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--current {
|
||||||
|
.loan-step__dot {
|
||||||
|
background: #4338ca;
|
||||||
|
border-color: #4338ca;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 0 0 4px rgb(67 56 202 / 15%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loan-step__label {
|
||||||
|
color: #4338ca;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--rejected {
|
||||||
|
.loan-step__dot {
|
||||||
|
background: #dc2626;
|
||||||
|
border-color: #dc2626;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loan-step__label {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--hold {
|
||||||
|
.loan-step__dot {
|
||||||
|
background: #d97706;
|
||||||
|
border-color: #d97706;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loan-step__label {
|
||||||
|
color: #d97706;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Action card — the current stage's checklist/inputs and Approve/Reject buttons.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.loan-action-card {
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
margin: 0 16px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loan-checklist {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loan-checklist-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #1e293b;
|
||||||
|
|
||||||
|
// stylelint-disable-next-line selector-no-qualifying-type
|
||||||
|
input[type='checkbox'] {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
accent-color: #4338ca;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loan-action-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loan-kv-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 12px 20px;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loan-kv-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
|
||||||
|
dt {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
dd {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #0d1b2a;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loan-approval-summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
padding: 4px 18px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loan-approval-tile {
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
&__role {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__decision {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--approved .loan-approval-tile__decision {
|
||||||
|
color: #15803d;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--rejected .loan-approval-tile__decision {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--pending .loan-approval-tile__decision {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* 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 { NgModule } from '@angular/core';
|
||||||
|
|
||||||
|
import { SharedModule } from '../shared/shared.module';
|
||||||
|
import { LoanOriginationRoutingModule } from './loan-origination-routing.module';
|
||||||
|
import { LoanPipelineComponent } from './loan-pipeline/loan-pipeline.component';
|
||||||
|
import { ApplicationDetailComponent } from './application-detail/application-detail.component';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
imports: [
|
||||||
|
SharedModule,
|
||||||
|
LoanOriginationRoutingModule,
|
||||||
|
LoanPipelineComponent,
|
||||||
|
ApplicationDetailComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class LoanOriginationModule {}
|
||||||
@@ -0,0 +1,570 @@
|
|||||||
|
<!--
|
||||||
|
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="pg">
|
||||||
|
<header class="pg-head">
|
||||||
|
<div>
|
||||||
|
<div class="pg-eyebrow">CREDIT · LOAN ORIGINATION · {{ scopeName | uppercase }}</div>
|
||||||
|
<h1 class="pg-title">Loan Origination Pipeline</h1>
|
||||||
|
<p class="pg-sub npa-report-note">
|
||||||
|
Every sanctioning stage from application intake to disbursement — KYC/OVD, CIC credit check, Field
|
||||||
|
Investigation, Personal Discussion, Credit Committee, Risk & Legal Approval — as on {{ asOnDate }}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="pg-toolbar">
|
||||||
|
@if (isHeadOffice) {
|
||||||
|
<label class="pg-field">
|
||||||
|
<span>Branch</span>
|
||||||
|
<select class="pg-select" [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>
|
||||||
|
</label>
|
||||||
|
} @else {
|
||||||
|
<label class="pg-field">
|
||||||
|
<span>Branch</span>
|
||||||
|
<span class="pg-chip">{{ scopeName }}</span>
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="generating" (click)="exportExcel()">
|
||||||
|
{{ generating ? 'Exporting…' : 'Export to Excel' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="inv-kpis npa-kpis--ratio">
|
||||||
|
<div class="inv-kpi">
|
||||||
|
<span class="inv-kpi__label">Total Applications</span>
|
||||||
|
<span class="inv-kpi__value">{{ totalApplications }}</span>
|
||||||
|
<span class="inv-kpi__sub">in the active pipeline</span>
|
||||||
|
</div>
|
||||||
|
<div class="inv-kpi">
|
||||||
|
<span class="inv-kpi__label">In Progress</span>
|
||||||
|
<span class="inv-kpi__value">{{ inProgressCount }}</span>
|
||||||
|
<span class="inv-kpi__sub">moving through the workflow</span>
|
||||||
|
</div>
|
||||||
|
<div class="inv-kpi">
|
||||||
|
<span class="inv-kpi__label">On Hold</span>
|
||||||
|
<span class="inv-kpi__value npa-warn">{{ onHoldCount }}</span>
|
||||||
|
<span class="inv-kpi__sub">discrepancy under resolution</span>
|
||||||
|
</div>
|
||||||
|
<div class="inv-kpi">
|
||||||
|
<span class="inv-kpi__label">Rejected</span>
|
||||||
|
<span class="inv-kpi__value npa-warn">{{ rejectedCount }}</span>
|
||||||
|
<span class="inv-kpi__sub">declined at Committee/Risk</span>
|
||||||
|
</div>
|
||||||
|
<div class="inv-kpi">
|
||||||
|
<span class="inv-kpi__label">Disbursed</span>
|
||||||
|
<span class="inv-kpi__value">{{ disbursedApplications.length }}</span>
|
||||||
|
<span class="inv-kpi__sub">₹{{ disbursedAmount | number: '1.0-0' }} total</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cctr-tabs">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--ghost"
|
||||||
|
[class.pg-btn--active]="activeView === 'pipeline'"
|
||||||
|
(click)="setView('pipeline')"
|
||||||
|
>
|
||||||
|
Pipeline
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--ghost"
|
||||||
|
[class.pg-btn--active]="activeView === 'new'"
|
||||||
|
(click)="setView('new')"
|
||||||
|
>
|
||||||
|
New Application
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--ghost"
|
||||||
|
[class.pg-btn--active]="activeView === 'channel'"
|
||||||
|
(click)="setView('channel')"
|
||||||
|
>
|
||||||
|
Channel-wise Report
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--ghost"
|
||||||
|
[class.pg-btn--active]="activeView === 'bc'"
|
||||||
|
(click)="setView('bc')"
|
||||||
|
>
|
||||||
|
BC-wise Report
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--ghost"
|
||||||
|
[class.pg-btn--active]="activeView === 'share'"
|
||||||
|
(click)="setView('share')"
|
||||||
|
>
|
||||||
|
Share Member Report
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--ghost"
|
||||||
|
[class.pg-btn--active]="activeView === 'target'"
|
||||||
|
(click)="setView('target')"
|
||||||
|
>
|
||||||
|
Target vs Actual
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===================== PIPELINE ===================== -->
|
||||||
|
@if (activeView === 'pipeline') {
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">Filters</div>
|
||||||
|
<div class="inv-form-grid">
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Channel</span>
|
||||||
|
<select [(ngModel)]="channelFilter" (ngModelChange)="currentPage = 1">
|
||||||
|
<option value="">-- All --</option>
|
||||||
|
<option value="BRANCH">Branch</option>
|
||||||
|
<option value="BC">Business Correspondent (BC)</option>
|
||||||
|
<option value="SELF_APPLY">Self-Apply (Digital)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Stage</span>
|
||||||
|
<select [(ngModel)]="stageFilter" (ngModelChange)="currentPage = 1">
|
||||||
|
<option value="">-- All --</option>
|
||||||
|
@for (s of stageOrder; track s) {
|
||||||
|
<option [value]="s">{{ stageLabels[s] }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Status</span>
|
||||||
|
<select [(ngModel)]="statusFilter" (ngModelChange)="currentPage = 1">
|
||||||
|
<option value="">-- All --</option>
|
||||||
|
<option value="IN_PROGRESS">In Progress</option>
|
||||||
|
<option value="ON_HOLD">On Hold</option>
|
||||||
|
<option value="REJECTED">Rejected</option>
|
||||||
|
<option value="DISBURSED">Disbursed</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Search (Application No / Applicant)</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="searchText"
|
||||||
|
(ngModelChange)="currentPage = 1"
|
||||||
|
placeholder="e.g. Ramesh, LN0102..."
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">Applications ({{ filteredApplications.length }})</div>
|
||||||
|
<div class="pg-table-scroll">
|
||||||
|
<table class="pg-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Application No</th>
|
||||||
|
<th>Applicant</th>
|
||||||
|
<th>Branch</th>
|
||||||
|
<th>Channel</th>
|
||||||
|
<th>Product</th>
|
||||||
|
<th class="num">Requested Amount</th>
|
||||||
|
<th>Current Stage</th>
|
||||||
|
<th class="num">Progress</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (a of paged(filteredApplications); track a.applicationNo) {
|
||||||
|
<tr class="cctr-branch-row" (click)="openApplication(a)">
|
||||||
|
<td>
|
||||||
|
<span class="pg-chip">{{ a.applicationNo }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ a.applicantName }}</td>
|
||||||
|
<td>{{ a.branchName }}</td>
|
||||||
|
<td>{{ channelLabels[a.channel] }}</td>
|
||||||
|
<td>{{ a.productName }}</td>
|
||||||
|
<td class="num">₹{{ a.requestedAmount | number: '1.0-0' }}</td>
|
||||||
|
<td>{{ stageLabel(a.currentStage) }}</td>
|
||||||
|
<td class="num">{{ progress(a) }}%</td>
|
||||||
|
<td>
|
||||||
|
<span [class]="badgeClass(a)">{{ statusLabel(a.overallStatus) }}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="9" class="pg-empty">No applications match these filters.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
@if (filteredApplications.length > pageSize) {
|
||||||
|
<div class="pg-pagination">
|
||||||
|
<span class="pg-pagination__info"
|
||||||
|
>Showing {{ rangeStart(filteredApplications) }}–{{ rangeEnd(filteredApplications) }} of
|
||||||
|
{{ filteredApplications.length }}</span
|
||||||
|
>
|
||||||
|
<div class="pg-pagination__controls">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--ghost"
|
||||||
|
[disabled]="currentPage === 1"
|
||||||
|
(click)="goToPage(currentPage - 1, filteredApplications)"
|
||||||
|
>
|
||||||
|
‹ Prev
|
||||||
|
</button>
|
||||||
|
<span class="pg-pagination__page">Page {{ currentPage }} of {{ pageCount(filteredApplications) }}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--ghost"
|
||||||
|
[disabled]="currentPage === pageCount(filteredApplications)"
|
||||||
|
(click)="goToPage(currentPage + 1, filteredApplications)"
|
||||||
|
>
|
||||||
|
Next ›
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- ===================== NEW APPLICATION ===================== -->
|
||||||
|
@if (activeView === 'new') {
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">New Application — Channel Intake</div>
|
||||||
|
<div class="inv-form-grid">
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>* Channel</span>
|
||||||
|
<select [(ngModel)]="newApp.channel">
|
||||||
|
<option value="BRANCH">Branch</option>
|
||||||
|
<option value="BC">Business Correspondent (BC)</option>
|
||||||
|
<option value="SELF_APPLY">Self-Apply (Digital)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
@if (newApp.channel === 'BC') {
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>* BC Agent / Point</span>
|
||||||
|
<input type="text" [(ngModel)]="newApp.bcAgentName" placeholder="e.g. Suvidha BC Point — Andheri" />
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>* Customer Type</span>
|
||||||
|
<select [(ngModel)]="newApp.customerType">
|
||||||
|
<option value="NEW">New Customer</option>
|
||||||
|
<option value="EXISTING">Existing Customer</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>* Applicant Name</span>
|
||||||
|
<input type="text" [(ngModel)]="newApp.applicantName" placeholder="Full name" />
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>* Branch</span>
|
||||||
|
<select [(ngModel)]="newApp.branchCode" [disabled]="!isHeadOffice">
|
||||||
|
@for (b of newApplicationBranches; track b.code) {
|
||||||
|
<option [value]="b.code">{{ b.name }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>* Product</span>
|
||||||
|
<select [(ngModel)]="newApp.productCode">
|
||||||
|
@for (p of products; track p.code) {
|
||||||
|
<option [value]="p.code">{{ p.name }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>* Requested Amount</span>
|
||||||
|
<input type="number" [(ngModel)]="newApp.requestedAmount" placeholder="₹" />
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Tenure (Months)</span>
|
||||||
|
<input type="number" [(ngModel)]="newApp.tenureMonths" placeholder="e.g. 36" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="inv-form-actions">
|
||||||
|
<button type="button" class="pg-btn pg-btn--primary" (click)="saveNewApplication()">Submit Application</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- ===================== CHANNEL-WISE REPORT ===================== -->
|
||||||
|
@if (activeView === 'channel') {
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">Channel-wise Report — Branch / BC / Self-Apply</div>
|
||||||
|
<table class="pg-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Channel</th>
|
||||||
|
<th class="num">Applications</th>
|
||||||
|
<th class="num">Requested Amount</th>
|
||||||
|
<th class="num col-in">Disbursed Count</th>
|
||||||
|
<th class="num col-in">Disbursed Amount</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (r of channelReport; track r.channel) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ r.label }}</td>
|
||||||
|
<td class="num">{{ r.count }}</td>
|
||||||
|
<td class="num">₹{{ r.requestedAmount | number: '1.0-0' }}</td>
|
||||||
|
<td class="num col-in">{{ r.disbursedCount }}</td>
|
||||||
|
<td class="num col-in">₹{{ r.disbursedAmount | number: '1.0-0' }}</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="cctr-total-row">
|
||||||
|
<td>Total</td>
|
||||||
|
<td class="num">{{ totalApplications }}</td>
|
||||||
|
<td class="num">
|
||||||
|
₹{{
|
||||||
|
channelReport[0].requestedAmount + channelReport[1].requestedAmount + channelReport[2].requestedAmount
|
||||||
|
| number: '1.0-0'
|
||||||
|
}}
|
||||||
|
</td>
|
||||||
|
<td class="num">{{ disbursedApplications.length }}</td>
|
||||||
|
<td class="num">₹{{ disbursedAmount | number: '1.0-0' }}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- ===================== BC-WISE REPORT ===================== -->
|
||||||
|
@if (activeView === 'bc') {
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">
|
||||||
|
BC-wise Report — Business Correspondent Sourced Applications ({{ bcApplications.length }})
|
||||||
|
</div>
|
||||||
|
<table class="pg-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>BC Agent / Point</th>
|
||||||
|
<th class="num">Applications</th>
|
||||||
|
<th class="num">Requested Amount</th>
|
||||||
|
<th class="num col-in">Disbursed Count</th>
|
||||||
|
<th class="num col-in">Disbursed Amount</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (r of bcReport; track r.bcAgentName) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ r.bcAgentName }}</td>
|
||||||
|
<td class="num">{{ r.count }}</td>
|
||||||
|
<td class="num">₹{{ r.requestedAmount | number: '1.0-0' }}</td>
|
||||||
|
<td class="num col-in">{{ r.disbursedCount }}</td>
|
||||||
|
<td class="num col-in">₹{{ r.disbursedAmount | number: '1.0-0' }}</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="pg-empty">No BC-sourced applications in this branch.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- ===================== SHARE MEMBER REPORT ===================== -->
|
||||||
|
@if (activeView === 'share') {
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">Share Member Report</div>
|
||||||
|
<p class="pg-sub npa-report-note">
|
||||||
|
A cooperative-bank borrower must hold membership shares in the society before a loan can be sanctioned.
|
||||||
|
{{ shareMemberCount }} of {{ totalApplications }} applicants are already members; the rest need membership
|
||||||
|
allotted before Credit Committee approval — done from the application's own detail screen.
|
||||||
|
</p>
|
||||||
|
<div class="pg-table-scroll">
|
||||||
|
<table class="pg-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Application No</th>
|
||||||
|
<th>Applicant</th>
|
||||||
|
<th>Branch</th>
|
||||||
|
<th>Current Stage</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (a of paged(nonMemberApplications); track a.applicationNo) {
|
||||||
|
<tr class="cctr-branch-row" (click)="openApplication(a)">
|
||||||
|
<td>
|
||||||
|
<span class="pg-chip">{{ a.applicationNo }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ a.applicantName }}</td>
|
||||||
|
<td>{{ a.branchName }}</td>
|
||||||
|
<td>{{ stageLabel(a.currentStage) }}</td>
|
||||||
|
<td>
|
||||||
|
<span [class]="badgeClass(a)">{{ statusLabel(a.overallStatus) }}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="pg-empty">Every active applicant in this branch already holds membership.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
@if (nonMemberApplications.length > pageSize) {
|
||||||
|
<div class="pg-pagination">
|
||||||
|
<span class="pg-pagination__info"
|
||||||
|
>Showing {{ rangeStart(nonMemberApplications) }}–{{ rangeEnd(nonMemberApplications) }} of
|
||||||
|
{{ nonMemberApplications.length }}</span
|
||||||
|
>
|
||||||
|
<div class="pg-pagination__controls">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--ghost"
|
||||||
|
[disabled]="currentPage === 1"
|
||||||
|
(click)="goToPage(currentPage - 1, nonMemberApplications)"
|
||||||
|
>
|
||||||
|
‹ Prev
|
||||||
|
</button>
|
||||||
|
<span class="pg-pagination__page">Page {{ currentPage }} of {{ pageCount(nonMemberApplications) }}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--ghost"
|
||||||
|
[disabled]="currentPage === pageCount(nonMemberApplications)"
|
||||||
|
(click)="goToPage(currentPage + 1, nonMemberApplications)"
|
||||||
|
>
|
||||||
|
Next ›
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- ===================== TARGET VS ACTUAL ===================== -->
|
||||||
|
@if (activeView === 'target') {
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">Branch-wise Disbursement Target — Annual</div>
|
||||||
|
<table class="pg-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Branch</th>
|
||||||
|
<th class="num">Target</th>
|
||||||
|
<th class="num col-in">Disbursed (Actual)</th>
|
||||||
|
<th class="num">Achievement %</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (row of branchTargetRows; track row.branchCode) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ row.branchName }}</td>
|
||||||
|
<td class="num">₹{{ row.target | number: '1.0-0' }}</td>
|
||||||
|
<td class="num col-in">₹{{ row.actual | number: '1.0-0' }}</td>
|
||||||
|
<td class="num">{{ row.achievementPct | number: '1.1-1' }}%</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
class="loan-badge"
|
||||||
|
[class.loan-badge--disbursed]="row.status !== 'BEHIND'"
|
||||||
|
[class.loan-badge--hold]="row.status === 'BEHIND'"
|
||||||
|
>
|
||||||
|
{{ statusLabelOf(row.status) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pg-card">
|
||||||
|
<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">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Product</th>
|
||||||
|
<th>Target Basis</th>
|
||||||
|
<th class="num">Target %</th>
|
||||||
|
<th class="num col-in">Actual %</th>
|
||||||
|
<th class="num">Variance</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (row of productTargetRows; track row.code) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ row.label }}</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" [class.col-out]="row.variancePct < 0" [class.col-in]="row.variancePct > 0">
|
||||||
|
{{ row.variancePct > 0 ? '+' : '' }}{{ row.variancePct | number: '1.1-1' }} pp
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
class="loan-badge"
|
||||||
|
[class.loan-badge--disbursed]="row.status !== 'BEHIND'"
|
||||||
|
[class.loan-badge--hold]="row.status === 'BEHIND'"
|
||||||
|
>
|
||||||
|
{{ statusLabelOf(row.status) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">Channel Mix — Target % vs Actual % (of Disbursed Amount)</div>
|
||||||
|
<table class="pg-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Channel</th>
|
||||||
|
<th class="num">Target %</th>
|
||||||
|
<th class="num col-in">Actual %</th>
|
||||||
|
<th class="num">Variance</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (row of channelTargetRows; track row.code) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ row.label }}</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" [class.col-out]="row.variancePct < 0" [class.col-in]="row.variancePct > 0">
|
||||||
|
{{ row.variancePct > 0 ? '+' : '' }}{{ row.variancePct | number: '1.1-1' }} pp
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
class="loan-badge"
|
||||||
|
[class.loan-badge--disbursed]="row.status !== 'BEHIND'"
|
||||||
|
[class.loan-badge--hold]="row.status === 'BEHIND'"
|
||||||
|
>
|
||||||
|
{{ statusLabelOf(row.status) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="inv-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
|
||||||
@@ -0,0 +1,684 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||||
|
import { AuthenticationService } from '../../core/authentication/authentication.service';
|
||||||
|
import { ExportSheetSpec, exportWorkbook } from '../../cashier/cashier-export';
|
||||||
|
import {
|
||||||
|
BRANCHES,
|
||||||
|
BranchMaster,
|
||||||
|
branchTargetAmount,
|
||||||
|
Channel,
|
||||||
|
CHANNEL_LABELS,
|
||||||
|
CHANNEL_TARGET_PCT,
|
||||||
|
CustomerType,
|
||||||
|
LoanApplication,
|
||||||
|
LOAN_APPLICATIONS,
|
||||||
|
ORIGINATION_PRODUCTS,
|
||||||
|
OriginationStage,
|
||||||
|
OverallStatus,
|
||||||
|
PRODUCT_TARGET_PCT,
|
||||||
|
STAGE_LABELS,
|
||||||
|
STAGE_ORDER,
|
||||||
|
STATUS_LABELS,
|
||||||
|
TARGET_STATUS_LABELS,
|
||||||
|
TargetStatus,
|
||||||
|
createApplication,
|
||||||
|
progressPct,
|
||||||
|
stageBadgeClass,
|
||||||
|
targetStatus,
|
||||||
|
todayDDMMYYYY
|
||||||
|
} from '../loan-origination-data';
|
||||||
|
|
||||||
|
interface ChannelReportRow {
|
||||||
|
channel: Channel;
|
||||||
|
label: string;
|
||||||
|
count: number;
|
||||||
|
requestedAmount: number;
|
||||||
|
disbursedCount: number;
|
||||||
|
disbursedAmount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BcReportRow {
|
||||||
|
bcAgentName: string;
|
||||||
|
count: number;
|
||||||
|
requestedAmount: number;
|
||||||
|
disbursedCount: number;
|
||||||
|
disbursedAmount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BranchTargetRow {
|
||||||
|
branchCode: string;
|
||||||
|
branchName: string;
|
||||||
|
target: number;
|
||||||
|
actual: number;
|
||||||
|
achievementPct: number;
|
||||||
|
status: TargetStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MixTargetRow {
|
||||||
|
code: string;
|
||||||
|
label: string;
|
||||||
|
targetPct: number;
|
||||||
|
actualPct: number;
|
||||||
|
variancePct: number;
|
||||||
|
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';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loan Origination pipeline — every application currently moving through the sanctioning
|
||||||
|
* workflow (Application → KYC → CIC Check → Field Investigation → Personal Discussion → Credit
|
||||||
|
* Committee → Risk Approval → Legal Approval → Customer Signing → Cheque/ACH → Post-Check →
|
||||||
|
* Disbursed), plus the Channel-wise / BC-wise / Share Member reports and a New Application
|
||||||
|
* intake form. Every count/total here is derived from the same LOAN_APPLICATIONS array the
|
||||||
|
* Application Detail screen mutates, so the pipeline view can never drift from an application's
|
||||||
|
* real state.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-loan-pipeline',
|
||||||
|
templateUrl: './loan-pipeline.component.html',
|
||||||
|
styleUrls: [
|
||||||
|
'../../cashier/cashier-shared.scss',
|
||||||
|
'../../investment/investment-shared.scss',
|
||||||
|
'../../npa/npa-shared.scss',
|
||||||
|
'../loan-origination-shared.scss'
|
||||||
|
],
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
RouterModule
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class LoanPipelineComponent implements OnInit {
|
||||||
|
readonly stageOrder = STAGE_ORDER;
|
||||||
|
readonly stageLabels = STAGE_LABELS;
|
||||||
|
readonly statusLabels = STATUS_LABELS;
|
||||||
|
readonly channelLabels = CHANNEL_LABELS;
|
||||||
|
readonly branches = BRANCHES;
|
||||||
|
readonly products = ORIGINATION_PRODUCTS;
|
||||||
|
asOnDate = todayDDMMYYYY();
|
||||||
|
|
||||||
|
activeView: ViewId = 'pipeline';
|
||||||
|
|
||||||
|
isHeadOffice = false;
|
||||||
|
currentOfficeName = 'Branch Office';
|
||||||
|
private ownBranchCode: string | null = null;
|
||||||
|
branchFilter = '';
|
||||||
|
|
||||||
|
channelFilter = '';
|
||||||
|
stageFilter = '';
|
||||||
|
statusFilter = '';
|
||||||
|
searchText = '';
|
||||||
|
|
||||||
|
readonly pageSize = 20;
|
||||||
|
currentPage = 1;
|
||||||
|
|
||||||
|
generating = false;
|
||||||
|
|
||||||
|
newApp = {
|
||||||
|
channel: 'BRANCH' as Channel,
|
||||||
|
bcAgentName: '',
|
||||||
|
customerType: 'NEW' as CustomerType,
|
||||||
|
applicantName: '',
|
||||||
|
branchCode: this.branches[0].code,
|
||||||
|
productCode: this.products[0].code,
|
||||||
|
requestedAmount: null as number | null,
|
||||||
|
tenureMonths: null as number | null
|
||||||
|
};
|
||||||
|
|
||||||
|
toastMsg = '';
|
||||||
|
toastVisible = false;
|
||||||
|
private toastTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
private readonly auth = inject(AuthenticationService);
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
const requestedView = this.route.snapshot.queryParamMap.get('view') as ViewId | null;
|
||||||
|
if (requestedView && [
|
||||||
|
'pipeline',
|
||||||
|
'new',
|
||||||
|
'channel',
|
||||||
|
'bc',
|
||||||
|
'share',
|
||||||
|
'target'
|
||||||
|
].includes(requestedView)) {
|
||||||
|
this.activeView = requestedView;
|
||||||
|
}
|
||||||
|
|
||||||
|
const creds = this.auth.getCredentials();
|
||||||
|
this.isHeadOffice = this.auth.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;
|
||||||
|
// A branch-level login can only originate applications for its own branch — locked here,
|
||||||
|
// not just hidden, since the New Application form posts whatever branchCode is bound.
|
||||||
|
this.newApp.branchCode = this.branchFilter;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Branches selectable on the New Application form — every branch for Head Office, only the
|
||||||
|
* logged-in user's own branch otherwise. */
|
||||||
|
get newApplicationBranches(): BranchMaster[] {
|
||||||
|
return this.isHeadOffice ? this.branches : this.branches.filter((b) => b.code === this.branchFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
setView(view: ViewId): void {
|
||||||
|
this.activeView = view;
|
||||||
|
this.currentPage = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
setBranch(code: string): void {
|
||||||
|
this.branchFilter = code;
|
||||||
|
this.currentPage = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 scopedApplications(): LoanApplication[] {
|
||||||
|
return this.branchFilter ? LOAN_APPLICATIONS.filter((a) => a.branchCode === this.branchFilter) : LOAN_APPLICATIONS;
|
||||||
|
}
|
||||||
|
|
||||||
|
get filteredApplications(): LoanApplication[] {
|
||||||
|
return this.scopedApplications.filter((a) => {
|
||||||
|
if (this.channelFilter && a.channel !== this.channelFilter) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (this.stageFilter && a.currentStage !== this.stageFilter) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (this.statusFilter && a.overallStatus !== this.statusFilter) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (this.searchText) {
|
||||||
|
const q = this.searchText.toLowerCase();
|
||||||
|
return a.applicationNo.toLowerCase().includes(q) || a.applicantName.toLowerCase().includes(q);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------- Pagination --------------------
|
||||||
|
paged<T>(items: T[]): T[] {
|
||||||
|
const start = (this.currentPage - 1) * this.pageSize;
|
||||||
|
return items.slice(start, start + this.pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
pageCount(items: unknown[]): number {
|
||||||
|
return Math.max(1, Math.ceil(items.length / this.pageSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
rangeStart(items: unknown[]): number {
|
||||||
|
return items.length === 0 ? 0 : (this.currentPage - 1) * this.pageSize + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
rangeEnd(items: unknown[]): number {
|
||||||
|
return Math.min(this.currentPage * this.pageSize, items.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
goToPage(page: number, items: unknown[]): void {
|
||||||
|
this.currentPage = Math.min(Math.max(1, page), this.pageCount(items));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------- Portfolio KPIs --------------------
|
||||||
|
get totalApplications(): number {
|
||||||
|
return this.scopedApplications.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
get inProgressCount(): number {
|
||||||
|
return this.scopedApplications.filter((a) => a.overallStatus === 'IN_PROGRESS').length;
|
||||||
|
}
|
||||||
|
|
||||||
|
get onHoldCount(): number {
|
||||||
|
return this.scopedApplications.filter((a) => a.overallStatus === 'ON_HOLD').length;
|
||||||
|
}
|
||||||
|
|
||||||
|
get rejectedCount(): number {
|
||||||
|
return this.scopedApplications.filter((a) => a.overallStatus === 'REJECTED').length;
|
||||||
|
}
|
||||||
|
|
||||||
|
get disbursedApplications(): LoanApplication[] {
|
||||||
|
return this.scopedApplications.filter((a) => a.overallStatus === 'DISBURSED');
|
||||||
|
}
|
||||||
|
|
||||||
|
get disbursedAmount(): number {
|
||||||
|
return this.disbursedApplications.reduce((s, a) => s + a.requestedAmount, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
progress(a: LoanApplication): number {
|
||||||
|
return progressPct(a);
|
||||||
|
}
|
||||||
|
|
||||||
|
badgeClass(a: LoanApplication): string {
|
||||||
|
return stageBadgeClass(a);
|
||||||
|
}
|
||||||
|
|
||||||
|
statusLabel(status: OverallStatus): string {
|
||||||
|
return STATUS_LABELS[status];
|
||||||
|
}
|
||||||
|
|
||||||
|
stageLabel(stage: OriginationStage): string {
|
||||||
|
return STAGE_LABELS[stage];
|
||||||
|
}
|
||||||
|
|
||||||
|
openApplication(a: LoanApplication): void {
|
||||||
|
this.router.navigate([
|
||||||
|
'/loan-origination/application',
|
||||||
|
a.applicationNo
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------- Channel-wise / BC-wise / Share Member reports --------------------
|
||||||
|
get channelReport(): ChannelReportRow[] {
|
||||||
|
const channels: Channel[] = [
|
||||||
|
'BRANCH',
|
||||||
|
'BC',
|
||||||
|
'SELF_APPLY'
|
||||||
|
];
|
||||||
|
return channels.map((channel) => {
|
||||||
|
const rows = this.scopedApplications.filter((a) => a.channel === channel);
|
||||||
|
const disbursed = rows.filter((a) => a.overallStatus === 'DISBURSED');
|
||||||
|
return {
|
||||||
|
channel,
|
||||||
|
label: CHANNEL_LABELS[channel],
|
||||||
|
count: rows.length,
|
||||||
|
requestedAmount: rows.reduce((s, a) => s + a.requestedAmount, 0),
|
||||||
|
disbursedCount: disbursed.length,
|
||||||
|
disbursedAmount: disbursed.reduce((s, a) => s + a.requestedAmount, 0)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get bcApplications(): LoanApplication[] {
|
||||||
|
return this.scopedApplications.filter((a) => a.channel === 'BC');
|
||||||
|
}
|
||||||
|
|
||||||
|
get bcReport(): BcReportRow[] {
|
||||||
|
const names = Array.from(new Set(this.bcApplications.map((a) => a.bcAgentName))).sort();
|
||||||
|
return names.map((bcAgentName) => {
|
||||||
|
const rows = this.bcApplications.filter((a) => a.bcAgentName === bcAgentName);
|
||||||
|
const disbursed = rows.filter((a) => a.overallStatus === 'DISBURSED');
|
||||||
|
return {
|
||||||
|
bcAgentName,
|
||||||
|
count: rows.length,
|
||||||
|
requestedAmount: rows.reduce((s, a) => s + a.requestedAmount, 0),
|
||||||
|
disbursedCount: disbursed.length,
|
||||||
|
disbursedAmount: disbursed.reduce((s, a) => s + a.requestedAmount, 0)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get shareMemberCount(): number {
|
||||||
|
return this.scopedApplications.filter((a) => a.isShareMember).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
get nonMemberApplications(): LoanApplication[] {
|
||||||
|
return this.scopedApplications.filter((a) => !a.isShareMember && a.overallStatus !== 'REJECTED');
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------- Disbursement Target vs Actual --------------------
|
||||||
|
statusLabelOf(status: TargetStatus): string {
|
||||||
|
return TARGET_STATUS_LABELS[status];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Branches shown in the target table: every branch for Head Office's consolidated view, only
|
||||||
|
* the drilled-into/own branch otherwise — same scoping the rest of the screen already uses. */
|
||||||
|
get branchTargetRows(): BranchTargetRow[] {
|
||||||
|
const branchesToShow =
|
||||||
|
this.isHeadOffice && !this.branchFilter
|
||||||
|
? this.branches
|
||||||
|
: this.branches.filter((b) => b.code === this.branchFilter);
|
||||||
|
return branchesToShow.map((b) => {
|
||||||
|
const actual = LOAN_APPLICATIONS.filter((a) => a.branchCode === b.code && a.overallStatus === 'DISBURSED').reduce(
|
||||||
|
(s, a) => s + a.requestedAmount,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const target = branchTargetAmount(b.code);
|
||||||
|
const achievementPct = target ? (actual / target) * 100 : 0;
|
||||||
|
return {
|
||||||
|
branchCode: b.code,
|
||||||
|
branchName: b.name,
|
||||||
|
target,
|
||||||
|
actual,
|
||||||
|
achievementPct,
|
||||||
|
status: targetStatus(achievementPct, 100, 5)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private get disbursedInScope(): LoanApplication[] {
|
||||||
|
return this.scopedApplications.filter((a) => a.overallStatus === 'DISBURSED');
|
||||||
|
}
|
||||||
|
|
||||||
|
get productTargetRows(): MixTargetRow[] {
|
||||||
|
const total = this.disbursedInScope.reduce((s, a) => s + a.requestedAmount, 0);
|
||||||
|
return this.products.map((p) => {
|
||||||
|
const productAmount = this.disbursedInScope
|
||||||
|
.filter((a) => a.productCode === p.code)
|
||||||
|
.reduce((s, a) => s + a.requestedAmount, 0);
|
||||||
|
const actualPct = total ? (productAmount / total) * 100 : 0;
|
||||||
|
const targetPct = PRODUCT_TARGET_PCT[p.code] || 0;
|
||||||
|
return {
|
||||||
|
code: p.code,
|
||||||
|
label: p.name,
|
||||||
|
targetPct,
|
||||||
|
actualPct,
|
||||||
|
variancePct: actualPct - targetPct,
|
||||||
|
status: targetStatus(actualPct, targetPct, 3),
|
||||||
|
isRbiMandated: p.code === 'KCC' || p.code === 'CC'
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get channelTargetRows(): MixTargetRow[] {
|
||||||
|
const total = this.disbursedInScope.reduce((s, a) => s + a.requestedAmount, 0);
|
||||||
|
const channels: Channel[] = [
|
||||||
|
'BRANCH',
|
||||||
|
'BC',
|
||||||
|
'SELF_APPLY'
|
||||||
|
];
|
||||||
|
return channels.map((channel) => {
|
||||||
|
const channelAmount = this.disbursedInScope
|
||||||
|
.filter((a) => a.channel === channel)
|
||||||
|
.reduce((s, a) => s + a.requestedAmount, 0);
|
||||||
|
const actualPct = total ? (channelAmount / total) * 100 : 0;
|
||||||
|
const targetPct = CHANNEL_TARGET_PCT[channel];
|
||||||
|
return {
|
||||||
|
code: channel,
|
||||||
|
label: CHANNEL_LABELS[channel],
|
||||||
|
targetPct,
|
||||||
|
actualPct,
|
||||||
|
variancePct: actualPct - targetPct,
|
||||||
|
status: targetStatus(actualPct, targetPct, 3),
|
||||||
|
isRbiMandated: false
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------- New Application intake --------------------
|
||||||
|
productOf(code: string) {
|
||||||
|
return this.products.find((p) => p.code === code) || this.products[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
saveNewApplication(): void {
|
||||||
|
if (!this.newApp.applicantName.trim() || !this.newApp.requestedAmount || this.newApp.requestedAmount <= 0) {
|
||||||
|
this.toast('Enter the applicant name and a valid requested amount.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.newApp.channel === 'BC' && !this.newApp.bcAgentName.trim()) {
|
||||||
|
this.toast('Enter the BC agent/point name for a Business Correspondent application.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// A branch-level login is locked to its own branch regardless of what the (disabled) select
|
||||||
|
// is bound to — defence in depth against the DOM value ever being tampered with.
|
||||||
|
if (!this.isHeadOffice && this.ownBranchCode) {
|
||||||
|
this.newApp.branchCode = this.ownBranchCode;
|
||||||
|
}
|
||||||
|
const branch = this.branches.find((b) => b.code === this.newApp.branchCode) || this.branches[0];
|
||||||
|
const product = this.productOf(this.newApp.productCode);
|
||||||
|
const seq = LOAN_APPLICATIONS.length + 20000;
|
||||||
|
const app = createApplication(
|
||||||
|
{
|
||||||
|
channel: this.newApp.channel,
|
||||||
|
bcAgentName: this.newApp.bcAgentName || '—',
|
||||||
|
customerType: this.newApp.customerType,
|
||||||
|
applicantName: this.newApp.applicantName.trim(),
|
||||||
|
branchCode: branch.code,
|
||||||
|
branchName: branch.name,
|
||||||
|
productCode: product.code,
|
||||||
|
productName: product.name,
|
||||||
|
secured: product.secured,
|
||||||
|
requestedAmount: this.newApp.requestedAmount,
|
||||||
|
tenureMonths: this.newApp.tenureMonths || product.minTenure,
|
||||||
|
interestRate: +((product.minRate + product.maxRate) / 2).toFixed(2)
|
||||||
|
},
|
||||||
|
seq
|
||||||
|
);
|
||||||
|
LOAN_APPLICATIONS.unshift(app);
|
||||||
|
this.toast(`Application ${app.applicationNo} received for ${app.applicantName}.`);
|
||||||
|
this.newApp = {
|
||||||
|
channel: 'BRANCH',
|
||||||
|
bcAgentName: '',
|
||||||
|
customerType: 'NEW',
|
||||||
|
applicantName: '',
|
||||||
|
branchCode: this.isHeadOffice ? this.branches[0].code : this.ownBranchCode || this.branches[0].code,
|
||||||
|
productCode: this.products[0].code,
|
||||||
|
requestedAmount: null,
|
||||||
|
tenureMonths: null
|
||||||
|
};
|
||||||
|
this.router.navigate([
|
||||||
|
'/loan-origination/application',
|
||||||
|
app.applicationNo
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------- Export to Excel --------------------
|
||||||
|
async exportExcel(): Promise<void> {
|
||||||
|
this.generating = true;
|
||||||
|
try {
|
||||||
|
const subtitle = `Scope: ${this.scopeName} · As On: ${this.asOnDate}`;
|
||||||
|
const sheets: ExportSheetSpec[] = [
|
||||||
|
{
|
||||||
|
sheetName: 'Pipeline',
|
||||||
|
title: 'Loan Origination Pipeline',
|
||||||
|
subtitle,
|
||||||
|
headers: [
|
||||||
|
'Application No',
|
||||||
|
'Applicant',
|
||||||
|
'Branch',
|
||||||
|
'Channel',
|
||||||
|
'Product',
|
||||||
|
'Requested Amount',
|
||||||
|
'Current Stage',
|
||||||
|
'Status'
|
||||||
|
],
|
||||||
|
widths: [
|
||||||
|
16,
|
||||||
|
22,
|
||||||
|
16,
|
||||||
|
22,
|
||||||
|
16,
|
||||||
|
18,
|
||||||
|
24,
|
||||||
|
14
|
||||||
|
],
|
||||||
|
rows: this.filteredApplications.map((a) => [
|
||||||
|
a.applicationNo,
|
||||||
|
a.applicantName,
|
||||||
|
a.branchName,
|
||||||
|
CHANNEL_LABELS[a.channel],
|
||||||
|
a.productName,
|
||||||
|
a.requestedAmount,
|
||||||
|
STAGE_LABELS[a.currentStage],
|
||||||
|
STATUS_LABELS[a.overallStatus]
|
||||||
|
])
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sheetName: 'Channel-wise',
|
||||||
|
title: 'Channel-wise Report',
|
||||||
|
subtitle,
|
||||||
|
headers: [
|
||||||
|
'Channel',
|
||||||
|
'Applications',
|
||||||
|
'Requested Amount',
|
||||||
|
'Disbursed Count',
|
||||||
|
'Disbursed Amount'
|
||||||
|
],
|
||||||
|
widths: [
|
||||||
|
26,
|
||||||
|
16,
|
||||||
|
18,
|
||||||
|
16,
|
||||||
|
18
|
||||||
|
],
|
||||||
|
rows: this.channelReport.map((r) => [
|
||||||
|
r.label,
|
||||||
|
r.count,
|
||||||
|
r.requestedAmount,
|
||||||
|
r.disbursedCount,
|
||||||
|
r.disbursedAmount
|
||||||
|
])
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sheetName: 'BC-wise',
|
||||||
|
title: 'BC-wise Report',
|
||||||
|
subtitle,
|
||||||
|
headers: [
|
||||||
|
'BC Agent / Point',
|
||||||
|
'Applications',
|
||||||
|
'Requested Amount',
|
||||||
|
'Disbursed Count',
|
||||||
|
'Disbursed Amount'
|
||||||
|
],
|
||||||
|
widths: [
|
||||||
|
30,
|
||||||
|
16,
|
||||||
|
18,
|
||||||
|
16,
|
||||||
|
18
|
||||||
|
],
|
||||||
|
rows: this.bcReport.map((r) => [
|
||||||
|
r.bcAgentName,
|
||||||
|
r.count,
|
||||||
|
r.requestedAmount,
|
||||||
|
r.disbursedCount,
|
||||||
|
r.disbursedAmount
|
||||||
|
])
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sheetName: 'Share Member',
|
||||||
|
title: 'Share Member Report',
|
||||||
|
subtitle,
|
||||||
|
headers: [
|
||||||
|
'Application No',
|
||||||
|
'Applicant',
|
||||||
|
'Branch',
|
||||||
|
'Current Stage',
|
||||||
|
'Status'
|
||||||
|
],
|
||||||
|
widths: [
|
||||||
|
16,
|
||||||
|
22,
|
||||||
|
16,
|
||||||
|
24,
|
||||||
|
14
|
||||||
|
],
|
||||||
|
rows: this.nonMemberApplications.map((a) => [
|
||||||
|
a.applicationNo,
|
||||||
|
a.applicantName,
|
||||||
|
a.branchName,
|
||||||
|
STAGE_LABELS[a.currentStage],
|
||||||
|
STATUS_LABELS[a.overallStatus]
|
||||||
|
])
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sheetName: 'Branch Target',
|
||||||
|
title: 'Branch-wise Disbursement Target — Annual',
|
||||||
|
subtitle,
|
||||||
|
headers: [
|
||||||
|
'Branch',
|
||||||
|
'Target',
|
||||||
|
'Disbursed (Actual)',
|
||||||
|
'Achievement %',
|
||||||
|
'Status'
|
||||||
|
],
|
||||||
|
widths: [
|
||||||
|
20,
|
||||||
|
18,
|
||||||
|
18,
|
||||||
|
14,
|
||||||
|
16
|
||||||
|
],
|
||||||
|
rows: this.branchTargetRows.map((r) => [
|
||||||
|
r.branchName,
|
||||||
|
r.target,
|
||||||
|
r.actual,
|
||||||
|
Number(r.achievementPct.toFixed(1)),
|
||||||
|
this.statusLabelOf(r.status)
|
||||||
|
])
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sheetName: 'Product-Channel Target',
|
||||||
|
title: 'Product & Channel Mix — Target % vs Actual %',
|
||||||
|
subtitle,
|
||||||
|
headers: [
|
||||||
|
'Mix',
|
||||||
|
'Target Basis',
|
||||||
|
'Target %',
|
||||||
|
'Actual %',
|
||||||
|
'Variance (pp)',
|
||||||
|
'Status'
|
||||||
|
],
|
||||||
|
widths: [
|
||||||
|
26,
|
||||||
|
18,
|
||||||
|
12,
|
||||||
|
12,
|
||||||
|
14,
|
||||||
|
16
|
||||||
|
],
|
||||||
|
rows: [
|
||||||
|
...this.productTargetRows.map((r) => [
|
||||||
|
`Product — ${r.label}`,
|
||||||
|
r.isRbiMandated ? 'RBI PSL Sub-Target' : 'Internal Target',
|
||||||
|
r.targetPct,
|
||||||
|
Number(r.actualPct.toFixed(1)),
|
||||||
|
Number(r.variancePct.toFixed(1)),
|
||||||
|
this.statusLabelOf(r.status)
|
||||||
|
]),
|
||||||
|
...this.channelTargetRows.map((r) => [
|
||||||
|
`Channel — ${r.label}`,
|
||||||
|
'Internal Target',
|
||||||
|
r.targetPct,
|
||||||
|
Number(r.actualPct.toFixed(1)),
|
||||||
|
Number(r.variancePct.toFixed(1)),
|
||||||
|
this.statusLabelOf(r.status)
|
||||||
|
])
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
await exportWorkbook(sheets, `Loan-Origination-${this.scopeName}-${this.asOnDate}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Loan Origination export failed', err);
|
||||||
|
} 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+384
-22
@@ -79,6 +79,26 @@ export interface RecoveryTxn {
|
|||||||
mode: string;
|
mode: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One entry in an account's collection/recovery journey — the real-world touchpoints a CBS
|
||||||
|
* Recovery Management System logs against an overdue loan, in the order they actually happened. */
|
||||||
|
export type RecoveryEventType =
|
||||||
|
| 'SMS Reminder'
|
||||||
|
| 'IVR / Auto Call'
|
||||||
|
| 'Tele-calling'
|
||||||
|
| 'Overdue Call'
|
||||||
|
| 'Field Visit'
|
||||||
|
| 'Formal Overdue Letter'
|
||||||
|
| 'Recovery Agent Assigned'
|
||||||
|
| 'Demand Notice'
|
||||||
|
| 'Payment Received';
|
||||||
|
|
||||||
|
export interface RecoveryEvent {
|
||||||
|
date: string;
|
||||||
|
type: RecoveryEventType;
|
||||||
|
channel: string;
|
||||||
|
remarks: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface LoanAccount {
|
export interface LoanAccount {
|
||||||
accountNo: string;
|
accountNo: string;
|
||||||
customerName: string;
|
customerName: string;
|
||||||
@@ -88,6 +108,7 @@ export interface LoanAccount {
|
|||||||
productCode: string;
|
productCode: string;
|
||||||
productName: string;
|
productName: string;
|
||||||
secured: boolean;
|
secured: boolean;
|
||||||
|
repaymentType: RepaymentType;
|
||||||
sanctionedAmount: number;
|
sanctionedAmount: number;
|
||||||
disbursementDate: string;
|
disbursementDate: string;
|
||||||
interestRate: number;
|
interestRate: number;
|
||||||
@@ -96,11 +117,27 @@ export interface LoanAccount {
|
|||||||
overdueDays: number;
|
overdueDays: number;
|
||||||
overdueDaysQuarterAgo: number;
|
overdueDaysQuarterAgo: number;
|
||||||
collateralValue: number;
|
collateralValue: number;
|
||||||
|
/** Current estimated market value of the pledged security — can run above or below
|
||||||
|
* `collateralValue` (the value recorded at sanction/last valuation), which is exactly what the
|
||||||
|
* Exception Check / Agriculture-Gold Market Value reports exist to catch. 0 for unsecured loans. */
|
||||||
|
collateralMarketValue: number;
|
||||||
|
/** Fixed EMI for an EMI-repayment loan (standard reducing-balance formula on the sanctioned
|
||||||
|
* amount and tenure) — 0 for OVERDRAWN/BULLET loans, which aren't due on a fixed instalment. */
|
||||||
|
emiAmount: number;
|
||||||
|
/** Drawing limit for an OVERDRAWN (CC/OD/KCC) account — kept below the sanctioned amount by a
|
||||||
|
* margin, same as a real cash-credit facility. 0 for EMI/BULLET loans. */
|
||||||
|
drawingPower: number;
|
||||||
|
/** Bullet-repayment due date (principal falls due in full on this date) — '—' for EMI/OVERDRAWN
|
||||||
|
* loans, which don't have a single bullet maturity. */
|
||||||
|
maturityDate: string;
|
||||||
lastPaymentDate: string;
|
lastPaymentDate: string;
|
||||||
isLossAsset: boolean;
|
isLossAsset: boolean;
|
||||||
isRestructured: boolean;
|
isRestructured: boolean;
|
||||||
provisionCoverageRatio: number;
|
provisionCoverageRatio: number;
|
||||||
recoveries: RecoveryTxn[];
|
recoveries: RecoveryTxn[];
|
||||||
|
/** Chronological log of collection touchpoints (SMS/call/visit/notice) plus actual recoveries —
|
||||||
|
* empty for an account that has never gone overdue. */
|
||||||
|
collectionTimeline: RecoveryEvent[];
|
||||||
/** '—' for Standard/SMA accounts — only NPA accounts are actually assigned a reason. */
|
/** '—' for Standard/SMA accounts — only NPA accounts are actually assigned a reason. */
|
||||||
npaReason: string;
|
npaReason: string;
|
||||||
/** '—' for Standard accounts — SMA and NPA accounts get a recovery officer assigned for follow-up. */
|
/** '—' for Standard accounts — SMA and NPA accounts get a recovery officer assigned for follow-up. */
|
||||||
@@ -174,6 +211,17 @@ export const BRANCHES: BranchMaster[] = [
|
|||||||
{ code: 'BR08', name: 'Kolhapur Branch' }
|
{ code: 'BR08', name: 'Kolhapur Branch' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** How the loan is contractually repaid — drives the Recovery Management System's per-type
|
||||||
|
* overdue check (EMI shortfall / drawing-power breach / bullet-maturity lapse) and the
|
||||||
|
* Expected-vs-Actual Recovery figures, since each type is "due" on a different basis. */
|
||||||
|
export type RepaymentType = 'EMI' | 'OVERDRAWN' | 'BULLET';
|
||||||
|
|
||||||
|
export const REPAYMENT_TYPE_LABELS: Record<RepaymentType, string> = {
|
||||||
|
EMI: 'Term Loan (EMI)',
|
||||||
|
OVERDRAWN: 'Overdrawn (CC / OD)',
|
||||||
|
BULLET: 'Bullet Repayment'
|
||||||
|
};
|
||||||
|
|
||||||
interface ProductMaster {
|
interface ProductMaster {
|
||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -182,6 +230,11 @@ interface ProductMaster {
|
|||||||
maxAmt: number;
|
maxAmt: number;
|
||||||
minRate: number;
|
minRate: number;
|
||||||
maxRate: number;
|
maxRate: number;
|
||||||
|
repaymentType: RepaymentType;
|
||||||
|
/** Sanctioned tenure in months — drives the EMI formula for EMI-type products and the
|
||||||
|
* bullet maturity date for BULLET-type products. Renewal cycle (not a repayment schedule)
|
||||||
|
* for OVERDRAWN/revolving products. */
|
||||||
|
tenureMonths: number;
|
||||||
}
|
}
|
||||||
export const LOAN_PRODUCTS: ProductMaster[] = [
|
export const LOAN_PRODUCTS: ProductMaster[] = [
|
||||||
{
|
{
|
||||||
@@ -191,15 +244,87 @@ export const LOAN_PRODUCTS: ProductMaster[] = [
|
|||||||
minAmt: 500000,
|
minAmt: 500000,
|
||||||
maxAmt: 5000000,
|
maxAmt: 5000000,
|
||||||
minRate: 11,
|
minRate: 11,
|
||||||
maxRate: 13.5
|
maxRate: 13.5,
|
||||||
|
repaymentType: 'OVERDRAWN',
|
||||||
|
tenureMonths: 12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: '02',
|
||||||
|
name: 'Term Loan - MSME',
|
||||||
|
secured: true,
|
||||||
|
minAmt: 300000,
|
||||||
|
maxAmt: 3000000,
|
||||||
|
minRate: 11,
|
||||||
|
maxRate: 14,
|
||||||
|
repaymentType: 'EMI',
|
||||||
|
tenureMonths: 60
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: '03',
|
||||||
|
name: 'Housing Loan',
|
||||||
|
secured: true,
|
||||||
|
minAmt: 1000000,
|
||||||
|
maxAmt: 4000000,
|
||||||
|
minRate: 8.5,
|
||||||
|
maxRate: 10.5,
|
||||||
|
repaymentType: 'EMI',
|
||||||
|
tenureMonths: 180
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: '04',
|
||||||
|
name: 'Vehicle Loan',
|
||||||
|
secured: true,
|
||||||
|
minAmt: 300000,
|
||||||
|
maxAmt: 1500000,
|
||||||
|
minRate: 9,
|
||||||
|
maxRate: 11.5,
|
||||||
|
repaymentType: 'EMI',
|
||||||
|
tenureMonths: 84
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: '05',
|
||||||
|
name: 'Gold Loan',
|
||||||
|
secured: true,
|
||||||
|
minAmt: 50000,
|
||||||
|
maxAmt: 1000000,
|
||||||
|
minRate: 9,
|
||||||
|
maxRate: 12,
|
||||||
|
repaymentType: 'BULLET',
|
||||||
|
tenureMonths: 12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: '06',
|
||||||
|
name: 'Kisan Credit Card',
|
||||||
|
secured: true,
|
||||||
|
minAmt: 100000,
|
||||||
|
maxAmt: 500000,
|
||||||
|
minRate: 7,
|
||||||
|
maxRate: 9,
|
||||||
|
repaymentType: 'OVERDRAWN',
|
||||||
|
tenureMonths: 12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: '07',
|
||||||
|
name: 'Personal Loan',
|
||||||
|
secured: false,
|
||||||
|
minAmt: 50000,
|
||||||
|
maxAmt: 500000,
|
||||||
|
minRate: 13,
|
||||||
|
maxRate: 16,
|
||||||
|
repaymentType: 'EMI',
|
||||||
|
tenureMonths: 36
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: '08',
|
||||||
|
name: 'Education Loan',
|
||||||
|
secured: false,
|
||||||
|
minAmt: 200000,
|
||||||
|
maxAmt: 1500000,
|
||||||
|
minRate: 10,
|
||||||
|
maxRate: 12,
|
||||||
|
repaymentType: 'EMI',
|
||||||
|
tenureMonths: 84
|
||||||
},
|
},
|
||||||
{ code: '02', name: 'Term Loan - MSME', secured: true, minAmt: 300000, maxAmt: 3000000, minRate: 11, maxRate: 14 },
|
|
||||||
{ code: '03', name: 'Housing Loan', secured: true, minAmt: 1000000, maxAmt: 4000000, minRate: 8.5, maxRate: 10.5 },
|
|
||||||
{ code: '04', name: 'Vehicle Loan', secured: true, minAmt: 300000, maxAmt: 1500000, minRate: 9, maxRate: 11.5 },
|
|
||||||
{ code: '05', name: 'Gold Loan', secured: true, minAmt: 50000, maxAmt: 1000000, minRate: 9, maxRate: 12 },
|
|
||||||
{ code: '06', name: 'Kisan Credit Card', secured: true, minAmt: 100000, maxAmt: 500000, minRate: 7, maxRate: 9 },
|
|
||||||
{ code: '07', name: 'Personal Loan', secured: false, minAmt: 50000, maxAmt: 500000, minRate: 13, maxRate: 16 },
|
|
||||||
{ code: '08', name: 'Education Loan', secured: false, minAmt: 200000, maxAmt: 1500000, minRate: 10, maxRate: 12 },
|
|
||||||
{
|
{
|
||||||
code: '09',
|
code: '09',
|
||||||
name: 'SHG / Micro Finance Loan',
|
name: 'SHG / Micro Finance Loan',
|
||||||
@@ -207,7 +332,9 @@ export const LOAN_PRODUCTS: ProductMaster[] = [
|
|||||||
minAmt: 20000,
|
minAmt: 20000,
|
||||||
maxAmt: 200000,
|
maxAmt: 200000,
|
||||||
minRate: 12,
|
minRate: 12,
|
||||||
maxRate: 14
|
maxRate: 14,
|
||||||
|
repaymentType: 'EMI',
|
||||||
|
tenureMonths: 24
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
code: '10',
|
code: '10',
|
||||||
@@ -216,7 +343,9 @@ export const LOAN_PRODUCTS: ProductMaster[] = [
|
|||||||
minAmt: 500000,
|
minAmt: 500000,
|
||||||
maxAmt: 2500000,
|
maxAmt: 2500000,
|
||||||
minRate: 11,
|
minRate: 11,
|
||||||
maxRate: 13
|
maxRate: 13,
|
||||||
|
repaymentType: 'OVERDRAWN',
|
||||||
|
tenureMonths: 12
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -345,6 +474,113 @@ function pickBucket(): StageBucket {
|
|||||||
return BUCKETS[0];
|
return BUCKETS[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Standard reducing-balance EMI formula on the original sanctioned amount and tenure — the same
|
||||||
|
* fixed instalment the loan was actually sanctioned with, not something recomputed off the
|
||||||
|
* current (partly-repaid) outstanding balance. */
|
||||||
|
function calcEmi(principal: number, annualRatePct: number, tenureMonths: number): number {
|
||||||
|
const r = annualRatePct / 12 / 100;
|
||||||
|
if (r === 0) {
|
||||||
|
return round(principal / tenureMonths);
|
||||||
|
}
|
||||||
|
const factor = Math.pow(1 + r, tenureMonths);
|
||||||
|
return round((principal * r * factor) / (factor - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TimelineCheckpoint {
|
||||||
|
atDay: number;
|
||||||
|
type: RecoveryEventType;
|
||||||
|
channel: string;
|
||||||
|
remarks: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The real-world escalation ladder a CBS Recovery Management System runs an overdue account
|
||||||
|
* through — each checkpoint only fires once the account has actually been overdue that long, so
|
||||||
|
* a 10-day-overdue account shows just the SMS/call steps while a 200-day NPA shows the full
|
||||||
|
* ladder up to the demand notice. */
|
||||||
|
const TIMELINE_CHECKPOINTS: TimelineCheckpoint[] = [
|
||||||
|
{
|
||||||
|
atDay: 3,
|
||||||
|
type: 'SMS Reminder',
|
||||||
|
channel: 'SMS Gateway',
|
||||||
|
remarks: 'Auto SMS reminder sent for the overdue instalment/interest.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
atDay: 7,
|
||||||
|
type: 'IVR / Auto Call',
|
||||||
|
channel: 'IVR System',
|
||||||
|
remarks: 'Automated voice-call reminder placed to the registered mobile number.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
atDay: 15,
|
||||||
|
type: 'Tele-calling',
|
||||||
|
channel: 'Branch Tele-calling Desk',
|
||||||
|
remarks: 'Branch staff called the customer to follow up on the continuing overdue.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
atDay: 25,
|
||||||
|
type: 'Overdue Call',
|
||||||
|
channel: 'Recovery Officer',
|
||||||
|
remarks: 'Recovery officer placed a personal follow-up call.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
atDay: 30,
|
||||||
|
type: 'Field Visit',
|
||||||
|
channel: 'Branch Staff',
|
||||||
|
remarks: 'Field visit conducted at the registered address — account under SMA monitoring.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
atDay: 45,
|
||||||
|
type: 'SMS Reminder',
|
||||||
|
channel: 'SMS Gateway',
|
||||||
|
remarks: 'Second reminder SMS sent — account still overdue.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
atDay: 60,
|
||||||
|
type: 'Formal Overdue Letter',
|
||||||
|
channel: 'Registered Post',
|
||||||
|
remarks: 'Formal overdue letter dispatched — account crossed into SMA-2 monitoring.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
atDay: 75,
|
||||||
|
type: 'Recovery Agent Assigned',
|
||||||
|
channel: 'External Recovery Agency',
|
||||||
|
remarks: 'Case assigned to a recovery agent for intensified follow-up.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
atDay: 91,
|
||||||
|
type: 'Demand Notice',
|
||||||
|
channel: 'Recovery Officer',
|
||||||
|
remarks: 'Account classified NPA — formal demand notice process initiated.'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Builds the account's full collection journey — every escalation checkpoint the account has
|
||||||
|
* actually reached, interleaved date-wise with its real cash recoveries — so the Account Detail
|
||||||
|
* screen can show one combined, chronological timeline instead of two disconnected lists. */
|
||||||
|
function buildCollectionTimeline(overdueDays: number, recoveries: RecoveryTxn[], today: Date): RecoveryEvent[] {
|
||||||
|
const events: RecoveryEvent[] = [];
|
||||||
|
for (const cp of TIMELINE_CHECKPOINTS) {
|
||||||
|
if (cp.atDay <= overdueDays) {
|
||||||
|
events.push({
|
||||||
|
date: fmt(addDays(today, -(overdueDays - cp.atDay))),
|
||||||
|
type: cp.type,
|
||||||
|
channel: cp.channel,
|
||||||
|
remarks: cp.remarks
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const r of recoveries) {
|
||||||
|
events.push({
|
||||||
|
date: r.date,
|
||||||
|
type: 'Payment Received',
|
||||||
|
channel: r.mode,
|
||||||
|
remarks: `Recovery of ₹${r.amount.toLocaleString('en-IN')} received via ${r.mode}.`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
events.sort((a, b) => parseDMY(a.date).getTime() - parseDMY(b.date).getTime());
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
/** Recovery-workflow progression — only NPA accounts get any legal action, and the more
|
/** Recovery-workflow progression — only NPA accounts get any legal action, and the more
|
||||||
* advanced/stressed the classification, the more likely the bank has already escalated. */
|
* advanced/stressed the classification, the more likely the bank has already escalated. */
|
||||||
function legalActionFor(stage: NpaStage): LegalActionStatus {
|
function legalActionFor(stage: NpaStage): LegalActionStatus {
|
||||||
@@ -398,6 +634,26 @@ function generateAccounts(count: number): LoanAccount[] {
|
|||||||
const overdueAmount = round(outstandingBalance * overdueFrac);
|
const overdueAmount = round(outstandingBalance * overdueFrac);
|
||||||
const collateralValue = product.secured ? round(sanctionedAmount * between(0.85, 1.25)) : 0;
|
const collateralValue = product.secured ? round(sanctionedAmount * between(0.85, 1.25)) : 0;
|
||||||
|
|
||||||
|
// Current market value of the pledged security vs its value at sanction: gold trends upward
|
||||||
|
// over time (real-world gold-price appreciation); crop/agri collateral (KCC) is volatile in
|
||||||
|
// both directions, matching how "Crop Failure" shows up as an NPA reason; other secured
|
||||||
|
// collateral (property/vehicle) moves in a narrower band. Unsecured loans have none.
|
||||||
|
let marketValueFactor = 0;
|
||||||
|
if (product.code === '05') {
|
||||||
|
marketValueFactor = between(1.05, 1.35);
|
||||||
|
} else if (product.code === '06') {
|
||||||
|
marketValueFactor = between(0.55, 1.2);
|
||||||
|
} else if (product.secured) {
|
||||||
|
marketValueFactor = between(0.85, 1.15);
|
||||||
|
}
|
||||||
|
const collateralMarketValue = product.secured ? round(collateralValue * marketValueFactor) : 0;
|
||||||
|
|
||||||
|
const emiAmount =
|
||||||
|
product.repaymentType === 'EMI' ? calcEmi(sanctionedAmount, interestRate, product.tenureMonths) : 0;
|
||||||
|
const drawingPower = product.repaymentType === 'OVERDRAWN' ? round(sanctionedAmount * between(0.75, 0.9)) : 0;
|
||||||
|
const maturityDate =
|
||||||
|
product.repaymentType === 'BULLET' ? fmt(addDays(parseDMY(disbursementDate), product.tenureMonths * 30)) : '—';
|
||||||
|
|
||||||
const npaReason = isNpaStage(stressed) ? pick(NPA_REASONS) : '—';
|
const npaReason = isNpaStage(stressed) ? pick(NPA_REASONS) : '—';
|
||||||
const recoveryOfficer = isNpaStage(stressed) || isSmaStage(stressed) ? pick(RECOVERY_OFFICERS) : '—';
|
const recoveryOfficer = isNpaStage(stressed) || isSmaStage(stressed) ? pick(RECOVERY_OFFICERS) : '—';
|
||||||
const legalActionStatus = legalActionFor(stressed);
|
const legalActionStatus = legalActionFor(stressed);
|
||||||
@@ -439,6 +695,8 @@ function generateAccounts(count: number): LoanAccount[] {
|
|||||||
? fmt(latestRecoveryDate)
|
? fmt(latestRecoveryDate)
|
||||||
: formulaLastPaymentDate;
|
: formulaLastPaymentDate;
|
||||||
|
|
||||||
|
const collectionTimeline = buildCollectionTimeline(overdueDays, recoveries, today);
|
||||||
|
|
||||||
const seq = String(1000 + i).padStart(6, '0');
|
const seq = String(1000 + i).padStart(6, '0');
|
||||||
accounts.push({
|
accounts.push({
|
||||||
accountNo: `1${branch.code.slice(2)}${product.code}${seq}`,
|
accountNo: `1${branch.code.slice(2)}${product.code}${seq}`,
|
||||||
@@ -449,6 +707,7 @@ function generateAccounts(count: number): LoanAccount[] {
|
|||||||
productCode: product.code,
|
productCode: product.code,
|
||||||
productName: product.name,
|
productName: product.name,
|
||||||
secured: product.secured,
|
secured: product.secured,
|
||||||
|
repaymentType: product.repaymentType,
|
||||||
sanctionedAmount,
|
sanctionedAmount,
|
||||||
disbursementDate,
|
disbursementDate,
|
||||||
interestRate,
|
interestRate,
|
||||||
@@ -457,11 +716,16 @@ function generateAccounts(count: number): LoanAccount[] {
|
|||||||
overdueDays,
|
overdueDays,
|
||||||
overdueDaysQuarterAgo,
|
overdueDaysQuarterAgo,
|
||||||
collateralValue,
|
collateralValue,
|
||||||
|
collateralMarketValue,
|
||||||
|
emiAmount,
|
||||||
|
drawingPower,
|
||||||
|
maturityDate,
|
||||||
lastPaymentDate,
|
lastPaymentDate,
|
||||||
isLossAsset,
|
isLossAsset,
|
||||||
isRestructured,
|
isRestructured,
|
||||||
provisionCoverageRatio: +between(0.75, 1.1).toFixed(2),
|
provisionCoverageRatio: +between(0.75, 1.1).toFixed(2),
|
||||||
recoveries,
|
recoveries,
|
||||||
|
collectionTimeline,
|
||||||
npaReason,
|
npaReason,
|
||||||
recoveryOfficer,
|
recoveryOfficer,
|
||||||
legalActionStatus
|
legalActionStatus
|
||||||
@@ -553,14 +817,23 @@ export function provisionHeld(a: LoanAccount): number {
|
|||||||
return Math.min(a.outstandingBalance, round(provisionRequired(a) * a.provisionCoverageRatio));
|
return Math.min(a.outstandingBalance, round(provisionRequired(a) * a.provisionCoverageRatio));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Interest income de-recognised because the account has been NPA for longer than 90 days. */
|
/** Days elapsed since the account first crossed 90 days overdue (i.e. became NPA) — 0 for any
|
||||||
export function interestReversed(a: LoanAccount): number {
|
* account that isn't currently NPA. The single source both interest-reversal and NPA-since-date
|
||||||
const stage = currentStage(a);
|
* are built from, so neither can ever quietly drift out of step with the other. */
|
||||||
if (!isNpaStage(stage)) {
|
export function daysSinceNpa(a: LoanAccount): number {
|
||||||
|
if (!isNpaStage(currentStage(a))) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
const daysSinceNpa = Math.max(0, a.overdueDays - 90);
|
return Math.max(0, a.overdueDays - 90);
|
||||||
return round(a.outstandingBalance * (a.interestRate / 100) * (daysSinceNpa / 365));
|
}
|
||||||
|
|
||||||
|
/** Interest income de-recognised because the account has been NPA for longer than 90 days. */
|
||||||
|
export function interestReversed(a: LoanAccount): number {
|
||||||
|
const days = daysSinceNpa(a);
|
||||||
|
if (days <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return round(a.outstandingBalance * (a.interestRate / 100) * (days / 365));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Date the account first crossed 90 days overdue (i.e. became NPA) — derived from overdueDays,
|
/** Date the account first crossed 90 days overdue (i.e. became NPA) — derived from overdueDays,
|
||||||
@@ -569,14 +842,96 @@ export function npaSinceDate(a: LoanAccount): string {
|
|||||||
if (!isNpaStage(currentStage(a))) {
|
if (!isNpaStage(currentStage(a))) {
|
||||||
return '—';
|
return '—';
|
||||||
}
|
}
|
||||||
const daysSinceNpa = Math.max(0, a.overdueDays - 90);
|
return fmt(addDays(new Date(), -daysSinceNpa(a)));
|
||||||
return fmt(addDays(new Date(), -daysSinceNpa));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function totalRecovered(a: LoanAccount): number {
|
export function totalRecovered(a: LoanAccount): number {
|
||||||
return a.recoveries.reduce((s, r) => s + r.amount, 0);
|
return a.recoveries.reduce((s, r) => s + r.amount, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cash recovered against an NPA account is appropriated to the memorandum "undebited interest"
|
||||||
|
* bucket first — consistent with IRAC norms recognising NPA interest income only on actual cash
|
||||||
|
* receipt, never on accrual — and only the balance beyond that reduces principal. Capped at the
|
||||||
|
* interest actually accrued, so this can never show more interest "recovered" than ever accrued.
|
||||||
|
*/
|
||||||
|
export function interestRecoveredCash(a: LoanAccount): number {
|
||||||
|
return Math.min(totalRecovered(a), interestReversed(a));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Memorandum interest still un-recovered — the "undebited interest" carried outside the
|
||||||
|
* borrower's loan account (per IRAC norms), less whatever cash has actually been appropriated
|
||||||
|
* against it so far. */
|
||||||
|
export function undebitedInterestBalance(a: LoanAccount): number {
|
||||||
|
return interestReversed(a) - interestRecoveredCash(a);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Recovery Management System — per-repayment-type overdue check, Expected vs
|
||||||
|
// Actual Recovery, and the collateral-adequacy exception check. `overdueDays`
|
||||||
|
// stays the single universal "how overdue is this account" signal (so IRAC
|
||||||
|
// classification never needs a second, type-specific day-counter) — only its
|
||||||
|
// PLAIN-ENGLISH INTERPRETATION differs by repayment type: an EMI account
|
||||||
|
// missed its instalment, an OVERDRAWN account has been over its drawing power,
|
||||||
|
// a BULLET account is past maturity unpaid.
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** True once a bullet loan's maturity date has passed — the point at which the bank checks
|
||||||
|
* whether the account "self-closed" (fully repaid) or is now overdue. */
|
||||||
|
export function isBulletMatured(a: LoanAccount): boolean {
|
||||||
|
return a.repaymentType === 'BULLET' && a.maturityDate !== '—' && parseDMY(a.maturityDate).getTime() <= Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A bullet loan past maturity that's still carrying an overdue balance. */
|
||||||
|
export function isBulletOverdue(a: LoanAccount): boolean {
|
||||||
|
return isBulletMatured(a) && a.overdueDays > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A cash-credit/overdraft-style account currently drawn beyond its sanctioned drawing power. */
|
||||||
|
export function isOverdrawnBreach(a: LoanAccount): boolean {
|
||||||
|
return a.repaymentType === 'OVERDRAWN' && a.outstandingBalance > a.drawingPower;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What ought to be recovered this month, by repayment type: the fixed EMI for a term loan, one
|
||||||
|
* month's interest-servicing for an overdrawn CC/OD/KCC account, or the full outstanding once a
|
||||||
|
* bullet loan has matured (0 before maturity — nothing is contractually due yet). */
|
||||||
|
export function expectedRecoveryThisMonth(a: LoanAccount): number {
|
||||||
|
switch (a.repaymentType) {
|
||||||
|
case 'EMI':
|
||||||
|
return a.emiAmount;
|
||||||
|
case 'OVERDRAWN':
|
||||||
|
return round((a.outstandingBalance * a.interestRate) / 100 / 12);
|
||||||
|
case 'BULLET':
|
||||||
|
return isBulletMatured(a) ? a.outstandingBalance : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What was actually recovered this month — `overdueDays === 0` means the account is fully
|
||||||
|
* current, i.e. this cycle's EMI/interest/bullet-repayment landed in full; any overdue days at
|
||||||
|
* all means it didn't, exactly the binary "full amount or mark Overdue" check a real recovery
|
||||||
|
* desk runs (this demo book doesn't model partial-instalment part-payments). */
|
||||||
|
export function actualRecoveryThisMonth(a: LoanAccount): number {
|
||||||
|
if (a.repaymentType === 'BULLET') {
|
||||||
|
return isBulletMatured(a) && a.overdueDays === 0 ? a.outstandingBalance : 0;
|
||||||
|
}
|
||||||
|
return a.overdueDays === 0 ? expectedRecoveryThisMonth(a) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Current collateral cover — market value of security as a % of outstanding balance. 0 for an
|
||||||
|
* unsecured loan (no collateral to speak of). */
|
||||||
|
export function securityCoverageRatio(a: LoanAccount): number {
|
||||||
|
if (!a.secured || a.outstandingBalance <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return (a.collateralMarketValue / a.outstandingBalance) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when a secured loan's CURRENT security market value has fallen below what's actually
|
||||||
|
* outstanding — the "security value is above loan" exception check every recovery review runs. */
|
||||||
|
export function isUndersecured(a: LoanAccount): boolean {
|
||||||
|
return a.secured && a.collateralMarketValue < a.outstandingBalance;
|
||||||
|
}
|
||||||
|
|
||||||
export function movementLabel(prev: NpaStage, curr: NpaStage): string {
|
export function movementLabel(prev: NpaStage, curr: NpaStage): string {
|
||||||
if (prev === curr) {
|
if (prev === curr) {
|
||||||
return 'No Change';
|
return 'No Change';
|
||||||
@@ -594,26 +949,33 @@ export function movementLabel(prev: NpaStage, curr: NpaStage): string {
|
|||||||
// in-page group/report tab rows, so a menu item and its screen can never
|
// in-page group/report tab rows, so a menu item and its screen can never
|
||||||
// point at different labels.
|
// point at different labels.
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
export type ReportGroupId = 'reports' | 'analysis' | 'classification';
|
export type ReportGroupId = 'reports' | 'analysis' | 'classification' | 'recovery-mgmt';
|
||||||
export interface ReportMeta {
|
export interface ReportMeta {
|
||||||
id: string;
|
id: string;
|
||||||
group: ReportGroupId;
|
group: ReportGroupId;
|
||||||
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' },
|
||||||
{ id: 'account-detail', group: 'analysis', label: 'NPA Account Detail Report' },
|
{ id: 'account-detail', group: 'analysis', label: 'NPA Account Detail Report' },
|
||||||
{ id: 'movement', group: 'analysis', label: 'NPA Movement Report' },
|
{ id: 'movement', group: 'analysis', label: 'NPA Movement Report' },
|
||||||
{ id: 'provision', group: 'analysis', label: 'NPA with Provision and Required Provision' },
|
{ id: 'provision', group: 'analysis', label: 'NPA with Provision and Required Provision' },
|
||||||
|
{ id: 'undebited-interest', group: 'analysis', label: 'Undebited Interest Recovery Report' },
|
||||||
{ id: 'probable', group: 'classification', label: 'Probable NPA' },
|
{ id: 'probable', group: 'classification', label: 'Probable NPA' },
|
||||||
{ id: 'sma', group: 'classification', label: 'SMA Report' }
|
{ id: 'sma', group: 'classification', label: 'SMA Report' },
|
||||||
|
{ id: 'recovery-dashboard', group: 'recovery-mgmt', label: 'Recovery Management Dashboard' },
|
||||||
|
{ id: 'daywise-overdue', group: 'recovery-mgmt', label: 'Day-wise Overdue Report' },
|
||||||
|
{ id: 'daywise-overdrawn', group: 'recovery-mgmt', label: 'Day-wise Overdrawn Report' },
|
||||||
|
{ id: 'agri-gold-value', group: 'recovery-mgmt', label: 'Agriculture / Gold Market Value Report' },
|
||||||
|
{ id: 'exception-check', group: 'recovery-mgmt', label: 'Exception Check — Security Value vs Loan' }
|
||||||
];
|
];
|
||||||
export const GROUP_LABELS: Record<ReportGroupId, string> = {
|
export const GROUP_LABELS: Record<ReportGroupId, string> = {
|
||||||
reports: 'NPA Reports',
|
reports: 'NPA Reports',
|
||||||
analysis: 'NPA Analysis',
|
analysis: 'NPA Analysis',
|
||||||
classification: 'NPA Classification'
|
classification: 'NPA Classification',
|
||||||
|
'recovery-mgmt': 'Recovery Management'
|
||||||
};
|
};
|
||||||
export const DEFAULT_REPORT_ID = 'summary';
|
export const DEFAULT_REPORT_ID = 'summary';
|
||||||
|
|||||||
@@ -38,6 +38,9 @@
|
|||||||
<input type="text" [(ngModel)]="asOnDate" placeholder="DD-MM-YYYY" />
|
<input type="text" [(ngModel)]="asOnDate" placeholder="DD-MM-YYYY" />
|
||||||
</label>
|
</label>
|
||||||
<button type="button" class="pg-btn pg-btn--primary" (click)="generate()">Generate</button>
|
<button type="button" class="pg-btn pg-btn--primary" (click)="generate()">Generate</button>
|
||||||
|
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="generating" (click)="exportExcel()">
|
||||||
|
{{ generating ? 'Exporting…' : 'Export to Excel' }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -72,7 +75,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="cctr-tabs">
|
<div class="cctr-tabs">
|
||||||
@for (g of ['reports', 'analysis', 'classification']; track g) {
|
@for (g of ['reports', 'analysis', 'classification', 'recovery-mgmt']; track g) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="pg-btn pg-btn--ghost"
|
class="pg-btn pg-btn--ghost"
|
||||||
@@ -100,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>
|
||||||
@@ -261,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" />
|
||||||
@@ -296,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>
|
||||||
@@ -459,9 +489,14 @@
|
|||||||
|
|
||||||
@if (selectedAccount; as a) {
|
@if (selectedAccount; as a) {
|
||||||
<div class="pg-card">
|
<div class="pg-card">
|
||||||
<div class="pos-section-label">
|
<div class="pos-section-label npa-detail-header">
|
||||||
{{ a.accountNo }} — {{ a.customerName }}
|
<span
|
||||||
<span [class]="badgeClass(stage(a))">{{ stageLabel(stage(a)) }}</span>
|
>{{ a.accountNo }} — {{ a.customerName }}
|
||||||
|
<span [class]="badgeClass(stage(a))">{{ stageLabel(stage(a)) }}</span></span
|
||||||
|
>
|
||||||
|
<button type="button" class="pg-btn npa-timeline-trigger" (click)="openTimelineModal()">
|
||||||
|
<fa-icon icon="history"></fa-icon> Recovery Journey
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<dl class="npa-detail-grid">
|
<dl class="npa-detail-grid">
|
||||||
<div class="npa-detail-field">
|
<div class="npa-detail-field">
|
||||||
@@ -781,7 +816,97 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- ===================== 08 PROBABLE NPA ===================== -->
|
<!-- ===================== 08 UNDEBITED INTEREST RECOVERY REPORT ===================== -->
|
||||||
|
@if (activeReport === 'undebited-interest') {
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">
|
||||||
|
Undebited Interest Recovery Report — Memorandum Interest Held Outside the Loan Account (IRAC Norms)
|
||||||
|
</div>
|
||||||
|
<p class="pg-sub npa-report-note">
|
||||||
|
Once an account turns NPA, interest is no longer debited to the borrower's loan account or recognised as income
|
||||||
|
— it is tracked here as memorandum "undebited interest" and recognised only when actually recovered in cash.
|
||||||
|
Cash recovered is appropriated to this bucket first; any balance beyond it reduces principal.
|
||||||
|
</p>
|
||||||
|
<div class="pg-table-scroll">
|
||||||
|
<table class="pg-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Account No</th>
|
||||||
|
<th>Customer</th>
|
||||||
|
<th>Branch</th>
|
||||||
|
<th>Stage</th>
|
||||||
|
<th>NPA Since</th>
|
||||||
|
<th class="num">Days NPA</th>
|
||||||
|
<th class="num">Rate</th>
|
||||||
|
<th class="num col-out">Undebited Interest Accrued</th>
|
||||||
|
<th class="num col-in">Interest Recovered (Cash)</th>
|
||||||
|
<th class="num">Undebited Interest Balance</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (a of paged(npaAccounts); track a.accountNo) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span class="pg-chip">{{ a.accountNo }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ a.customerName }}</td>
|
||||||
|
<td>{{ a.branchName }}</td>
|
||||||
|
<td>
|
||||||
|
<span [class]="badgeClass(stage(a))">{{ stageLabel(stage(a)) }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ npaSince(a) }}</td>
|
||||||
|
<td class="num">{{ daysSinceNpaOf(a) }}</td>
|
||||||
|
<td class="num">{{ a.interestRate }}%</td>
|
||||||
|
<td class="num col-out">₹{{ interestRev(a) | number: '1.0-0' }}</td>
|
||||||
|
<td class="num col-in">₹{{ interestRecoveredCashOf(a) | number: '1.0-0' }}</td>
|
||||||
|
<td class="num">₹{{ undebitedInterestBalanceOf(a) | number: '1.0-0' }}</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="10" class="pg-empty">No NPA accounts in this branch.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="cctr-total-row">
|
||||||
|
<td colspan="7">Total ({{ npaAccounts.length }} accounts)</td>
|
||||||
|
<td class="num">₹{{ undebitedInterestAccruedTotal | number: '1.0-0' }}</td>
|
||||||
|
<td class="num">₹{{ interestRecoveredCashTotal | number: '1.0-0' }}</td>
|
||||||
|
<td class="num">₹{{ undebitedInterestBalanceTotal | number: '1.0-0' }}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
@if (npaAccounts.length > pageSize) {
|
||||||
|
<div class="pg-pagination">
|
||||||
|
<span class="pg-pagination__info"
|
||||||
|
>Showing {{ rangeStart(npaAccounts) }}–{{ rangeEnd(npaAccounts) }} of {{ npaAccounts.length }}</span
|
||||||
|
>
|
||||||
|
<div class="pg-pagination__controls">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--ghost"
|
||||||
|
[disabled]="currentPage === 1"
|
||||||
|
(click)="goToPage(currentPage - 1, npaAccounts)"
|
||||||
|
>
|
||||||
|
‹ Prev
|
||||||
|
</button>
|
||||||
|
<span class="pg-pagination__page">Page {{ currentPage }} of {{ pageCount(npaAccounts) }}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--ghost"
|
||||||
|
[disabled]="currentPage === pageCount(npaAccounts)"
|
||||||
|
(click)="goToPage(currentPage + 1, npaAccounts)"
|
||||||
|
>
|
||||||
|
Next ›
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- ===================== 09 PROBABLE NPA ===================== -->
|
||||||
@if (activeReport === 'probable') {
|
@if (activeReport === 'probable') {
|
||||||
<div class="pg-card">
|
<div class="pg-card">
|
||||||
<div class="pos-section-label">
|
<div class="pos-section-label">
|
||||||
@@ -868,7 +993,7 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- ===================== 09 SMA REPORT ===================== -->
|
<!-- ===================== 10 SMA REPORT ===================== -->
|
||||||
@if (activeReport === 'sma') {
|
@if (activeReport === 'sma') {
|
||||||
<div class="pg-card">
|
<div class="pg-card">
|
||||||
<div class="pos-section-label">Special Mention Accounts — SMA-0 (1–30d) / SMA-1 (31–60d) / SMA-2 (61–90d)</div>
|
<div class="pos-section-label">Special Mention Accounts — SMA-0 (1–30d) / SMA-1 (31–60d) / SMA-2 (61–90d)</div>
|
||||||
@@ -944,6 +1069,548 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<!-- ===================== 11 RECOVERY MANAGEMENT DASHBOARD ===================== -->
|
||||||
|
@if (activeReport === 'recovery-dashboard') {
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">Recovery Management Dashboard — Loan-Type Overdue Check</div>
|
||||||
|
<p class="pg-sub npa-report-note">
|
||||||
|
Every loan is checked for "is this cycle's dues fully recovered" the way it's actually due — a Term Loan (EMI)
|
||||||
|
is checked against its instalment, an Overdrawn (CC/OD/KCC) account against its drawing power and interest
|
||||||
|
servicing, and a Bullet loan (Gold) is checked once past maturity.
|
||||||
|
</p>
|
||||||
|
<div class="inv-kpis npa-kpis--three">
|
||||||
|
@for (row of recoveryTypeSummary; track row.type) {
|
||||||
|
<div class="inv-kpi">
|
||||||
|
<span class="inv-kpi__label">{{ row.label }}</span>
|
||||||
|
<span class="inv-kpi__value">{{ row.accounts }} accounts</span>
|
||||||
|
<span class="inv-kpi__sub" [class.npa-warn]="row.overdueCount > 0"
|
||||||
|
>{{ row.overdueCount }} overdue this cycle</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">Expected vs Actual Recovery — This Cycle, by Loan Type</div>
|
||||||
|
<table class="pg-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Loan Type</th>
|
||||||
|
<th class="num">Accounts</th>
|
||||||
|
<th class="num">Overdue Now</th>
|
||||||
|
<th class="num">Expected Recovery</th>
|
||||||
|
<th class="num col-in">Actual Recovery</th>
|
||||||
|
<th class="num">Variance</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (row of recoveryTypeSummary; track row.type) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ row.label }}</td>
|
||||||
|
<td class="num">{{ row.accounts }}</td>
|
||||||
|
<td class="num">{{ row.overdueCount }}</td>
|
||||||
|
<td class="num">₹{{ row.expected | number: '1.0-0' }}</td>
|
||||||
|
<td class="num col-in">₹{{ row.actual | number: '1.0-0' }}</td>
|
||||||
|
<td class="num" [class.col-out]="row.actual - row.expected < 0">
|
||||||
|
₹{{ row.actual - row.expected | number: '1.0-0' }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="cctr-total-row">
|
||||||
|
<td>Total</td>
|
||||||
|
<td class="num">{{ allAccounts.length }}</td>
|
||||||
|
<td class="num">
|
||||||
|
{{
|
||||||
|
recoveryTypeSummary[0].overdueCount +
|
||||||
|
recoveryTypeSummary[1].overdueCount +
|
||||||
|
recoveryTypeSummary[2].overdueCount
|
||||||
|
}}
|
||||||
|
</td>
|
||||||
|
<td class="num">₹{{ expectedRecoveryTotal | number: '1.0-0' }}</td>
|
||||||
|
<td class="num">₹{{ actualRecoveryTotal | number: '1.0-0' }}</td>
|
||||||
|
<td class="num" [class.col-out]="recoveryVarianceTotal < 0">
|
||||||
|
₹{{ recoveryVarianceTotal | number: '1.0-0' }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
<p class="pg-sub npa-report-note">
|
||||||
|
Overall recovery rate: {{ recoveryPctTotal | number: '1.1-1' }}% of this cycle's expected dues.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">Loan-wise Expected vs Actual Recovery</div>
|
||||||
|
<div class="pg-table-scroll">
|
||||||
|
<table class="pg-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Account No</th>
|
||||||
|
<th>Customer</th>
|
||||||
|
<th>Branch</th>
|
||||||
|
<th>Loan Type</th>
|
||||||
|
<th class="num">Expected</th>
|
||||||
|
<th class="num col-in">Actual</th>
|
||||||
|
<th class="num">Variance</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (a of paged(loanWiseRecoveryAccounts); track a.accountNo) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span class="pg-chip">{{ a.accountNo }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ a.customerName }}</td>
|
||||||
|
<td>{{ a.branchName }}</td>
|
||||||
|
<td>{{ repaymentTypeLabel(a.repaymentType) }}</td>
|
||||||
|
<td class="num">₹{{ expectedThisMonth(a) | number: '1.0-0' }}</td>
|
||||||
|
<td class="num col-in">₹{{ actualThisMonth(a) | number: '1.0-0' }}</td>
|
||||||
|
<td class="num" [class.col-out]="recoveryVariance(a) < 0">
|
||||||
|
₹{{ recoveryVariance(a) | number: '1.0-0' }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
class="npa-badge"
|
||||||
|
[class.npa-badge--standard]="recoveryVariance(a) >= 0"
|
||||||
|
[class.npa-badge--substandard]="recoveryVariance(a) < 0"
|
||||||
|
>
|
||||||
|
{{ recoveryVariance(a) >= 0 ? 'Recovered' : 'Shortfall' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="8" class="pg-empty">No accounts in this branch.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
@if (loanWiseRecoveryAccounts.length > pageSize) {
|
||||||
|
<div class="pg-pagination">
|
||||||
|
<span class="pg-pagination__info"
|
||||||
|
>Showing {{ rangeStart(loanWiseRecoveryAccounts) }}–{{ rangeEnd(loanWiseRecoveryAccounts) }} of
|
||||||
|
{{ loanWiseRecoveryAccounts.length }}</span
|
||||||
|
>
|
||||||
|
<div class="pg-pagination__controls">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--ghost"
|
||||||
|
[disabled]="currentPage === 1"
|
||||||
|
(click)="goToPage(currentPage - 1, loanWiseRecoveryAccounts)"
|
||||||
|
>
|
||||||
|
‹ Prev
|
||||||
|
</button>
|
||||||
|
<span class="pg-pagination__page">Page {{ currentPage }} of {{ pageCount(loanWiseRecoveryAccounts) }}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--ghost"
|
||||||
|
[disabled]="currentPage === pageCount(loanWiseRecoveryAccounts)"
|
||||||
|
(click)="goToPage(currentPage + 1, loanWiseRecoveryAccounts)"
|
||||||
|
>
|
||||||
|
Next ›
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- ===================== 12 DAY-WISE OVERDUE REPORT ===================== -->
|
||||||
|
@if (activeReport === 'daywise-overdue') {
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">Day-wise Overdue Report — Ageing Buckets</div>
|
||||||
|
<table class="pg-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Overdue Bucket</th>
|
||||||
|
<th class="num">Accounts</th>
|
||||||
|
<th class="num">Outstanding</th>
|
||||||
|
<th class="num col-out">Overdue Amount</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (b of overdueAgeingBuckets; track b.label) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ b.label }}</td>
|
||||||
|
<td class="num">{{ b.count }}</td>
|
||||||
|
<td class="num">₹{{ b.outstanding | number: '1.0-0' }}</td>
|
||||||
|
<td class="num col-out">₹{{ b.overdueAmt | number: '1.0-0' }}</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">Overdue Accounts — Day-wise, Marked by SMA Stage</div>
|
||||||
|
<div class="pg-table-scroll">
|
||||||
|
<table class="pg-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Account No</th>
|
||||||
|
<th>Customer</th>
|
||||||
|
<th>Branch</th>
|
||||||
|
<th>Product</th>
|
||||||
|
<th>Stage</th>
|
||||||
|
<th class="num">Overdue Days</th>
|
||||||
|
<th class="num">Outstanding</th>
|
||||||
|
<th class="num col-out">Overdue Amount</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (a of paged(overdueDaysAccounts); track a.accountNo) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span class="pg-chip">{{ a.accountNo }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ a.customerName }}</td>
|
||||||
|
<td>{{ a.branchName }}</td>
|
||||||
|
<td>{{ a.productName }}</td>
|
||||||
|
<td>
|
||||||
|
<span [class]="badgeClass(stage(a))">{{ stageLabel(stage(a)) }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="num">{{ a.overdueDays }}</td>
|
||||||
|
<td class="num">₹{{ a.outstandingBalance | number: '1.0-0' }}</td>
|
||||||
|
<td class="num col-out">₹{{ a.overdueAmount | number: '1.0-0' }}</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="8" class="pg-empty">No overdue accounts in this branch.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
@if (overdueDaysAccounts.length > pageSize) {
|
||||||
|
<div class="pg-pagination">
|
||||||
|
<span class="pg-pagination__info"
|
||||||
|
>Showing {{ rangeStart(overdueDaysAccounts) }}–{{ rangeEnd(overdueDaysAccounts) }} of
|
||||||
|
{{ overdueDaysAccounts.length }}</span
|
||||||
|
>
|
||||||
|
<div class="pg-pagination__controls">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--ghost"
|
||||||
|
[disabled]="currentPage === 1"
|
||||||
|
(click)="goToPage(currentPage - 1, overdueDaysAccounts)"
|
||||||
|
>
|
||||||
|
‹ Prev
|
||||||
|
</button>
|
||||||
|
<span class="pg-pagination__page">Page {{ currentPage }} of {{ pageCount(overdueDaysAccounts) }}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--ghost"
|
||||||
|
[disabled]="currentPage === pageCount(overdueDaysAccounts)"
|
||||||
|
(click)="goToPage(currentPage + 1, overdueDaysAccounts)"
|
||||||
|
>
|
||||||
|
Next ›
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- ===================== 13 DAY-WISE OVERDRAWN REPORT ===================== -->
|
||||||
|
@if (activeReport === 'daywise-overdrawn') {
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">
|
||||||
|
Day-wise Overdrawn Report — Cash Credit / Overdraft / KCC Accounts ({{ overdrawnBreachCount }} of
|
||||||
|
{{ overdrawnAccounts.length }} breaching Drawing Power)
|
||||||
|
</div>
|
||||||
|
<div class="pg-table-scroll">
|
||||||
|
<table class="pg-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Account No</th>
|
||||||
|
<th>Customer</th>
|
||||||
|
<th>Branch</th>
|
||||||
|
<th>Product</th>
|
||||||
|
<th class="num">Drawing Power</th>
|
||||||
|
<th class="num">Outstanding</th>
|
||||||
|
<th class="num col-out">Excess Over DP</th>
|
||||||
|
<th class="num">Days Overdrawn</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (a of paged(overdrawnAccounts); track a.accountNo) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span class="pg-chip">{{ a.accountNo }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ a.customerName }}</td>
|
||||||
|
<td>{{ a.branchName }}</td>
|
||||||
|
<td>{{ a.productName }}</td>
|
||||||
|
<td class="num">₹{{ a.drawingPower | number: '1.0-0' }}</td>
|
||||||
|
<td class="num">₹{{ a.outstandingBalance | number: '1.0-0' }}</td>
|
||||||
|
<td class="num col-out">₹{{ excessOverDp(a) | number: '1.0-0' }}</td>
|
||||||
|
<td class="num">{{ overdrawnBreachOf(a) ? a.overdueDays : 0 }}</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
class="npa-badge"
|
||||||
|
[class.npa-badge--standard]="!overdrawnBreachOf(a)"
|
||||||
|
[class.npa-badge--substandard]="overdrawnBreachOf(a)"
|
||||||
|
>
|
||||||
|
{{ overdrawnBreachOf(a) ? 'DP Breached' : 'Within Limit' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="9" class="pg-empty">No Overdrawn accounts in this branch.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
@if (overdrawnAccounts.length > pageSize) {
|
||||||
|
<div class="pg-pagination">
|
||||||
|
<span class="pg-pagination__info"
|
||||||
|
>Showing {{ rangeStart(overdrawnAccounts) }}–{{ rangeEnd(overdrawnAccounts) }} of
|
||||||
|
{{ overdrawnAccounts.length }}</span
|
||||||
|
>
|
||||||
|
<div class="pg-pagination__controls">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--ghost"
|
||||||
|
[disabled]="currentPage === 1"
|
||||||
|
(click)="goToPage(currentPage - 1, overdrawnAccounts)"
|
||||||
|
>
|
||||||
|
‹ Prev
|
||||||
|
</button>
|
||||||
|
<span class="pg-pagination__page">Page {{ currentPage }} of {{ pageCount(overdrawnAccounts) }}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--ghost"
|
||||||
|
[disabled]="currentPage === pageCount(overdrawnAccounts)"
|
||||||
|
(click)="goToPage(currentPage + 1, overdrawnAccounts)"
|
||||||
|
>
|
||||||
|
Next ›
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- ===================== 14 AGRICULTURE / GOLD MARKET VALUE REPORT ===================== -->
|
||||||
|
@if (activeReport === 'agri-gold-value') {
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">Agriculture / Gold Market Value Report — Current Security Value vs Loan</div>
|
||||||
|
<div class="pg-table-scroll">
|
||||||
|
<table class="pg-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Account No</th>
|
||||||
|
<th>Customer</th>
|
||||||
|
<th>Branch</th>
|
||||||
|
<th>Product</th>
|
||||||
|
<th class="num">Outstanding</th>
|
||||||
|
<th class="num">Collateral Value (At Sanction)</th>
|
||||||
|
<th class="num">Market Value (Now)</th>
|
||||||
|
<th class="num">Coverage %</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (a of paged(agriGoldAccounts); track a.accountNo) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span class="pg-chip">{{ a.accountNo }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ a.customerName }}</td>
|
||||||
|
<td>{{ a.branchName }}</td>
|
||||||
|
<td>{{ a.productName }}</td>
|
||||||
|
<td class="num">₹{{ a.outstandingBalance | number: '1.0-0' }}</td>
|
||||||
|
<td class="num">₹{{ a.collateralValue | number: '1.0-0' }}</td>
|
||||||
|
<td
|
||||||
|
class="num"
|
||||||
|
[class.col-in]="a.collateralMarketValue >= a.collateralValue"
|
||||||
|
[class.col-out]="a.collateralMarketValue < a.collateralValue"
|
||||||
|
>
|
||||||
|
₹{{ a.collateralMarketValue | number: '1.0-0' }}
|
||||||
|
</td>
|
||||||
|
<td class="num">{{ coverageRatio(a) | number: '1.0-0' }}%</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
class="npa-badge"
|
||||||
|
[class.npa-badge--standard]="!undersecuredOf(a)"
|
||||||
|
[class.npa-badge--substandard]="undersecuredOf(a)"
|
||||||
|
>
|
||||||
|
{{ undersecuredOf(a) ? 'Undersecured' : 'Adequate' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="9" class="pg-empty">No Agriculture / Gold accounts in this branch.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
@if (agriGoldAccounts.length > pageSize) {
|
||||||
|
<div class="pg-pagination">
|
||||||
|
<span class="pg-pagination__info"
|
||||||
|
>Showing {{ rangeStart(agriGoldAccounts) }}–{{ rangeEnd(agriGoldAccounts) }} of
|
||||||
|
{{ agriGoldAccounts.length }}</span
|
||||||
|
>
|
||||||
|
<div class="pg-pagination__controls">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--ghost"
|
||||||
|
[disabled]="currentPage === 1"
|
||||||
|
(click)="goToPage(currentPage - 1, agriGoldAccounts)"
|
||||||
|
>
|
||||||
|
‹ Prev
|
||||||
|
</button>
|
||||||
|
<span class="pg-pagination__page">Page {{ currentPage }} of {{ pageCount(agriGoldAccounts) }}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--ghost"
|
||||||
|
[disabled]="currentPage === pageCount(agriGoldAccounts)"
|
||||||
|
(click)="goToPage(currentPage + 1, agriGoldAccounts)"
|
||||||
|
>
|
||||||
|
Next ›
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- ===================== 15 EXCEPTION CHECK REPORT ===================== -->
|
||||||
|
@if (activeReport === 'exception-check') {
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">
|
||||||
|
Exception Check — Security Value vs Loan ({{ exceptionAccounts.length }} accounts undersecured)
|
||||||
|
</div>
|
||||||
|
<p class="pg-sub npa-report-note">
|
||||||
|
Every secured loan's current security market value is expected to stay above its outstanding balance. This lists
|
||||||
|
the exceptions — where it doesn't — so recovery/credit can review before the next classification review.
|
||||||
|
</p>
|
||||||
|
<div class="pg-table-scroll">
|
||||||
|
<table class="pg-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Account No</th>
|
||||||
|
<th>Customer</th>
|
||||||
|
<th>Branch</th>
|
||||||
|
<th>Product</th>
|
||||||
|
<th>Stage</th>
|
||||||
|
<th class="num">Outstanding</th>
|
||||||
|
<th class="num">Market Value</th>
|
||||||
|
<th class="num col-out">Shortfall</th>
|
||||||
|
<th class="num">Coverage %</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (a of paged(exceptionAccounts); track a.accountNo) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span class="pg-chip">{{ a.accountNo }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ a.customerName }}</td>
|
||||||
|
<td>{{ a.branchName }}</td>
|
||||||
|
<td>{{ a.productName }}</td>
|
||||||
|
<td>
|
||||||
|
<span [class]="badgeClass(stage(a))">{{ stageLabel(stage(a)) }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="num">₹{{ a.outstandingBalance | number: '1.0-0' }}</td>
|
||||||
|
<td class="num">₹{{ a.collateralMarketValue | number: '1.0-0' }}</td>
|
||||||
|
<td class="num col-out">₹{{ securityShortfall(a) | number: '1.0-0' }}</td>
|
||||||
|
<td class="num">{{ coverageRatio(a) | number: '1.0-0' }}%</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="9" class="pg-empty">No undersecured accounts in this branch.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
@if (exceptionAccounts.length > pageSize) {
|
||||||
|
<div class="pg-pagination">
|
||||||
|
<span class="pg-pagination__info"
|
||||||
|
>Showing {{ rangeStart(exceptionAccounts) }}–{{ rangeEnd(exceptionAccounts) }} of
|
||||||
|
{{ exceptionAccounts.length }}</span
|
||||||
|
>
|
||||||
|
<div class="pg-pagination__controls">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--ghost"
|
||||||
|
[disabled]="currentPage === 1"
|
||||||
|
(click)="goToPage(currentPage - 1, exceptionAccounts)"
|
||||||
|
>
|
||||||
|
‹ Prev
|
||||||
|
</button>
|
||||||
|
<span class="pg-pagination__page">Page {{ currentPage }} of {{ pageCount(exceptionAccounts) }}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--ghost"
|
||||||
|
[disabled]="currentPage === pageCount(exceptionAccounts)"
|
||||||
|
(click)="goToPage(currentPage + 1, exceptionAccounts)"
|
||||||
|
>
|
||||||
|
Next ›
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ===================== Recovery / Collection Journey popup ===================== -->
|
||||||
|
@if (showTimelineModal && selectedAccount; as a) {
|
||||||
|
<div class="npa-timeline-backdrop" (click)="closeTimelineModal()">
|
||||||
|
<div class="npa-timeline-modal" (click)="$event.stopPropagation()">
|
||||||
|
<header class="npa-timeline-modal__head">
|
||||||
|
<div class="npa-timeline-modal__title">
|
||||||
|
<span class="npa-timeline-modal__eyebrow">Recovery / Collection Journey</span>
|
||||||
|
<h2>{{ a.accountNo }} — {{ a.customerName }}</h2>
|
||||||
|
<div class="npa-timeline-modal__meta">
|
||||||
|
<span [class]="badgeClass(stage(a))">{{ stageLabel(stage(a)) }}</span>
|
||||||
|
<span class="npa-timeline-modal__branch">{{ a.branchName }}</span>
|
||||||
|
<span class="npa-timeline-modal__count">{{ a.collectionTimeline.length }} touchpoints</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="npa-timeline-modal__close" (click)="closeTimelineModal()" aria-label="Close">
|
||||||
|
<fa-icon icon="times"></fa-icon>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div class="npa-timeline-modal__body">
|
||||||
|
@if (a.collectionTimeline.length) {
|
||||||
|
<ol class="npa-timeline">
|
||||||
|
@for (e of a.collectionTimeline; track e.date + e.type) {
|
||||||
|
<li [class]="'npa-timeline-item npa-timeline-item--' + timelineEventTone(e.type)">
|
||||||
|
<span class="npa-timeline-item__icon"><fa-icon [icon]="timelineEventIcon(e.type)"></fa-icon></span>
|
||||||
|
<div class="npa-timeline-item__body">
|
||||||
|
<div class="npa-timeline-item__row">
|
||||||
|
<span class="npa-timeline-item__type">{{ e.type }}</span>
|
||||||
|
<span class="npa-timeline-item__date">{{ e.date }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="npa-timeline-item__remarks">{{ e.remarks }}</p>
|
||||||
|
<span class="npa-timeline-item__channel">{{ e.channel }}</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ol>
|
||||||
|
} @else {
|
||||||
|
<div class="npa-timeline-empty">
|
||||||
|
<fa-icon icon="check-circle"></fa-icon>
|
||||||
|
<p>No collection activity — this account has never gone overdue.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<div class="inv-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
|
<div class="inv-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,10 @@
|
|||||||
// and ../investment/investment-shared.scss (inv-kpis, inv-form-*, inv-toast — reused as-is since
|
// and ../investment/investment-shared.scss (inv-kpis, inv-form-*, inv-toast — reused as-is since
|
||||||
// they are the same generic "KPI tile / entry form / toast" primitives, not Investment-specific).
|
// they are the same generic "KPI tile / entry form / toast" primitives, not Investment-specific).
|
||||||
|
|
||||||
|
.npa-report-note {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
.npa-badge {
|
.npa-badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -84,6 +88,301 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Recovery Management Dashboard's 3 loan-type panels — same .inv-kpi tile, just a 3-wide grid
|
||||||
|
// instead of the portfolio strip's 4-wide one.
|
||||||
|
.npa-kpis--three {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Account Detail header — title/badge on the left, the Recovery Journey trigger pinned right.
|
||||||
|
.npa-detail-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.npa-timeline-trigger {
|
||||||
|
margin-left: auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
background: linear-gradient(135deg, #eef2ff, #e0e7ff);
|
||||||
|
color: #4338ca;
|
||||||
|
border: 1px solid #c7d2fe;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: normal;
|
||||||
|
transition:
|
||||||
|
box-shadow 0.15s ease,
|
||||||
|
transform 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: linear-gradient(135deg, #e0e7ff, #c7d2fe);
|
||||||
|
box-shadow: 0 4px 14px rgb(67 56 202 / 22%);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Recovery / Collection Journey popup — a date-led timeline of every SMS, call,
|
||||||
|
// visit, notice and payment run against one account, opened from the Account
|
||||||
|
// Detail Report's "Recovery Journey" button.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.npa-timeline-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgb(15 23 42 / 55%);
|
||||||
|
backdrop-filter: blur(3px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 24px;
|
||||||
|
animation: npa-timeline-fade-in 0.15s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.npa-timeline-modal {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 18px;
|
||||||
|
width: 640px;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: min(720px, 88vh);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: 0 24px 60px rgb(15 23 42 / 35%);
|
||||||
|
animation: npa-timeline-slide-up 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 20px 22px;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
background: linear-gradient(180deg, #f8fafc, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__eyebrow {
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #4338ca;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__branch::before,
|
||||||
|
&__count::before {
|
||||||
|
content: '·';
|
||||||
|
margin-right: 8px;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__close {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
background: #fff;
|
||||||
|
color: #64748b;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background 0.15s ease,
|
||||||
|
color 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__body {
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 22px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes npa-timeline-fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes npa-timeline-slide-up {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(14px) scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.npa-timeline-backdrop,
|
||||||
|
.npa-timeline-modal {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The timeline list itself — a connecting vertical line runs behind the icon badges.
|
||||||
|
.npa-timeline {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.npa-timeline-item {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
padding-bottom: 22px;
|
||||||
|
|
||||||
|
// The connecting line spans from just under this item's icon to just above the next one's —
|
||||||
|
// anchored to the item itself (not the fixed-size icon) via top+bottom, so it always reaches
|
||||||
|
// exactly as far as the gap to the next entry, however tall this entry's text runs.
|
||||||
|
&:not(:last-child)::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 34px;
|
||||||
|
bottom: -22px;
|
||||||
|
left: 17px;
|
||||||
|
width: 2px;
|
||||||
|
background: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 999px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 13px;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--neutral &__icon {
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--alert &__icon {
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--positive &__icon {
|
||||||
|
background: #f0fdf4;
|
||||||
|
color: #15803d;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__type {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 13.5px;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__date {
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__remarks {
|
||||||
|
margin: 3px 0 6px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: #475569;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__channel {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #64748b;
|
||||||
|
background: #f1f5f9;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.npa-timeline-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 40px 20px;
|
||||||
|
color: #64748b;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
font-size: 26px;
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.npa-detail-grid {
|
.npa-detail-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,22 @@
|
|||||||
</mat-card-header>
|
</mat-card-header>
|
||||||
|
|
||||||
<mat-card-content class="aml-card-content">
|
<mat-card-content class="aml-card-content">
|
||||||
|
<!-- Separate the rules by the window they are measured over -->
|
||||||
|
<div class="period-filters">
|
||||||
|
<span class="period-filters-label">Applies over</span>
|
||||||
|
<button
|
||||||
|
*ngFor="let p of periodFilters"
|
||||||
|
type="button"
|
||||||
|
class="period-chip"
|
||||||
|
[class.active]="activePeriod === p.key"
|
||||||
|
[disabled]="periodCount(p.key) === 0"
|
||||||
|
(click)="setPeriod(p.key)"
|
||||||
|
>
|
||||||
|
{{ p.label }}
|
||||||
|
<span class="period-chip-n">{{ periodCount(p.key) }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<table mat-table [dataSource]="rules" class="aml-table mat-elevation-z1">
|
<table mat-table [dataSource]="rules" class="aml-table mat-elevation-z1">
|
||||||
<!-- Order Position Column -->
|
<!-- Order Position Column -->
|
||||||
<ng-container matColumnDef="orderPosition">
|
<ng-container matColumnDef="orderPosition">
|
||||||
@@ -45,10 +61,37 @@
|
|||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<!-- Income / Occupation Limit Column -->
|
<!-- What the min/max figures are measured against -->
|
||||||
<ng-container matColumnDef="incomeLimit">
|
<ng-container matColumnDef="limitBasis">
|
||||||
<th mat-header-cell *matHeaderCellDef>Income / Occupation Limit</th>
|
<th mat-header-cell *matHeaderCellDef>Limit Basis</th>
|
||||||
<td mat-cell *matCellDef="let element" class="income-limit-cell">{{ getIncomeLimitLabel(element) }}</td>
|
<td mat-cell *matCellDef="let element" class="basis-cell">
|
||||||
|
{{ getLimitBasis(element) }}
|
||||||
|
<span *ngIf="getNonNumericCriterion(element)" class="basis-sub">
|
||||||
|
{{ getNonNumericCriterion(element) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Minimum Limit Column -->
|
||||||
|
<ng-container matColumnDef="minLimit">
|
||||||
|
<th mat-header-cell *matHeaderCellDef class="col-right">Minimum Limit</th>
|
||||||
|
<td mat-cell *matCellDef="let element" class="col-right amount-cell">{{ getMinLimit(element) }}</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Maximum Limit Column -->
|
||||||
|
<ng-container matColumnDef="maxLimit">
|
||||||
|
<th mat-header-cell *matHeaderCellDef class="col-right">Maximum Limit</th>
|
||||||
|
<td mat-cell *matCellDef="let element" class="col-right amount-cell">
|
||||||
|
<span [class.no-ceiling]="getMaxLimit(element) === 'No upper limit'">{{ getMaxLimit(element) }}</span>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Observation window the rule is measured over -->
|
||||||
|
<ng-container matColumnDef="appliesOver">
|
||||||
|
<th mat-header-cell *matHeaderCellDef class="col-center">Applies Over</th>
|
||||||
|
<td mat-cell *matCellDef="let element" class="col-center">
|
||||||
|
<span [ngClass]="periodClass(element)">{{ periodLabel(element) }}</span>
|
||||||
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<!-- Reason Column -->
|
<!-- Reason Column -->
|
||||||
|
|||||||
@@ -114,13 +114,141 @@
|
|||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.income-limit-cell {
|
.col-right {
|
||||||
|
text-align: right;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* What the min/max amounts are measured against. */
|
||||||
|
.basis-cell {
|
||||||
color: #334155;
|
color: #334155;
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
max-width: 200px;
|
max-width: 150px;
|
||||||
line-height: 1.35;
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.basis-sub {
|
||||||
|
display: block;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount-cell {
|
||||||
|
font-family: 'DM Mono', monospace;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: #0f172a;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* An open-ended threshold rule has a floor but no ceiling. */
|
||||||
|
.no-ceiling {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
font-style: italic;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Observation-window badges ─────────────────────────────────────────── */
|
||||||
|
.period-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
|
||||||
|
&.period-txn {
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.period-day {
|
||||||
|
background: #dbeafe;
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.period-week {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.period-month {
|
||||||
|
background: #ede9fe;
|
||||||
|
color: #6d28d9;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.period-other {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chips that separate the rule list by window. */
|
||||||
|
.period-filters {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 0 0 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.period-filters-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #64748b;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin-right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.period-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 6px 13px;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #334155;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: all 0.15s;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
border-color: #1a237e;
|
||||||
|
color: #1a237e;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: #1a237e;
|
||||||
|
border-color: #1a237e;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.period-chip-n {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 1px 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #475569;
|
||||||
|
|
||||||
|
.period-chip.active & {
|
||||||
|
background: rgb(255 255 255 / 22%);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Badges for Risk Category */
|
/* Badges for Risk Category */
|
||||||
|
|||||||
@@ -42,24 +42,185 @@ export class AmlRulesComponent implements OnInit {
|
|||||||
'orderPosition',
|
'orderPosition',
|
||||||
'ruleName',
|
'ruleName',
|
||||||
'riskCategory',
|
'riskCategory',
|
||||||
'incomeLimit',
|
'limitBasis',
|
||||||
|
'minLimit',
|
||||||
|
'maxLimit',
|
||||||
|
'appliesOver',
|
||||||
'reason',
|
'reason',
|
||||||
'amlCheckStatus',
|
'amlCheckStatus',
|
||||||
'bannedEntityMatch'
|
'bannedEntityMatch'
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Every rule as returned by the API, before the period filter is applied. */
|
||||||
|
private allRules: any[] = [];
|
||||||
|
|
||||||
|
/** Period buckets the rules can be separated into. */
|
||||||
|
readonly periodFilters: { key: string; label: string }[] = [
|
||||||
|
{ key: '', label: 'All rules' },
|
||||||
|
{ key: 'TXN', label: 'Per transaction' },
|
||||||
|
{ key: 'DAY', label: 'Daily' },
|
||||||
|
{ key: 'WEEK', label: 'Weekly' },
|
||||||
|
{ key: 'MONTH', label: 'Monthly' },
|
||||||
|
{ key: 'OTHER', label: 'Other window' }
|
||||||
|
];
|
||||||
|
activePeriod = '';
|
||||||
|
|
||||||
@ViewChild(MatPaginator) paginator!: MatPaginator;
|
@ViewChild(MatPaginator) paginator!: MatPaginator;
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.clientsService.getClientRiskRules().subscribe({
|
this.clientsService.getClientRiskRules().subscribe({
|
||||||
next: (data) => {
|
next: (data) => {
|
||||||
this.rules.data = data || [];
|
this.allRules = data || [];
|
||||||
|
this.applyPeriodFilter();
|
||||||
this.rules.paginator = this.paginator;
|
this.rules.paginator = this.paginator;
|
||||||
},
|
},
|
||||||
error: () => {}
|
error: () => {}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setPeriod(key: string): void {
|
||||||
|
this.activePeriod = key;
|
||||||
|
this.applyPeriodFilter();
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyPeriodFilter(): void {
|
||||||
|
this.rules.data = this.activePeriod
|
||||||
|
? this.allRules.filter((r) => this.periodKey(r) === this.activePeriod)
|
||||||
|
: this.allRules;
|
||||||
|
if (this.rules.paginator) {
|
||||||
|
this.rules.paginator.firstPage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How many rules fall in each period bucket — shown on the filter chips. */
|
||||||
|
periodCount(key: string): number {
|
||||||
|
return key ? this.allRules.filter((r) => this.periodKey(r) === key).length : this.allRules.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Buckets a rule by the observation window it is measured over.
|
||||||
|
* A rule with no window is evaluated on the single transaction itself, so it is
|
||||||
|
* in force every day rather than accumulating over a period.
|
||||||
|
*/
|
||||||
|
periodKey(rule: any): string {
|
||||||
|
const days = rule.txnWindowDays;
|
||||||
|
if (days == null || days === 0) {
|
||||||
|
return 'TXN';
|
||||||
|
}
|
||||||
|
if (days === 1) {
|
||||||
|
return 'DAY';
|
||||||
|
}
|
||||||
|
if (days === 7) {
|
||||||
|
return 'WEEK';
|
||||||
|
}
|
||||||
|
if (days >= 28 && days <= 31) {
|
||||||
|
return 'MONTH';
|
||||||
|
}
|
||||||
|
return 'OTHER';
|
||||||
|
}
|
||||||
|
|
||||||
|
periodLabel(rule: any): string {
|
||||||
|
const days = rule.txnWindowDays;
|
||||||
|
switch (this.periodKey(rule)) {
|
||||||
|
case 'TXN':
|
||||||
|
return 'Per transaction';
|
||||||
|
case 'DAY':
|
||||||
|
return 'Daily';
|
||||||
|
case 'WEEK':
|
||||||
|
return 'Weekly';
|
||||||
|
case 'MONTH':
|
||||||
|
return 'Monthly';
|
||||||
|
default:
|
||||||
|
return `${days} days`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
periodClass(rule: any): string {
|
||||||
|
return 'period-badge period-' + this.periodKey(rule).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sub-caption telling the reader what the min/max figures actually measure. */
|
||||||
|
getLimitBasis(rule: any): string {
|
||||||
|
if (rule.minMonthlyIncome > 0 || rule.maxMonthlyIncome > 0) {
|
||||||
|
return 'Monthly Income';
|
||||||
|
}
|
||||||
|
if (rule.minAnnualIncome > 0 || rule.maxAnnualIncome > 0) {
|
||||||
|
return 'Annual Income';
|
||||||
|
}
|
||||||
|
if (rule.minAnnualTurnover > 0 || rule.maxAnnualTurnover > 0) {
|
||||||
|
return 'Annual Turnover';
|
||||||
|
}
|
||||||
|
if (rule.txnThresholdAmountMediumRisk != null || rule.txnThresholdAmountHighRisk != null) {
|
||||||
|
return 'Txn Amount (tiered)';
|
||||||
|
}
|
||||||
|
if (rule.txnThresholdAmount != null) {
|
||||||
|
return 'Txn Amount';
|
||||||
|
}
|
||||||
|
if (rule.occupationName) {
|
||||||
|
return 'Occupation';
|
||||||
|
}
|
||||||
|
if (rule.occupationCategoryName) {
|
||||||
|
return 'Occupation Category';
|
||||||
|
}
|
||||||
|
return 'Behavioural pattern';
|
||||||
|
}
|
||||||
|
|
||||||
|
private inr(n: number): string {
|
||||||
|
return '₹' + Number(n).toLocaleString('en-IN');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lower bound of the rule, or an em dash where the criterion is not numeric. */
|
||||||
|
getMinLimit(rule: any): string {
|
||||||
|
if (rule.minMonthlyIncome > 0) {
|
||||||
|
return this.inr(rule.minMonthlyIncome);
|
||||||
|
}
|
||||||
|
if (rule.minAnnualIncome > 0) {
|
||||||
|
return this.inr(rule.minAnnualIncome);
|
||||||
|
}
|
||||||
|
if (rule.minAnnualTurnover > 0) {
|
||||||
|
return this.inr(rule.minAnnualTurnover);
|
||||||
|
}
|
||||||
|
// Declarative band from m_aml_transaction_rule takes precedence over the raw threshold.
|
||||||
|
if (rule.txnMinLimitAmount != null) {
|
||||||
|
return this.inr(rule.txnMinLimitAmount);
|
||||||
|
}
|
||||||
|
if (rule.txnThresholdAmount != null) {
|
||||||
|
return this.inr(rule.txnThresholdAmount);
|
||||||
|
}
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upper bound, or "No upper limit" where the rule is genuinely open-ended. */
|
||||||
|
getMaxLimit(rule: any): string {
|
||||||
|
if (rule.maxMonthlyIncome > 0) {
|
||||||
|
return this.inr(rule.maxMonthlyIncome);
|
||||||
|
}
|
||||||
|
if (rule.maxAnnualIncome > 0) {
|
||||||
|
return this.inr(rule.maxAnnualIncome);
|
||||||
|
}
|
||||||
|
if (rule.maxAnnualTurnover > 0) {
|
||||||
|
return this.inr(rule.maxAnnualTurnover);
|
||||||
|
}
|
||||||
|
if (rule.txnMaxLimitAmount != null) {
|
||||||
|
return this.inr(rule.txnMaxLimitAmount);
|
||||||
|
}
|
||||||
|
// A floor with no configured ceiling means the rule has no upper bound.
|
||||||
|
if (
|
||||||
|
rule.minMonthlyIncome > 0 ||
|
||||||
|
rule.minAnnualIncome > 0 ||
|
||||||
|
rule.minAnnualTurnover > 0 ||
|
||||||
|
rule.txnThresholdAmount != null
|
||||||
|
) {
|
||||||
|
return 'No upper limit';
|
||||||
|
}
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Occupation-driven rules carry no amount; show the criterion instead. */
|
||||||
|
getNonNumericCriterion(rule: any): string {
|
||||||
|
return rule.occupationName || rule.occupationCategoryName || '';
|
||||||
|
}
|
||||||
|
|
||||||
getRiskCategoryLabel(category: number): string {
|
getRiskCategoryLabel(category: number): string {
|
||||||
switch (category) {
|
switch (category) {
|
||||||
case 1:
|
case 1:
|
||||||
@@ -85,39 +246,4 @@ export class AmlRulesComponent implements OnInit {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Formats whichever income/turnover/occupation criterion drives this rule's risk mark. */
|
|
||||||
getIncomeLimitLabel(rule: any): string {
|
|
||||||
const inr = (n: number) => '₹' + Number(n).toLocaleString('en-IN');
|
|
||||||
if (rule.minMonthlyIncome != null && rule.minMonthlyIncome > 0) {
|
|
||||||
return rule.maxMonthlyIncome != null && rule.maxMonthlyIncome > 0
|
|
||||||
? `Monthly Income ${inr(rule.minMonthlyIncome)} - ${inr(rule.maxMonthlyIncome)}`
|
|
||||||
: `Monthly Income > ${inr(rule.minMonthlyIncome)}`;
|
|
||||||
}
|
|
||||||
if (rule.minAnnualIncome != null && rule.minAnnualIncome > 0) {
|
|
||||||
return rule.maxAnnualIncome != null && rule.maxAnnualIncome > 0
|
|
||||||
? `Annual Income ${inr(rule.minAnnualIncome)} - ${inr(rule.maxAnnualIncome)}`
|
|
||||||
: `Annual Income > ${inr(rule.minAnnualIncome)}`;
|
|
||||||
}
|
|
||||||
if (rule.minAnnualTurnover != null && rule.minAnnualTurnover > 0) {
|
|
||||||
return rule.maxAnnualTurnover != null && rule.maxAnnualTurnover > 0
|
|
||||||
? `Annual Turnover ${inr(rule.minAnnualTurnover)} - ${inr(rule.maxAnnualTurnover)}`
|
|
||||||
: `Annual Turnover > ${inr(rule.minAnnualTurnover)}`;
|
|
||||||
}
|
|
||||||
if (rule.occupationName) return `Occupation: ${rule.occupationName}`;
|
|
||||||
if (rule.occupationCategoryName) return `Occupation Category: ${rule.occupationCategoryName}`;
|
|
||||||
if (rule.txnThresholdAmountMediumRisk != null || rule.txnThresholdAmountHighRisk != null) {
|
|
||||||
const parts: string[] = [];
|
|
||||||
if (rule.txnThresholdAmountMediumRisk != null) parts.push(`${inr(rule.txnThresholdAmountMediumRisk)} (Medium Risk)`);
|
|
||||||
if (rule.txnThresholdAmountHighRisk != null) parts.push(`${inr(rule.txnThresholdAmountHighRisk)} (High Risk)`);
|
|
||||||
return `Txn Amount ≥ ${parts.join(' / ')}`;
|
|
||||||
}
|
|
||||||
if (rule.txnThresholdAmount != null) {
|
|
||||||
const amt = inr(rule.txnThresholdAmount);
|
|
||||||
if (rule.txnRuleType === 'THRESHOLD') return `Txn Amount ≥ ${amt}`;
|
|
||||||
if (rule.txnWindowDays != null) return `${amt} within ${rule.txnWindowDays} days`;
|
|
||||||
return `Txn Amount ≥ ${amt}`;
|
|
||||||
}
|
|
||||||
return 'Behavioral / Transaction Pattern';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<!--
|
||||||
|
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="glm-root">
|
||||||
|
<div class="glm-head">
|
||||||
|
<div>
|
||||||
|
<h3>Map General Ledger accounts</h3>
|
||||||
|
<p>{{ data.returnCode }} · {{ data.lineLabel }}</p>
|
||||||
|
</div>
|
||||||
|
<button class="glm-btn" (click)="cancel()"><fa-icon icon="times"></fa-icon></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Live comparison between the ledger and the figure on the return -->
|
||||||
|
<div
|
||||||
|
class="glm-compare"
|
||||||
|
[class.glm-compare--match]="selected.length && matchesFiled"
|
||||||
|
[class.glm-compare--diff]="selected.length && !matchesFiled && variance !== null"
|
||||||
|
>
|
||||||
|
<div class="glm-fig">
|
||||||
|
<span class="glm-fig-l">On the return</span>
|
||||||
|
<span class="glm-fig-v">{{ data.filedValue === null ? '—' : (data.filedValue | number: '1.2-2') }}</span>
|
||||||
|
</div>
|
||||||
|
<fa-icon icon="exchange-alt" class="glm-fig-ic"></fa-icon>
|
||||||
|
<div class="glm-fig">
|
||||||
|
<span class="glm-fig-l">From the ledger</span>
|
||||||
|
<span class="glm-fig-v">{{ selected.length ? (derivedValue | number: '1.2-2') : '—' }}</span>
|
||||||
|
</div>
|
||||||
|
@if (selected.length && variance !== null) {
|
||||||
|
<div class="glm-fig glm-fig--var">
|
||||||
|
<span class="glm-fig-l">Variance</span>
|
||||||
|
<span class="glm-fig-v">{{ variance | number: '1.2-2' }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="glm-compare-note">
|
||||||
|
@if (!selected.length) {
|
||||||
|
Select accounts below to derive this line from the ledger.
|
||||||
|
} @else if (matchesFiled) {
|
||||||
|
Ledger agrees with the filed figure.
|
||||||
|
} @else {
|
||||||
|
Ledger differs from the filed figure — the mapped value will be shown on the return.
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Currently pinned accounts -->
|
||||||
|
<div class="glm-selected">
|
||||||
|
<div class="glm-selected-h">
|
||||||
|
<span>Mapped accounts ({{ selected.length }})</span>
|
||||||
|
@if (selected.length) {
|
||||||
|
<button class="glm-link" (click)="clearAll()">Remove all</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (selected.length) {
|
||||||
|
<div class="glm-chips">
|
||||||
|
@for (code of selected; track code) {
|
||||||
|
<span class="glm-chip">
|
||||||
|
<span class="mono">{{ code }}</span> {{ accountName(code) }}
|
||||||
|
<button class="glm-chip-x" (click)="removeSelected(code)" [attr.aria-label]="'Remove ' + code">
|
||||||
|
<fa-icon icon="times"></fa-icon>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<p class="glm-empty">No accounts mapped — this line still uses the figure filed on the return.</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Chart of accounts picker -->
|
||||||
|
<div class="glm-filters">
|
||||||
|
<input class="glm-inp" type="text" placeholder="Search by code, name or group…" [(ngModel)]="search" />
|
||||||
|
<select class="glm-inp glm-inp--sm" [(ngModel)]="typeFilter">
|
||||||
|
<option value="">All types</option>
|
||||||
|
@for (t of types; track t) {
|
||||||
|
<option [value]="t">{{ t }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="glm-list">
|
||||||
|
<table class="glm-tbl">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="ta-c">Map</th>
|
||||||
|
<th>GL Code</th>
|
||||||
|
<th>Account</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Group</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (a of filtered; track a.code) {
|
||||||
|
<tr [class.on]="isSelected(a.code)" (click)="toggle(a.code)">
|
||||||
|
<td class="ta-c">
|
||||||
|
<span class="glm-check" [class.on]="isSelected(a.code)">
|
||||||
|
@if (isSelected(a.code)) {
|
||||||
|
<fa-icon icon="check-circle"></fa-icon>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="mono">{{ a.code }}</td>
|
||||||
|
<td>{{ a.name }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="glm-type" [class]="'t-' + a.type.toLowerCase()">{{ a.type }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="muted">{{ a.group }}</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="glm-empty-row">No accounts match that search.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="glm-actions">
|
||||||
|
<button class="glm-btn" (click)="cancel()">Cancel</button>
|
||||||
|
<button class="glm-btn glm-btn-primary" (click)="save()">Save mapping ({{ selected.length }})</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
/**
|
||||||
|
* 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 {
|
||||||
|
--m-navy: #002f5f;
|
||||||
|
--m-green: #15803d;
|
||||||
|
--m-green-l: #dcfce7;
|
||||||
|
--m-amber: #b45309;
|
||||||
|
--m-amber-l: #fef3c7;
|
||||||
|
--m-red: #c0392b;
|
||||||
|
--m-red-l: #fdecea;
|
||||||
|
--m-g50: #f8fafc;
|
||||||
|
--m-g100: #f1f5f9;
|
||||||
|
--m-g200: #e2e8f0;
|
||||||
|
--m-g300: #cbd5e1;
|
||||||
|
--m-g500: #64748b;
|
||||||
|
--m-g700: #334155;
|
||||||
|
--m-g900: #0f172a;
|
||||||
|
|
||||||
|
display: block;
|
||||||
|
font-family: 'DM Sans', system-ui, sans-serif;
|
||||||
|
color: var(--m-g900);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glm-root {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
max-height: 88vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glm-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 2px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--m-g500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ledger vs filed figure */
|
||||||
|
.glm-compare {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 18px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--m-g100);
|
||||||
|
border: 1px solid var(--m-g200);
|
||||||
|
|
||||||
|
&--match {
|
||||||
|
background: var(--m-green-l);
|
||||||
|
border-color: #86efac;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--diff {
|
||||||
|
background: var(--m-amber-l);
|
||||||
|
border-color: #fcd34d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.glm-fig {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
&--var .glm-fig-v {
|
||||||
|
color: var(--m-amber);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.glm-fig-l {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--m-g500);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glm-fig-v {
|
||||||
|
font-family: 'DM Mono', monospace;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glm-fig-ic {
|
||||||
|
color: var(--m-g500);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glm-compare-note {
|
||||||
|
flex: 1 1 220px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--m-g700);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pinned accounts */
|
||||||
|
.glm-selected-h {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--m-g500);
|
||||||
|
margin-bottom: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glm-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glm-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 6px 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #e0f2fe;
|
||||||
|
color: #075985;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glm-chip-x {
|
||||||
|
border: none;
|
||||||
|
background: rgb(7 89 133 / 12%);
|
||||||
|
color: #075985;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 10px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--m-red);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.glm-empty {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--m-g500);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Picker */
|
||||||
|
.glm-filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glm-inp {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--m-g200);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
|
||||||
|
&--sm {
|
||||||
|
flex: 0 0 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--m-navy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.glm-list {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 180px;
|
||||||
|
max-height: 40vh;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--m-g200);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glm-tbl {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
|
||||||
|
th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
text-align: left;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--m-g500);
|
||||||
|
background: var(--m-g50);
|
||||||
|
border-bottom: 1px solid var(--m-g200);
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 7px 12px;
|
||||||
|
border-bottom: 1px solid var(--m-g100);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--m-g700);
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover td {
|
||||||
|
background: var(--m-g50);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.on td {
|
||||||
|
background: #eff6ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.glm-check {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 2px solid var(--m-g300);
|
||||||
|
border-radius: 5px;
|
||||||
|
color: transparent;
|
||||||
|
font-size: 11px;
|
||||||
|
|
||||||
|
&.on {
|
||||||
|
background: var(--m-green);
|
||||||
|
border-color: var(--m-green);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.glm-type {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: var(--m-g100);
|
||||||
|
color: var(--m-g700);
|
||||||
|
|
||||||
|
&.t-asset {
|
||||||
|
background: #dbeafe;
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.t-liability {
|
||||||
|
background: #e0f4f0;
|
||||||
|
color: #0e7e6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.t-income {
|
||||||
|
background: var(--m-green-l);
|
||||||
|
color: var(--m-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.t-expense {
|
||||||
|
background: var(--m-amber-l);
|
||||||
|
color: var(--m-amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.t-contingent {
|
||||||
|
background: var(--m-red-l);
|
||||||
|
color: var(--m-red);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.glm-empty-row {
|
||||||
|
text-align: center;
|
||||||
|
padding: 26px;
|
||||||
|
color: var(--m-g500);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family: 'DM Mono', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: var(--m-g500);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ta-c {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Actions */
|
||||||
|
.glm-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glm-btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: 1px solid var(--m-g300);
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--m-g700);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--m-g50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.glm-btn-primary {
|
||||||
|
background: var(--m-navy);
|
||||||
|
border-color: var(--m-navy);
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #013a75;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.glm-link {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--m-red);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, inject } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||||
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
import { GlAccount } from 'app/gl-reports/gl-reports-data';
|
||||||
|
import { GlDateRange, glValueForCodes } from 'app/gl-reports/gl-reports.compute';
|
||||||
|
import { RETURN_SCALE, glAccountsForPicker } from './gl-mapping';
|
||||||
|
|
||||||
|
export interface GlMappingDialogData {
|
||||||
|
returnCode: string;
|
||||||
|
lineLabel: string;
|
||||||
|
/** The figure currently printed on the return, in thousands. */
|
||||||
|
filedValue: number | null;
|
||||||
|
selected: string[];
|
||||||
|
range: GlDateRange;
|
||||||
|
branchCode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lets a user pin one or more GL accounts to a single return line, showing live what
|
||||||
|
* the ledger would produce for the selection against the figure currently on the return.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-gl-mapping-dialog',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
MatDialogModule,
|
||||||
|
FaIconComponent
|
||||||
|
],
|
||||||
|
templateUrl: './gl-mapping-dialog.component.html',
|
||||||
|
styleUrls: ['./gl-mapping-dialog.component.scss']
|
||||||
|
})
|
||||||
|
export class GlMappingDialogComponent {
|
||||||
|
private dialogRef = inject(MatDialogRef<GlMappingDialogComponent>);
|
||||||
|
data = inject<GlMappingDialogData>(MAT_DIALOG_DATA);
|
||||||
|
|
||||||
|
readonly accounts: GlAccount[] = glAccountsForPicker();
|
||||||
|
search = '';
|
||||||
|
typeFilter = '';
|
||||||
|
selected: string[] = [];
|
||||||
|
|
||||||
|
readonly types = [
|
||||||
|
'ASSET',
|
||||||
|
'LIABILITY',
|
||||||
|
'INCOME',
|
||||||
|
'EXPENSE',
|
||||||
|
'CONTINGENT'
|
||||||
|
];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.selected = [...(this.data.selected ?? [])];
|
||||||
|
}
|
||||||
|
|
||||||
|
get filtered(): GlAccount[] {
|
||||||
|
const q = this.search.trim().toLowerCase();
|
||||||
|
return this.accounts.filter((a) => {
|
||||||
|
if (this.typeFilter && a.type !== this.typeFilter) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!q) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return a.code.includes(q) || a.name.toLowerCase().includes(q) || a.group.toLowerCase().includes(q);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
isSelected(code: string): boolean {
|
||||||
|
return this.selected.includes(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle(code: string): void {
|
||||||
|
this.selected = this.isSelected(code) ? this.selected.filter((c) => c !== code) : [
|
||||||
|
...this.selected,
|
||||||
|
code
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
removeSelected(code: string): void {
|
||||||
|
this.selected = this.selected.filter((c) => c !== code);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearAll(): void {
|
||||||
|
this.selected = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
accountName(code: string): string {
|
||||||
|
return this.accounts.find((a) => a.code === code)?.name ?? code;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Live ledger figure for the current selection, in the return's scale. */
|
||||||
|
get derivedValue(): number {
|
||||||
|
return glValueForCodes(this.selected, this.data.range, this.data.branchCode) / RETURN_SCALE;
|
||||||
|
}
|
||||||
|
|
||||||
|
get variance(): number | null {
|
||||||
|
return this.data.filedValue == null ? null : this.derivedValue - this.data.filedValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
get matchesFiled(): boolean {
|
||||||
|
return this.variance !== null && Math.abs(this.variance) < 0.005;
|
||||||
|
}
|
||||||
|
|
||||||
|
save(): void {
|
||||||
|
this.dialogRef.close(this.selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel(): void {
|
||||||
|
this.dialogRef.close(undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
/**
|
||||||
|
* 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 { inject } from '@angular/core';
|
||||||
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
|
import { BranchScopeService } from 'app/core/branch-scope.service';
|
||||||
|
import { GlDateRange } from 'app/gl-reports/gl-reports.compute';
|
||||||
|
import { GL_BRANCHES } from 'app/gl-reports/gl-reports-data';
|
||||||
|
import { GlMappingDialogComponent, GlMappingDialogData } from './gl-mapping-dialog.component';
|
||||||
|
import {
|
||||||
|
clearReturnMappings,
|
||||||
|
getMapping,
|
||||||
|
lineKeyFor,
|
||||||
|
mappedLineCount,
|
||||||
|
mappedValue,
|
||||||
|
removeGl,
|
||||||
|
setMapping
|
||||||
|
} from './gl-mapping';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared GL-mapping behaviour for the OSS return screens.
|
||||||
|
*
|
||||||
|
* Each return line can be pinned to GL accounts; where it is, the figure shown comes
|
||||||
|
* from the ledger rather than the static filed value. Subclasses only supply their
|
||||||
|
* return code — everything else, including the ledger period and branch scope, is
|
||||||
|
* common.
|
||||||
|
*/
|
||||||
|
export abstract class GlMappingHost {
|
||||||
|
private dialog = inject(MatDialog);
|
||||||
|
private branchScope = inject(BranchScopeService);
|
||||||
|
|
||||||
|
/** e.g. 'R070'. Used to namespace this return's mappings. */
|
||||||
|
abstract readonly glReturnCode: string;
|
||||||
|
|
||||||
|
/** Ledger period the mapped figures are drawn from. */
|
||||||
|
abstract get glRange(): GlDateRange;
|
||||||
|
|
||||||
|
/** Whether the per-line mapping controls are visible. */
|
||||||
|
glMapMode = false;
|
||||||
|
|
||||||
|
/** '' = all branches consolidated. Seeded from the login's own scope. */
|
||||||
|
glBranchCode = this.branchScope.defaultBranchCode;
|
||||||
|
readonly glBranches = GL_BRANCHES;
|
||||||
|
|
||||||
|
/** Head Office may derive figures from any branch; a branch login cannot. */
|
||||||
|
get canSelectGlBranch(): boolean {
|
||||||
|
return this.branchScope.canSelectBranch;
|
||||||
|
}
|
||||||
|
get glScopeName(): string {
|
||||||
|
return this.branchScope.scopeLabel(this.glBranchCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Branch actually used for every ledger lookup, enforced rather than trusted. */
|
||||||
|
private get effectiveGlBranch(): string {
|
||||||
|
return this.branchScope.enforce(this.glBranchCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleGlMapMode(): void {
|
||||||
|
this.glMapMode = !this.glMapMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
glLineKey(label: string): string {
|
||||||
|
return lineKeyFor(label);
|
||||||
|
}
|
||||||
|
|
||||||
|
glCodesFor(label: string): string[] {
|
||||||
|
return getMapping(this.glReturnCode, this.glLineKey(label));
|
||||||
|
}
|
||||||
|
|
||||||
|
isGlMapped(label: string): boolean {
|
||||||
|
return this.glCodesFor(label).length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
get glMappedCount(): number {
|
||||||
|
return mappedLineCount(this.glReturnCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ledger figure for a line, or null when it has no mapping. */
|
||||||
|
glValue(label: string): number | null {
|
||||||
|
return mappedValue(this.glReturnCode, this.glLineKey(label), this.glRange, this.effectiveGlBranch);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The number to print for a line: the ledger value when mapped, otherwise the
|
||||||
|
* value filed on the return.
|
||||||
|
*/
|
||||||
|
effectiveValue(label: string, filed: number | null): number | null {
|
||||||
|
const mapped = this.glValue(label);
|
||||||
|
return mapped === null ? filed : mapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when a mapped line disagrees with the figure originally filed. */
|
||||||
|
glDiffers(label: string, filed: number | null): boolean {
|
||||||
|
const mapped = this.glValue(label);
|
||||||
|
if (mapped === null || filed === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return Math.abs(mapped - filed) >= 0.005;
|
||||||
|
}
|
||||||
|
|
||||||
|
removeGlFromLine(label: string, code: string): void {
|
||||||
|
removeGl(this.glReturnCode, this.glLineKey(label), code);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearAllGlMappings(): void {
|
||||||
|
clearReturnMappings(this.glReturnCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opens the chart-of-accounts picker for one return line. */
|
||||||
|
openGlPicker(label: string, filed: number | null): void {
|
||||||
|
const key = this.glLineKey(label);
|
||||||
|
const data: GlMappingDialogData = {
|
||||||
|
returnCode: this.glReturnCode,
|
||||||
|
lineLabel: label,
|
||||||
|
filedValue: filed,
|
||||||
|
selected: getMapping(this.glReturnCode, key),
|
||||||
|
range: this.glRange,
|
||||||
|
branchCode: this.effectiveGlBranch
|
||||||
|
};
|
||||||
|
this.dialog
|
||||||
|
.open(GlMappingDialogComponent, {
|
||||||
|
width: '92vw',
|
||||||
|
maxWidth: '980px',
|
||||||
|
maxHeight: '92vh',
|
||||||
|
autoFocus: false,
|
||||||
|
data
|
||||||
|
})
|
||||||
|
.afterClosed()
|
||||||
|
.subscribe((codes: string[] | undefined) => {
|
||||||
|
if (codes) {
|
||||||
|
setMapping(this.glReturnCode, key, codes);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps RBI return line items to General Ledger accounts.
|
||||||
|
*
|
||||||
|
* A return line can be pinned to one or more GL codes; the line's figure is then
|
||||||
|
* derived from the ledger instead of being a static number, so the filed return and
|
||||||
|
* the books cannot drift apart silently. Mappings are keyed by return code plus a
|
||||||
|
* stable line key and persisted locally.
|
||||||
|
*
|
||||||
|
* The OSS returns are denominated in thousands while the ledger is in rupees, so
|
||||||
|
* every derived figure is scaled on the way out.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { GL_ACCOUNTS, GlAccount } from 'app/gl-reports/gl-reports-data';
|
||||||
|
import { GlDateRange, glValueForCodes } from 'app/gl-reports/gl-reports.compute';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'rbi_return_gl_mappings';
|
||||||
|
|
||||||
|
/** Returns are reported in thousands of rupees. */
|
||||||
|
export const RETURN_SCALE = 1000;
|
||||||
|
|
||||||
|
export type GlMappingStore = Record<string, string[]>;
|
||||||
|
|
||||||
|
function mapKey(returnCode: string, lineKey: string): string {
|
||||||
|
return `${returnCode}::${lineKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The store is read from template getters on every change-detection pass, so it is
|
||||||
|
* held in memory and only re-parsed when a write invalidates it. Parsing JSON a few
|
||||||
|
* hundred times per click is otherwise a measurable cost on the larger returns.
|
||||||
|
*/
|
||||||
|
let storeCache: GlMappingStore | null = null;
|
||||||
|
|
||||||
|
function readStore(): GlMappingStore {
|
||||||
|
if (storeCache) {
|
||||||
|
return storeCache;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
storeCache = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}') as GlMappingStore;
|
||||||
|
} catch {
|
||||||
|
storeCache = {};
|
||||||
|
}
|
||||||
|
return storeCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeStore(store: GlMappingStore): void {
|
||||||
|
storeCache = store;
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(store));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GL codes currently pinned to a return line. */
|
||||||
|
export function getMapping(returnCode: string, lineKey: string): string[] {
|
||||||
|
return readStore()[mapKey(returnCode, lineKey)] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setMapping(returnCode: string, lineKey: string, codes: string[]): void {
|
||||||
|
const store = readStore();
|
||||||
|
const key = mapKey(returnCode, lineKey);
|
||||||
|
if (codes.length) {
|
||||||
|
store[key] = codes;
|
||||||
|
} else {
|
||||||
|
delete store[key];
|
||||||
|
}
|
||||||
|
writeStore(store);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addGl(returnCode: string, lineKey: string, code: string): void {
|
||||||
|
const current = getMapping(returnCode, lineKey);
|
||||||
|
if (!current.includes(code)) {
|
||||||
|
setMapping(returnCode, lineKey, [
|
||||||
|
...current,
|
||||||
|
code
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeGl(returnCode: string, lineKey: string, code: string): void {
|
||||||
|
setMapping(
|
||||||
|
returnCode,
|
||||||
|
lineKey,
|
||||||
|
getMapping(returnCode, lineKey).filter((c) => c !== code)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every line in a return that currently has a mapping. */
|
||||||
|
export function mappedLineCount(returnCode: string): number {
|
||||||
|
const prefix = `${returnCode}::`;
|
||||||
|
return Object.keys(readStore()).filter((k) => k.startsWith(prefix)).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearReturnMappings(returnCode: string): void {
|
||||||
|
const store = readStore();
|
||||||
|
const prefix = `${returnCode}::`;
|
||||||
|
for (const k of Object.keys(store)) {
|
||||||
|
if (k.startsWith(prefix)) {
|
||||||
|
delete store[k];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeStore(store);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ledger value for a mapped line, expressed in the return's own scale (thousands).
|
||||||
|
* Returns null when the line has no mapping, so callers can fall back to the filed figure.
|
||||||
|
*/
|
||||||
|
export function mappedValue(returnCode: string, lineKey: string, range: GlDateRange, branchCode = ''): number | null {
|
||||||
|
const codes = getMapping(returnCode, lineKey);
|
||||||
|
if (!codes.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return glValueForCodes(codes, range, branchCode) / RETURN_SCALE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A line key that stays stable across reorderings — derived from the label itself. */
|
||||||
|
export function lineKeyFor(label: string): string {
|
||||||
|
return label
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '')
|
||||||
|
.slice(0, 80);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GlMappingEntry {
|
||||||
|
returnCode: string;
|
||||||
|
lineKey: string;
|
||||||
|
codes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every mapping across all returns — drives the GL Mapping for Report listing. */
|
||||||
|
export function allMappings(): GlMappingEntry[] {
|
||||||
|
return Object.entries(readStore()).map(
|
||||||
|
([
|
||||||
|
key,
|
||||||
|
codes
|
||||||
|
]) => {
|
||||||
|
const [
|
||||||
|
returnCode,
|
||||||
|
lineKey
|
||||||
|
] = key.split('::');
|
||||||
|
return { returnCode, lineKey, codes };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human-readable name for a return code. */
|
||||||
|
export function returnName(code: string): string {
|
||||||
|
const names: Record<string, string> = {
|
||||||
|
R070: 'OSS 1 — Return on Assets and Liabilities',
|
||||||
|
R071: 'OSS 2 — Return on Earnings',
|
||||||
|
R323: 'OSS 3 — Return on Asset Quality'
|
||||||
|
};
|
||||||
|
return names[code] ?? code;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function glAccountsForPicker(): GlAccount[] {
|
||||||
|
return GL_ACCOUNTS;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function glLabel(code: string): string {
|
||||||
|
const a = GL_ACCOUNTS.find((x) => x.code === code);
|
||||||
|
return a ? `${a.code} — ${a.name}` : code;
|
||||||
|
}
|
||||||
@@ -360,6 +360,158 @@ td.derived {
|
|||||||
color: var(--o-g500);
|
color: var(--o-g500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── General Ledger mapping ─────────────────────────────────────────────── */
|
||||||
|
.oss-gl-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--o-g200);
|
||||||
|
|
||||||
|
&.on {
|
||||||
|
background: var(--o-blue-l);
|
||||||
|
border-color: #93c5fd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.oss-gl-bar-l {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--o-g700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.oss-gl-bar-r {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oss-gl-count {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 8px;
|
||||||
|
padding: 2px 9px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--o-green-l);
|
||||||
|
color: var(--o-green);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oss-gl-inp {
|
||||||
|
padding: 7px 11px;
|
||||||
|
border: 1px solid var(--o-g300);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-family: inherit;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oss-btn-clear {
|
||||||
|
color: var(--o-red);
|
||||||
|
border-color: var(--o-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.oss-gl-col {
|
||||||
|
min-width: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Read-only branch shown to a branch-level login in place of the selector. */
|
||||||
|
.oss-gl-scope {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
border: 1px solid var(--o-g300);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--o-g100);
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--o-g700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.oss-gl-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oss-gl-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 4px 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #e0f2fe;
|
||||||
|
color: #075985;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oss-gl-chip-x {
|
||||||
|
border: none;
|
||||||
|
background: rgb(7 89 133 / 14%);
|
||||||
|
color: #075985;
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 8px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--o-red);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.oss-gl-add {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 3px 9px;
|
||||||
|
border: 1px dashed var(--o-g300);
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--o-g500);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--o-navy);
|
||||||
|
color: var(--o-navy);
|
||||||
|
border-style: solid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.oss-gl-warn {
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--o-amber-d);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A figure sourced from the ledger rather than the filed return. */
|
||||||
|
td.gl-sourced {
|
||||||
|
background: #ecfdf5;
|
||||||
|
box-shadow: inset 3px 0 0 var(--o-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
td.gl-diff {
|
||||||
|
background: #fffbeb;
|
||||||
|
box-shadow: inset 3px 0 0 #d97706;
|
||||||
|
}
|
||||||
|
|
||||||
/* Tie-up status pills (cross-return reconciliation checks). */
|
/* Tie-up status pills (cross-return reconciliation checks). */
|
||||||
.report-pill {
|
.report-pill {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|||||||
@@ -7,13 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as ExcelJS from 'exceljs';
|
import * as ExcelJS from 'exceljs';
|
||||||
import {
|
import { MatrixTable, ReturnGeneralInfoRow, ReturnLine, ReturnSignatory, SIGNATORY_FIELDS } from './oss-return.model';
|
||||||
MatrixTable,
|
|
||||||
ReturnGeneralInfoRow,
|
|
||||||
ReturnLine,
|
|
||||||
ReturnSignatory,
|
|
||||||
SIGNATORY_FIELDS
|
|
||||||
} from './oss-return.model';
|
|
||||||
|
|
||||||
/** Palette lifted from the RBI CIMS workbooks so exports look like the originals. */
|
/** Palette lifted from the RBI CIMS workbooks so exports look like the originals. */
|
||||||
export const XL = {
|
export const XL = {
|
||||||
@@ -45,12 +39,7 @@ export function addBanner(sheet: ExcelJS.Worksheet, title: string, lastCol: stri
|
|||||||
sheet.addRow([]);
|
sheet.addRow([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function boxAllCells(
|
export function boxAllCells(sheet: ExcelJS.Worksheet, from: number, to: number, lastColIndex: number): void {
|
||||||
sheet: ExcelJS.Worksheet,
|
|
||||||
from: number,
|
|
||||||
to: number,
|
|
||||||
lastColIndex: number
|
|
||||||
): void {
|
|
||||||
for (let r = from; r <= to; r++) {
|
for (let r = from; r <= to; r++) {
|
||||||
for (let c = 1; c <= lastColIndex; c++) {
|
for (let c = 1; c <= lastColIndex; c++) {
|
||||||
sheet.getRow(r).getCell(c).border = {
|
sheet.getRow(r).getCell(c).border = {
|
||||||
@@ -64,7 +53,10 @@ export function boxAllCells(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function addSectionHeader(sheet: ExcelJS.Worksheet, label: string, unitLabel?: string): void {
|
export function addSectionHeader(sheet: ExcelJS.Worksheet, label: string, unitLabel?: string): void {
|
||||||
const row = sheet.addRow([label, unitLabel ?? '']);
|
const row = sheet.addRow([
|
||||||
|
label,
|
||||||
|
unitLabel ?? ''
|
||||||
|
]);
|
||||||
row.font = { bold: true };
|
row.font = { bold: true };
|
||||||
row.eachCell((cell) => (cell.fill = solidFill(XL.headerGrey)));
|
row.eachCell((cell) => (cell.fill = solidFill(XL.headerGrey)));
|
||||||
row.getCell(2).alignment = { horizontal: 'right' };
|
row.getCell(2).alignment = { horizontal: 'right' };
|
||||||
@@ -73,7 +65,10 @@ export function addSectionHeader(sheet: ExcelJS.Worksheet, label: string, unitLa
|
|||||||
/** Renders a labelled block; indents by level and shades derived totals. */
|
/** Renders a labelled block; indents by level and shades derived totals. */
|
||||||
export function addLineRows(sheet: ExcelJS.Worksheet, lines: ReturnLine[]): void {
|
export function addLineRows(sheet: ExcelJS.Worksheet, lines: ReturnLine[]): void {
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const row = sheet.addRow([line.label, line.value]);
|
const row = sheet.addRow([
|
||||||
|
line.label,
|
||||||
|
line.value
|
||||||
|
]);
|
||||||
const labelCell = row.getCell(1);
|
const labelCell = row.getCell(1);
|
||||||
const valueCell = row.getCell(2);
|
const valueCell = row.getCell(2);
|
||||||
|
|
||||||
@@ -98,7 +93,10 @@ export function addMatrixTable(sheet: ExcelJS.Worksheet, table: MatrixTable): vo
|
|||||||
const colCount = table.columns.length + 1;
|
const colCount = table.columns.length + 1;
|
||||||
const start = sheet.rowCount + 1;
|
const start = sheet.rowCount + 1;
|
||||||
|
|
||||||
const titleRow = sheet.addRow([table.title, ...(table.unit ? [table.unit] : [])]);
|
const titleRow = sheet.addRow([
|
||||||
|
table.title,
|
||||||
|
...(table.unit ? [table.unit] : [])
|
||||||
|
]);
|
||||||
if (!table.unit) {
|
if (!table.unit) {
|
||||||
sheet.mergeCells(titleRow.number, 1, titleRow.number, colCount);
|
sheet.mergeCells(titleRow.number, 1, titleRow.number, colCount);
|
||||||
}
|
}
|
||||||
@@ -115,13 +113,19 @@ export function addMatrixTable(sheet: ExcelJS.Worksheet, table: MatrixTable): vo
|
|||||||
sub.getCell(1).fill = solidFill(XL.headerGrey);
|
sub.getCell(1).fill = solidFill(XL.headerGrey);
|
||||||
}
|
}
|
||||||
|
|
||||||
const head = sheet.addRow(['Particulars', ...table.columns]);
|
const head = sheet.addRow([
|
||||||
|
'Particulars',
|
||||||
|
...table.columns
|
||||||
|
]);
|
||||||
head.font = { bold: true };
|
head.font = { bold: true };
|
||||||
head.alignment = { wrapText: true, horizontal: 'center', vertical: 'middle' };
|
head.alignment = { wrapText: true, horizontal: 'center', vertical: 'middle' };
|
||||||
head.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
|
head.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
|
||||||
|
|
||||||
for (const row of table.rows) {
|
for (const row of table.rows) {
|
||||||
const excelRow = sheet.addRow([row.label, ...row.values]);
|
const excelRow = sheet.addRow([
|
||||||
|
row.label,
|
||||||
|
...row.values
|
||||||
|
]);
|
||||||
const labelCell = excelRow.getCell(1);
|
const labelCell = excelRow.getCell(1);
|
||||||
labelCell.alignment = { wrapText: true, vertical: 'middle', indent: row.level };
|
labelCell.alignment = { wrapText: true, vertical: 'middle', indent: row.level };
|
||||||
if (row.level === 0 || row.header) {
|
if (row.level === 0 || row.header) {
|
||||||
@@ -158,13 +162,15 @@ export function buildGeneralInfoSheet(wb: ExcelJS.Workbook, rows: ReturnGeneralI
|
|||||||
const sheet = wb.addWorksheet('General Information');
|
const sheet = wb.addWorksheet('General Information');
|
||||||
sheet.columns = [
|
sheet.columns = [
|
||||||
{ width: 26 },
|
{ width: 26 },
|
||||||
{ width: 58 }
|
{ width: 58 }];
|
||||||
];
|
|
||||||
addBanner(sheet, 'General Information', 'B');
|
addBanner(sheet, 'General Information', 'B');
|
||||||
|
|
||||||
const first = sheet.rowCount + 1;
|
const first = sheet.rowCount + 1;
|
||||||
for (const item of rows) {
|
for (const item of rows) {
|
||||||
const row = sheet.addRow([item.label, item.value]);
|
const row = sheet.addRow([
|
||||||
|
item.label,
|
||||||
|
item.value
|
||||||
|
]);
|
||||||
row.getCell(1).fill = solidFill(XL.keyCell);
|
row.getCell(1).fill = solidFill(XL.keyCell);
|
||||||
// The workbook tints the audit flag yellow and leaves unfilled cells grey.
|
// The workbook tints the audit flag yellow and leaves unfilled cells grey.
|
||||||
const fill = item.label === 'Audit Flag' ? XL.auditYellow : item.value ? XL.derivedBlue : XL.headerGrey;
|
const fill = item.label === 'Audit Flag' ? XL.auditYellow : item.value ? XL.derivedBlue : XL.headerGrey;
|
||||||
@@ -182,19 +188,24 @@ export function buildSignatorySheet(
|
|||||||
const sheet = wb.addWorksheet(sheetName);
|
const sheet = wb.addWorksheet(sheetName);
|
||||||
sheet.columns = [
|
sheet.columns = [
|
||||||
{ width: 44 },
|
{ width: 44 },
|
||||||
{ width: 24 }
|
{ width: 24 }];
|
||||||
];
|
|
||||||
addBanner(sheet, sheetName, 'B');
|
addBanner(sheet, sheetName, 'B');
|
||||||
|
|
||||||
for (const person of signatories) {
|
for (const person of signatories) {
|
||||||
const start = sheet.rowCount + 1;
|
const start = sheet.rowCount + 1;
|
||||||
const head = sheet.addRow([person.heading, '']);
|
const head = sheet.addRow([
|
||||||
|
person.heading,
|
||||||
|
''
|
||||||
|
]);
|
||||||
head.font = { bold: true };
|
head.font = { bold: true };
|
||||||
head.alignment = { horizontal: 'center' };
|
head.alignment = { horizontal: 'center' };
|
||||||
head.getCell(1).fill = solidFill(XL.keyCell);
|
head.getCell(1).fill = solidFill(XL.keyCell);
|
||||||
|
|
||||||
for (const f of SIGNATORY_FIELDS) {
|
for (const f of SIGNATORY_FIELDS) {
|
||||||
const row = sheet.addRow([person[f.key], f.caption]);
|
const row = sheet.addRow([
|
||||||
|
person[f.key],
|
||||||
|
f.caption
|
||||||
|
]);
|
||||||
row.getCell(2).font = { bold: true };
|
row.getCell(2).font = { bold: true };
|
||||||
row.getCell(2).alignment = { horizontal: 'center' };
|
row.getCell(2).alignment = { horizontal: 'center' };
|
||||||
row.getCell(2).fill = solidFill(XL.keyCell);
|
row.getCell(2).fill = solidFill(XL.keyCell);
|
||||||
|
|||||||
@@ -153,8 +153,7 @@ export const RAL_LIABILITIES: RalLine[] = [
|
|||||||
{ label: 'ii. Borrowings', level: 1, value: null },
|
{ label: 'ii. Borrowings', level: 1, value: null },
|
||||||
|
|
||||||
{
|
{
|
||||||
label:
|
label: '12. Risk Provisions Other than netted off assets (BDDR, Spl BDDR, Depreciation on fixed assets etc.,)',
|
||||||
'12. Risk Provisions Other than netted off assets (BDDR, Spl BDDR, Depreciation on fixed assets etc.,)',
|
|
||||||
level: 0,
|
level: 0,
|
||||||
value: 39440,
|
value: 39440,
|
||||||
total: true
|
total: true
|
||||||
@@ -192,7 +191,12 @@ export const RAL_ASSETS: RalLine[] = [
|
|||||||
|
|
||||||
{ label: '2. I. Due from Banks', level: 0, value: 57600, total: true },
|
{ label: '2. I. Due from Banks', level: 0, value: 57600, total: true },
|
||||||
{ label: 'A. Balances with Banks', level: 1, value: 57600, total: true },
|
{ label: 'A. Balances with Banks', level: 1, value: 57600, total: true },
|
||||||
{ label: 'i. State/ Central Cooperative Bank of the State/District concerned in', level: 2, value: 32268, total: true },
|
{
|
||||||
|
label: 'i. State/ Central Cooperative Bank of the State/District concerned in',
|
||||||
|
level: 2,
|
||||||
|
value: 32268,
|
||||||
|
total: true
|
||||||
|
},
|
||||||
{ label: 'a. Current', level: 2, value: 5980 },
|
{ label: 'a. Current', level: 2, value: 5980 },
|
||||||
{ label: 'b. Savings', level: 2, value: null },
|
{ label: 'b. Savings', level: 2, value: null },
|
||||||
{ label: 'c. Fixed', level: 2, value: 26288 },
|
{ label: 'c. Fixed', level: 2, value: 26288 },
|
||||||
@@ -353,26 +357,73 @@ export const RAL_AGEING_HEADERS: string[] = [
|
|||||||
|
|
||||||
/** Sheet 3 — Annexure 1 :: 3. Age-wise Inter Branch Adjustment Account */
|
/** Sheet 3 — Annexure 1 :: 3. Age-wise Inter Branch Adjustment Account */
|
||||||
export const RAL_INTER_BRANCH_AGEING: RalAgeingRow[] = [
|
export const RAL_INTER_BRANCH_AGEING: RalAgeingRow[] = [
|
||||||
{ period: '3 months to 6 months', noOfDebitEntries: null, amtOfDebitEntries: null, noOfCreditEntries: null, amtOfCreditEntries: null },
|
{
|
||||||
{ period: 'Above 6 months to 1 year', noOfDebitEntries: null, amtOfDebitEntries: null, noOfCreditEntries: null, amtOfCreditEntries: null },
|
period: '3 months to 6 months',
|
||||||
{ period: 'Above 1 year to 2 years', noOfDebitEntries: null, amtOfDebitEntries: null, noOfCreditEntries: null, amtOfCreditEntries: null },
|
noOfDebitEntries: null,
|
||||||
{ period: 'Above 2 years', noOfDebitEntries: null, amtOfDebitEntries: null, noOfCreditEntries: null, amtOfCreditEntries: null },
|
amtOfDebitEntries: null,
|
||||||
|
noOfCreditEntries: null,
|
||||||
|
amtOfCreditEntries: null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
period: 'Above 6 months to 1 year',
|
||||||
|
noOfDebitEntries: null,
|
||||||
|
amtOfDebitEntries: null,
|
||||||
|
noOfCreditEntries: null,
|
||||||
|
amtOfCreditEntries: null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
period: 'Above 1 year to 2 years',
|
||||||
|
noOfDebitEntries: null,
|
||||||
|
amtOfDebitEntries: null,
|
||||||
|
noOfCreditEntries: null,
|
||||||
|
amtOfCreditEntries: null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
period: 'Above 2 years',
|
||||||
|
noOfDebitEntries: null,
|
||||||
|
amtOfDebitEntries: null,
|
||||||
|
noOfCreditEntries: null,
|
||||||
|
amtOfCreditEntries: null
|
||||||
|
},
|
||||||
{ period: 'Total', noOfDebitEntries: 0, amtOfDebitEntries: 0, noOfCreditEntries: 0, amtOfCreditEntries: 0 }
|
{ period: 'Total', noOfDebitEntries: 0, amtOfDebitEntries: 0, noOfCreditEntries: 0, amtOfCreditEntries: 0 }
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Sheet 3 — Annexure 1 :: 4. Age-wise Inter Bank Reconciliation Account */
|
/** Sheet 3 — Annexure 1 :: 4. Age-wise Inter Bank Reconciliation Account */
|
||||||
export const RAL_INTER_BANK_AGEING: RalAgeingRow[] = [
|
export const RAL_INTER_BANK_AGEING: RalAgeingRow[] = [
|
||||||
{ period: '3 months to 6 months', noOfDebitEntries: null, amtOfDebitEntries: null, noOfCreditEntries: null, amtOfCreditEntries: null },
|
{
|
||||||
{ period: 'Above 6 months to 1 year', noOfDebitEntries: null, amtOfDebitEntries: null, noOfCreditEntries: null, amtOfCreditEntries: null },
|
period: '3 months to 6 months',
|
||||||
{ period: 'Above 1 year to 2 years', noOfDebitEntries: null, amtOfDebitEntries: null, noOfCreditEntries: null, amtOfCreditEntries: null },
|
noOfDebitEntries: null,
|
||||||
{ period: 'Above 2 years', noOfDebitEntries: null, amtOfDebitEntries: null, noOfCreditEntries: null, amtOfCreditEntries: null },
|
amtOfDebitEntries: null,
|
||||||
|
noOfCreditEntries: null,
|
||||||
|
amtOfCreditEntries: null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
period: 'Above 6 months to 1 year',
|
||||||
|
noOfDebitEntries: null,
|
||||||
|
amtOfDebitEntries: null,
|
||||||
|
noOfCreditEntries: null,
|
||||||
|
amtOfCreditEntries: null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
period: 'Above 1 year to 2 years',
|
||||||
|
noOfDebitEntries: null,
|
||||||
|
amtOfDebitEntries: null,
|
||||||
|
noOfCreditEntries: null,
|
||||||
|
amtOfCreditEntries: null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
period: 'Above 2 years',
|
||||||
|
noOfDebitEntries: null,
|
||||||
|
amtOfDebitEntries: null,
|
||||||
|
noOfCreditEntries: null,
|
||||||
|
amtOfCreditEntries: null
|
||||||
|
},
|
||||||
{ period: 'Total', noOfDebitEntries: 0, amtOfDebitEntries: 0, noOfCreditEntries: 0, amtOfCreditEntries: 0 }
|
{ period: 'Total', noOfDebitEntries: 0, amtOfDebitEntries: 0, noOfCreditEntries: 0, amtOfCreditEntries: 0 }
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Sheet 3 — Annexure 1 :: 5. Unsecured guarantees issued by the bank */
|
/** Sheet 3 — Annexure 1 :: 5. Unsecured guarantees issued by the bank */
|
||||||
export const RAL_UNSECURED_GUARANTEES: RalUnsecuredGuarantee[] = [
|
export const RAL_UNSECURED_GUARANTEES: RalUnsecuredGuarantee[] = [
|
||||||
{ srNo: 1, borrower: '', beneficiary: '', amountOutstanding: null }
|
{ srNo: 1, borrower: '', beneficiary: '', amountOutstanding: null }];
|
||||||
];
|
|
||||||
export const RAL_UNSECURED_GUARANTEES_TOTAL = 0;
|
export const RAL_UNSECURED_GUARANTEES_TOTAL = 0;
|
||||||
|
|
||||||
/** Sheet 4 — Annexure 2 :: Sundry Debtors ageing buckets */
|
/** Sheet 4 — Annexure 2 :: Sundry Debtors ageing buckets */
|
||||||
@@ -401,20 +452,49 @@ export const RAL_SUNDRY_DEBTORS_NOTE =
|
|||||||
export const RAL_DEPOSITORS_BORROWERS: RalLine[] = [
|
export const RAL_DEPOSITORS_BORROWERS: RalLine[] = [
|
||||||
{ label: '1.Total No of Depositors', level: 0, value: 8156, total: true, count: true },
|
{ label: '1.Total No of Depositors', level: 0, value: 8156, total: true, count: true },
|
||||||
{ label: 'Total Customer Deposits', level: 0, value: 634309, total: true },
|
{ label: 'Total Customer Deposits', level: 0, value: 634309, total: true },
|
||||||
{ label: 'a. i) Of which Total No. of Depositors with Deposits less than Rs. 1 Lakh', level: 1, value: 6568, count: true },
|
{
|
||||||
|
label: 'a. i) Of which Total No. of Depositors with Deposits less than Rs. 1 Lakh',
|
||||||
|
level: 1,
|
||||||
|
value: 6568,
|
||||||
|
count: true
|
||||||
|
},
|
||||||
{ label: 'ii) Of which total Deposit Amount', level: 1, value: 109992 },
|
{ label: 'ii) Of which total Deposit Amount', level: 1, value: 109992 },
|
||||||
{ label: 'b. i) Of which Total No. of Depositors with Deposits Between Rs 1 Lakh to Rs. 2 Lakh', level: 1, value: 739, count: true },
|
{
|
||||||
|
label: 'b. i) Of which Total No. of Depositors with Deposits Between Rs 1 Lakh to Rs. 2 Lakh',
|
||||||
|
level: 1,
|
||||||
|
value: 739,
|
||||||
|
count: true
|
||||||
|
},
|
||||||
{ label: 'ii) Of which total Deposit Amount', level: 1, value: 102475 },
|
{ label: 'ii) Of which total Deposit Amount', level: 1, value: 102475 },
|
||||||
{ label: 'c. i) Of which Total No. of Depositors with Deposits Between Rs. 2 Lakh to Rs 5 Lakh', level: 1, value: 580, count: true },
|
{
|
||||||
|
label: 'c. i) Of which Total No. of Depositors with Deposits Between Rs. 2 Lakh to Rs 5 Lakh',
|
||||||
|
level: 1,
|
||||||
|
value: 580,
|
||||||
|
count: true
|
||||||
|
},
|
||||||
{ label: 'ii) Of which total Deposit Amount', level: 1, value: 176820 },
|
{ label: 'ii) Of which total Deposit Amount', level: 1, value: 176820 },
|
||||||
|
|
||||||
{ label: '2.Total No of Borrowers', level: 0, value: 363, total: true, count: true },
|
{ label: '2.Total No of Borrowers', level: 0, value: 363, total: true, count: true },
|
||||||
{ label: 'Total Loans and Advances (Other than Banks)', level: 0, value: 391288, total: true },
|
{ label: 'Total Loans and Advances (Other than Banks)', level: 0, value: 391288, total: true },
|
||||||
{ label: 'a. i) Of which Total No. of Borrowers with Loans and Advances less than Rs. 1 Lakh', level: 1, value: 55, count: true },
|
{
|
||||||
|
label: 'a. i) Of which Total No. of Borrowers with Loans and Advances less than Rs. 1 Lakh',
|
||||||
|
level: 1,
|
||||||
|
value: 55,
|
||||||
|
count: true
|
||||||
|
},
|
||||||
{ label: 'ii) Of which total Loans and Advances Amount', level: 1, value: 2061 },
|
{ label: 'ii) Of which total Loans and Advances Amount', level: 1, value: 2061 },
|
||||||
{ label: 'b. i) Of which Total No. of Borrowers with Loans and Advances Between Rs 1 Lakh to Rs. 2 Lakh', level: 1, value: 18, count: true },
|
{
|
||||||
|
label: 'b. i) Of which Total No. of Borrowers with Loans and Advances Between Rs 1 Lakh to Rs. 2 Lakh',
|
||||||
|
level: 1,
|
||||||
|
value: 18,
|
||||||
|
count: true
|
||||||
|
},
|
||||||
{ label: 'ii) Of which total Loans and Advances Amount', level: 1, value: 2837 },
|
{ label: 'ii) Of which total Loans and Advances Amount', level: 1, value: 2837 },
|
||||||
{ label: 'c. i) Of which Total No. of Borrowers with Loans and Advances Between Rs. 2 Lakh to Rs 5 Lakh', level: 1, value: 55, count: true },
|
{
|
||||||
|
label: 'c. i) Of which Total No. of Borrowers with Loans and Advances Between Rs. 2 Lakh to Rs 5 Lakh',
|
||||||
|
level: 1,
|
||||||
|
value: 55,
|
||||||
|
count: true
|
||||||
|
},
|
||||||
{ label: 'ii) Of which total Loans and Advances Amount', level: 1, value: 21260 }
|
{ label: 'ii) Of which total Loans and Advances Amount', level: 1, value: 21260 }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -81,11 +81,16 @@
|
|||||||
<div class="oss-section">
|
<div class="oss-section">
|
||||||
<div class="oss-sheet-banner">Assets & Liabilities</div>
|
<div class="oss-sheet-banner">Assets & Liabilities</div>
|
||||||
|
|
||||||
|
<ng-container [ngTemplateOutlet]="glBar"></ng-container>
|
||||||
|
|
||||||
<div class="oss-tbl-wrap">
|
<div class="oss-tbl-wrap">
|
||||||
<table class="oss-tbl">
|
<table class="oss-tbl">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Liabilities</th>
|
<th>Liabilities</th>
|
||||||
|
@if (glMapMode) {
|
||||||
|
<th class="oss-gl-col">Mapped GL accounts</th>
|
||||||
|
}
|
||||||
<th class="ta-r">Rs in Thousands</th>
|
<th class="ta-r">Rs in Thousands</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -93,8 +98,25 @@
|
|||||||
@for (line of liabilities; track $index) {
|
@for (line of liabilities; track $index) {
|
||||||
<tr [class.total-row]="line.total">
|
<tr [class.total-row]="line.total">
|
||||||
<td [class]="'lvl-' + line.level">{{ line.label }}</td>
|
<td [class]="'lvl-' + line.level">{{ line.label }}</td>
|
||||||
<td class="ta-r mono" [class.derived]="line.total">
|
@if (glMapMode) {
|
||||||
{{ line.value === null ? '' : (line.value | number: line.count ? '1.0-0' : '1.2-2') }}
|
<td>
|
||||||
|
<ng-container
|
||||||
|
[ngTemplateOutlet]="glCell"
|
||||||
|
[ngTemplateOutletContext]="{ label: line.label, filed: line.value }"
|
||||||
|
></ng-container>
|
||||||
|
</td>
|
||||||
|
}
|
||||||
|
<td
|
||||||
|
class="ta-r mono"
|
||||||
|
[class.derived]="line.total"
|
||||||
|
[class.gl-sourced]="isGlMapped(line.label)"
|
||||||
|
[class.gl-diff]="glDiffers(line.label, line.value)"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
effectiveValue(line.label, line.value) === null
|
||||||
|
? ''
|
||||||
|
: (effectiveValue(line.label, line.value) | number: (line.count ? '1.0-0' : '1.2-2'))
|
||||||
|
}}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
@@ -107,6 +129,9 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Assets</th>
|
<th>Assets</th>
|
||||||
|
@if (glMapMode) {
|
||||||
|
<th class="oss-gl-col">Mapped GL accounts</th>
|
||||||
|
}
|
||||||
<th class="ta-r">Rs in Thousands</th>
|
<th class="ta-r">Rs in Thousands</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -114,8 +139,25 @@
|
|||||||
@for (line of assets; track $index) {
|
@for (line of assets; track $index) {
|
||||||
<tr [class.total-row]="line.total">
|
<tr [class.total-row]="line.total">
|
||||||
<td [class]="'lvl-' + line.level">{{ line.label }}</td>
|
<td [class]="'lvl-' + line.level">{{ line.label }}</td>
|
||||||
<td class="ta-r mono" [class.derived]="line.total">
|
@if (glMapMode) {
|
||||||
{{ line.value === null ? '' : (line.value | number: line.count ? '1.0-0' : '1.2-2') }}
|
<td>
|
||||||
|
<ng-container
|
||||||
|
[ngTemplateOutlet]="glCell"
|
||||||
|
[ngTemplateOutletContext]="{ label: line.label, filed: line.value }"
|
||||||
|
></ng-container>
|
||||||
|
</td>
|
||||||
|
}
|
||||||
|
<td
|
||||||
|
class="ta-r mono"
|
||||||
|
[class.derived]="line.total"
|
||||||
|
[class.gl-sourced]="isGlMapped(line.label)"
|
||||||
|
[class.gl-diff]="glDiffers(line.label, line.value)"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
effectiveValue(line.label, line.value) === null
|
||||||
|
? ''
|
||||||
|
: (effectiveValue(line.label, line.value) | number: (line.count ? '1.0-0' : '1.2-2'))
|
||||||
|
}}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
@@ -343,7 +385,7 @@
|
|||||||
<tr [class.total-row]="line.total">
|
<tr [class.total-row]="line.total">
|
||||||
<td [class]="'lvl-' + line.level">{{ line.label }}</td>
|
<td [class]="'lvl-' + line.level">{{ line.label }}</td>
|
||||||
<td class="ta-r mono" [class.derived]="line.total">
|
<td class="ta-r mono" [class.derived]="line.total">
|
||||||
{{ line.value === null ? '' : (line.value | number: line.count ? '1.0-0' : '1.2-2') }}
|
{{ line.value === null ? '' : (line.value | number: (line.count ? '1.0-0' : '1.2-2')) }}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
@@ -417,6 +459,68 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<!-- GL mapping toolbar, shown above any mappable statement -->
|
||||||
|
<ng-template #glBar>
|
||||||
|
<div class="oss-gl-bar" [class.on]="glMapMode">
|
||||||
|
<div class="oss-gl-bar-l">
|
||||||
|
<fa-icon icon="project-diagram"></fa-icon>
|
||||||
|
<span>
|
||||||
|
<strong>General Ledger mapping</strong>
|
||||||
|
@if (glMappedCount) {
|
||||||
|
<span class="oss-gl-count">{{ glMappedCount }} line(s) mapped</span>
|
||||||
|
} @else {
|
||||||
|
<span class="muted"> — pin GL accounts to a line and its figure comes from the ledger</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="oss-gl-bar-r">
|
||||||
|
@if (glMapMode) {
|
||||||
|
@if (canSelectGlBranch) {
|
||||||
|
<select class="oss-gl-inp" [(ngModel)]="glBranchCode">
|
||||||
|
<option value="">All branches</option>
|
||||||
|
@for (b of glBranches; track b.code) {
|
||||||
|
<option [value]="b.code">{{ b.name }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
} @else {
|
||||||
|
<span class="oss-gl-scope" title="Your login is restricted to this branch">
|
||||||
|
<fa-icon icon="university"></fa-icon> {{ glScopeName }}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
@if (glMappedCount) {
|
||||||
|
<button class="oss-btn oss-btn-clear" (click)="clearAllGlMappings()">Clear all</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
<button class="oss-btn" [class.oss-btn-xls]="!glMapMode" (click)="toggleGlMapMode()">
|
||||||
|
<fa-icon [icon]="glMapMode ? 'times' : 'plus'"></fa-icon>
|
||||||
|
{{ glMapMode ? 'Done' : 'Add / Remove GLs' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
|
<!-- Per-line GL chips and picker trigger -->
|
||||||
|
<ng-template #glCell let-label="label" let-filed="filed">
|
||||||
|
<div class="oss-gl-cell">
|
||||||
|
@for (code of glCodesFor(label); track code) {
|
||||||
|
<span class="oss-gl-chip">
|
||||||
|
<span class="mono">{{ code }}</span>
|
||||||
|
<button class="oss-gl-chip-x" (click)="removeGlFromLine(label, code)" [attr.aria-label]="'Remove ' + code">
|
||||||
|
<fa-icon icon="times"></fa-icon>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
<button class="oss-gl-add" (click)="openGlPicker(label, filed)">
|
||||||
|
<fa-icon icon="plus"></fa-icon> {{ glCodesFor(label).length ? 'Edit' : 'Add GL' }}
|
||||||
|
</button>
|
||||||
|
@if (glDiffers(label, filed)) {
|
||||||
|
<span class="oss-gl-warn" title="Ledger figure differs from the value filed on the return">
|
||||||
|
<fa-icon icon="exclamation-triangle"></fa-icon> differs from filed
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
<!-- TOAST -->
|
<!-- TOAST -->
|
||||||
<div class="oss-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
|
<div class="oss-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { FormsModule } from '@angular/forms';
|
|||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
import * as ExcelJS from 'exceljs';
|
import * as ExcelJS from 'exceljs';
|
||||||
|
import { GlMappingHost } from '../gl-mapping.host';
|
||||||
import {
|
import {
|
||||||
addBanner,
|
addBanner,
|
||||||
addLineRows,
|
addLineRows,
|
||||||
@@ -55,7 +56,14 @@ type Oss1Tab = 'general' | 'balance-sheet' | 'annexure1' | 'annexure2' | 'signat
|
|||||||
FormsModule
|
FormsModule
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class Oss1RalComponent {
|
export class Oss1RalComponent extends GlMappingHost {
|
||||||
|
readonly glReturnCode = 'R070';
|
||||||
|
|
||||||
|
/** RAL covers the reporting quarter; mapped figures are drawn from the same span. */
|
||||||
|
get glRange() {
|
||||||
|
return { from: '2026-04-01', to: '2026-06-30' };
|
||||||
|
}
|
||||||
|
|
||||||
activeTab: Oss1Tab = 'general';
|
activeTab: Oss1Tab = 'general';
|
||||||
|
|
||||||
generalInfo = RAL_GENERAL_INFO;
|
generalInfo = RAL_GENERAL_INFO;
|
||||||
@@ -146,8 +154,7 @@ export class Oss1RalComponent {
|
|||||||
const sheet = wb.addWorksheet('Assets & Liabilities');
|
const sheet = wb.addWorksheet('Assets & Liabilities');
|
||||||
sheet.columns = [
|
sheet.columns = [
|
||||||
{ width: 78 },
|
{ width: 78 },
|
||||||
{ width: 20 }
|
{ width: 20 }];
|
||||||
];
|
|
||||||
addBanner(sheet, 'Assets & Liabilities', 'B');
|
addBanner(sheet, 'Assets & Liabilities', 'B');
|
||||||
|
|
||||||
const first = sheet.rowCount + 1;
|
const first = sheet.rowCount + 1;
|
||||||
@@ -187,18 +194,25 @@ export class Oss1RalComponent {
|
|||||||
{ width: 16 },
|
{ width: 16 },
|
||||||
{ width: 12 },
|
{ width: 12 },
|
||||||
{ width: 12 },
|
{ width: 12 },
|
||||||
{ width: 12 }
|
{ width: 12 }];
|
||||||
];
|
|
||||||
addBanner(sheet, 'Annexure 1', 'H');
|
addBanner(sheet, 'Annexure 1', 'H');
|
||||||
|
|
||||||
// 1. Break-up of Other Funds and Reserves
|
// 1. Break-up of Other Funds and Reserves
|
||||||
let start = sheet.rowCount + 1;
|
let start = sheet.rowCount + 1;
|
||||||
const fundsTitle = sheet.addRow(['1. Break-up of Other Funds and Reserves', '', 'Rs in Thousands']);
|
const fundsTitle = sheet.addRow([
|
||||||
|
'1. Break-up of Other Funds and Reserves',
|
||||||
|
'',
|
||||||
|
'Rs in Thousands'
|
||||||
|
]);
|
||||||
sheet.mergeCells(`A${fundsTitle.number}:B${fundsTitle.number}`);
|
sheet.mergeCells(`A${fundsTitle.number}:B${fundsTitle.number}`);
|
||||||
fundsTitle.font = { bold: true };
|
fundsTitle.font = { bold: true };
|
||||||
fundsTitle.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
|
fundsTitle.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
|
||||||
for (const line of this.otherFunds) {
|
for (const line of this.otherFunds) {
|
||||||
const row = sheet.addRow([line.label, '', line.value]);
|
const row = sheet.addRow([
|
||||||
|
line.label,
|
||||||
|
'',
|
||||||
|
line.value
|
||||||
|
]);
|
||||||
sheet.mergeCells(`A${row.number}:B${row.number}`);
|
sheet.mergeCells(`A${row.number}:B${row.number}`);
|
||||||
row.getCell(1).alignment = { wrapText: true, vertical: 'middle' };
|
row.getCell(1).alignment = { wrapText: true, vertical: 'middle' };
|
||||||
row.getCell(3).numFmt = '#,##0.00';
|
row.getCell(3).numFmt = '#,##0.00';
|
||||||
@@ -281,10 +295,20 @@ export class Oss1RalComponent {
|
|||||||
gHead.alignment = { wrapText: true, horizontal: 'center', vertical: 'middle' };
|
gHead.alignment = { wrapText: true, horizontal: 'center', vertical: 'middle' };
|
||||||
gHead.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
|
gHead.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
|
||||||
for (const g of this.unsecuredGuarantees) {
|
for (const g of this.unsecuredGuarantees) {
|
||||||
const row = sheet.addRow([g.srNo, g.borrower, g.beneficiary, g.amountOutstanding]);
|
const row = sheet.addRow([
|
||||||
|
g.srNo,
|
||||||
|
g.borrower,
|
||||||
|
g.beneficiary,
|
||||||
|
g.amountOutstanding
|
||||||
|
]);
|
||||||
row.getCell(4).numFmt = '#,##0.00';
|
row.getCell(4).numFmt = '#,##0.00';
|
||||||
}
|
}
|
||||||
const gTotal = sheet.addRow(['Total', '', '', this.unsecuredGuaranteesTotal]);
|
const gTotal = sheet.addRow([
|
||||||
|
'Total',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
this.unsecuredGuaranteesTotal
|
||||||
|
]);
|
||||||
gTotal.font = { bold: true };
|
gTotal.font = { bold: true };
|
||||||
gTotal.getCell(4).numFmt = '#,##0.00';
|
gTotal.getCell(4).numFmt = '#,##0.00';
|
||||||
gTotal.getCell(4).fill = solidFill(XL.derivedBlue);
|
gTotal.getCell(4).fill = solidFill(XL.derivedBlue);
|
||||||
@@ -327,9 +351,7 @@ export class Oss1RalComponent {
|
|||||||
3,
|
3,
|
||||||
4,
|
4,
|
||||||
5
|
5
|
||||||
].forEach(
|
].forEach((c) => (row.getCell(c).fill = solidFill(XL.derivedBlue)));
|
||||||
(c) => (row.getCell(c).fill = solidFill(XL.derivedBlue))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
boxAllCells(sheet, start, sheet.rowCount, 5);
|
boxAllCells(sheet, start, sheet.rowCount, 5);
|
||||||
@@ -341,8 +363,7 @@ export class Oss1RalComponent {
|
|||||||
{ width: 10 },
|
{ width: 10 },
|
||||||
{ width: 62 },
|
{ width: 62 },
|
||||||
{ width: 20 },
|
{ width: 20 },
|
||||||
{ width: 20 }
|
{ width: 20 }];
|
||||||
];
|
|
||||||
addBanner(sheet, 'Annexure 2', 'D');
|
addBanner(sheet, 'Annexure 2', 'D');
|
||||||
|
|
||||||
for (const block of this.sundryDebtors) {
|
for (const block of this.sundryDebtors) {
|
||||||
@@ -357,13 +378,23 @@ export class Oss1RalComponent {
|
|||||||
s.font = { bold: true, size: 10 };
|
s.font = { bold: true, size: 10 };
|
||||||
s.getCell(1).fill = solidFill(XL.headerGrey);
|
s.getCell(1).fill = solidFill(XL.headerGrey);
|
||||||
|
|
||||||
const head = sheet.addRow(['Sr. No', 'Due from Name', 'Amount due', 'Provision made']);
|
const head = sheet.addRow([
|
||||||
|
'Sr. No',
|
||||||
|
'Due from Name',
|
||||||
|
'Amount due',
|
||||||
|
'Provision made'
|
||||||
|
]);
|
||||||
head.font = { bold: true };
|
head.font = { bold: true };
|
||||||
head.alignment = { horizontal: 'center' };
|
head.alignment = { horizontal: 'center' };
|
||||||
head.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
|
head.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
|
||||||
|
|
||||||
for (const r of block.rows) {
|
for (const r of block.rows) {
|
||||||
const row = sheet.addRow([r.srNo, r.dueFromName, r.amountDue, r.provisionMade]);
|
const row = sheet.addRow([
|
||||||
|
r.srNo,
|
||||||
|
r.dueFromName,
|
||||||
|
r.amountDue,
|
||||||
|
r.provisionMade
|
||||||
|
]);
|
||||||
row.getCell(3).numFmt = '#,##0.00';
|
row.getCell(3).numFmt = '#,##0.00';
|
||||||
row.getCell(4).numFmt = '#,##0.00';
|
row.getCell(4).numFmt = '#,##0.00';
|
||||||
}
|
}
|
||||||
@@ -378,14 +409,22 @@ export class Oss1RalComponent {
|
|||||||
|
|
||||||
// Details of Depositors & Borrowers
|
// Details of Depositors & Borrowers
|
||||||
let start = sheet.rowCount + 1;
|
let start = sheet.rowCount + 1;
|
||||||
const dbTitle = sheet.addRow(['Details of Depositors & Borrowers', '', 'Rs. In Thousands']);
|
const dbTitle = sheet.addRow([
|
||||||
|
'Details of Depositors & Borrowers',
|
||||||
|
'',
|
||||||
|
'Rs. In Thousands'
|
||||||
|
]);
|
||||||
sheet.mergeCells(`A${dbTitle.number}:B${dbTitle.number}`);
|
sheet.mergeCells(`A${dbTitle.number}:B${dbTitle.number}`);
|
||||||
dbTitle.font = { bold: true };
|
dbTitle.font = { bold: true };
|
||||||
dbTitle.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
|
dbTitle.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
|
||||||
dbTitle.getCell(3).alignment = { horizontal: 'right' };
|
dbTitle.getCell(3).alignment = { horizontal: 'right' };
|
||||||
|
|
||||||
for (const line of this.depositorsBorrowers) {
|
for (const line of this.depositorsBorrowers) {
|
||||||
const row = sheet.addRow([line.label, '', line.value]);
|
const row = sheet.addRow([
|
||||||
|
line.label,
|
||||||
|
'',
|
||||||
|
line.value
|
||||||
|
]);
|
||||||
sheet.mergeCells(`A${row.number}:B${row.number}`);
|
sheet.mergeCells(`A${row.number}:B${row.number}`);
|
||||||
row.getCell(1).alignment = { wrapText: true, vertical: 'middle', indent: line.level };
|
row.getCell(1).alignment = { wrapText: true, vertical: 'middle', indent: line.level };
|
||||||
row.getCell(3).numFmt = line.count ? '#,##0' : '#,##0.00';
|
row.getCell(3).numFmt = line.count ? '#,##0' : '#,##0.00';
|
||||||
@@ -404,7 +443,11 @@ export class Oss1RalComponent {
|
|||||||
cTitle.font = { bold: true };
|
cTitle.font = { bold: true };
|
||||||
cTitle.getCell(1).fill = solidFill(XL.headerGrey);
|
cTitle.getCell(1).fill = solidFill(XL.headerGrey);
|
||||||
for (const line of this.complaints) {
|
for (const line of this.complaints) {
|
||||||
const row = sheet.addRow([line.label, '', line.value]);
|
const row = sheet.addRow([
|
||||||
|
line.label,
|
||||||
|
'',
|
||||||
|
line.value
|
||||||
|
]);
|
||||||
sheet.mergeCells(`A${row.number}:B${row.number}`);
|
sheet.mergeCells(`A${row.number}:B${row.number}`);
|
||||||
row.getCell(1).alignment = { wrapText: true, vertical: 'middle' };
|
row.getCell(1).alignment = { wrapText: true, vertical: 'middle' };
|
||||||
row.getCell(3).numFmt = '#,##0';
|
row.getCell(3).numFmt = '#,##0';
|
||||||
|
|||||||
@@ -122,11 +122,52 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- GL mapping toolbar -->
|
||||||
|
<div class="oss-gl-bar" [class.on]="glMapMode">
|
||||||
|
<div class="oss-gl-bar-l">
|
||||||
|
<fa-icon icon="project-diagram"></fa-icon>
|
||||||
|
<span>
|
||||||
|
<strong>General Ledger mapping</strong>
|
||||||
|
@if (glMappedCount) {
|
||||||
|
<span class="oss-gl-count">{{ glMappedCount }} line(s) mapped</span>
|
||||||
|
} @else {
|
||||||
|
<span class="muted"> — pin GL accounts to a line and its figure comes from the ledger</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="oss-gl-bar-r">
|
||||||
|
@if (glMapMode) {
|
||||||
|
@if (canSelectGlBranch) {
|
||||||
|
<select class="oss-gl-inp" [(ngModel)]="glBranchCode">
|
||||||
|
<option value="">All branches</option>
|
||||||
|
@for (b of glBranches; track b.code) {
|
||||||
|
<option [value]="b.code">{{ b.name }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
} @else {
|
||||||
|
<span class="oss-gl-scope" title="Your login is restricted to this branch">
|
||||||
|
<fa-icon icon="university"></fa-icon> {{ glScopeName }}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
@if (glMappedCount) {
|
||||||
|
<button class="oss-btn oss-btn-clear" (click)="clearAllGlMappings()">Clear all</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
<button class="oss-btn" [class.oss-btn-xls]="!glMapMode" (click)="toggleGlMapMode()">
|
||||||
|
<fa-icon [icon]="glMapMode ? 'times' : 'plus'"></fa-icon>
|
||||||
|
{{ glMapMode ? 'Done' : 'Add / Remove GLs' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="oss-tbl-wrap">
|
<div class="oss-tbl-wrap">
|
||||||
<table class="oss-tbl">
|
<table class="oss-tbl">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Particulars</th>
|
<th>Particulars</th>
|
||||||
|
@if (glMapMode) {
|
||||||
|
<th class="oss-gl-col">Mapped GL accounts</th>
|
||||||
|
}
|
||||||
<th class="ta-r">Rs in Thousand</th>
|
<th class="ta-r">Rs in Thousand</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -134,8 +175,44 @@
|
|||||||
@for (line of earnings; track $index) {
|
@for (line of earnings; track $index) {
|
||||||
<tr [class.total-row]="line.total">
|
<tr [class.total-row]="line.total">
|
||||||
<td [class]="'lvl-' + line.level">{{ line.label }}</td>
|
<td [class]="'lvl-' + line.level">{{ line.label }}</td>
|
||||||
<td class="ta-r mono" [class.derived]="line.total">
|
@if (glMapMode) {
|
||||||
{{ line.value === null ? '' : (line.value | number: line.count ? '1.0-0' : '1.2-2') }}
|
<td>
|
||||||
|
<div class="oss-gl-cell">
|
||||||
|
@for (code of glCodesFor(line.label); track code) {
|
||||||
|
<span class="oss-gl-chip">
|
||||||
|
<span class="mono">{{ code }}</span>
|
||||||
|
<button
|
||||||
|
class="oss-gl-chip-x"
|
||||||
|
(click)="removeGlFromLine(line.label, code)"
|
||||||
|
[attr.aria-label]="'Remove ' + code"
|
||||||
|
>
|
||||||
|
<fa-icon icon="times"></fa-icon>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
<button class="oss-gl-add" (click)="openGlPicker(line.label, line.value)">
|
||||||
|
<fa-icon icon="plus"></fa-icon>
|
||||||
|
{{ glCodesFor(line.label).length ? 'Edit' : 'Add GL' }}
|
||||||
|
</button>
|
||||||
|
@if (glDiffers(line.label, line.value)) {
|
||||||
|
<span class="oss-gl-warn" title="Ledger figure differs from the value filed on the return">
|
||||||
|
<fa-icon icon="exclamation-triangle"></fa-icon> differs from filed
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
}
|
||||||
|
<td
|
||||||
|
class="ta-r mono"
|
||||||
|
[class.derived]="line.total"
|
||||||
|
[class.gl-sourced]="isGlMapped(line.label)"
|
||||||
|
[class.gl-diff]="glDiffers(line.label, line.value)"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
effectiveValue(line.label, line.value) === null
|
||||||
|
? ''
|
||||||
|
: (effectiveValue(line.label, line.value) | number: (line.count ? '1.0-0' : '1.2-2'))
|
||||||
|
}}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
@@ -186,9 +263,10 @@
|
|||||||
<div class="oss-balance" [class.oss-balance--bad]="!otherExpensesTally">
|
<div class="oss-balance" [class.oss-balance--bad]="!otherExpensesTally">
|
||||||
<fa-icon [icon]="otherExpensesTally ? 'check-circle' : 'exclamation-triangle'"></fa-icon>
|
<fa-icon [icon]="otherExpensesTally ? 'check-circle' : 'exclamation-triangle'"></fa-icon>
|
||||||
<span>
|
<span>
|
||||||
Detail total <strong>₹{{ otherExpensesTotal | number: '1.2-2' }}</strong> vs Earnings line 12 ix)
|
Detail total <strong>₹{{ otherExpensesTotal | number: '1.2-2' }}</strong> vs Earnings line 12 ix) Others
|
||||||
Others
|
<strong
|
||||||
<strong>₹{{ otherExpensesLineValue === null ? '—' : (otherExpensesLineValue | number: '1.2-2') }}</strong>
|
>₹{{ otherExpensesLineValue === null ? '—' : (otherExpensesLineValue | number: '1.2-2') }}</strong
|
||||||
|
>
|
||||||
— {{ otherExpensesTally ? 'tallies' : 'MISMATCH — resolve before filing' }}
|
— {{ otherExpensesTally ? 'tallies' : 'MISMATCH — resolve before filing' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { FormsModule } from '@angular/forms';
|
|||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
import * as ExcelJS from 'exceljs';
|
import * as ExcelJS from 'exceljs';
|
||||||
|
import { GlMappingHost } from '../gl-mapping.host';
|
||||||
import {
|
import {
|
||||||
addBanner,
|
addBanner,
|
||||||
addLineRows,
|
addLineRows,
|
||||||
@@ -54,7 +55,14 @@ interface TieUp {
|
|||||||
FormsModule
|
FormsModule
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class Oss2RoeComponent {
|
export class Oss2RoeComponent extends GlMappingHost {
|
||||||
|
readonly glReturnCode = 'R071';
|
||||||
|
|
||||||
|
/** RoE reports the quarter; mapped income and expense figures use the same span. */
|
||||||
|
get glRange() {
|
||||||
|
return { from: '2026-04-01', to: '2026-06-30' };
|
||||||
|
}
|
||||||
|
|
||||||
activeTab: Oss2Tab = 'general';
|
activeTab: Oss2Tab = 'general';
|
||||||
|
|
||||||
generalInfo = ROE_GENERAL_INFO;
|
generalInfo = ROE_GENERAL_INFO;
|
||||||
@@ -178,8 +186,7 @@ export class Oss2RoeComponent {
|
|||||||
const sheet = wb.addWorksheet('Earnings');
|
const sheet = wb.addWorksheet('Earnings');
|
||||||
sheet.columns = [
|
sheet.columns = [
|
||||||
{ width: 90 },
|
{ width: 90 },
|
||||||
{ width: 20 }
|
{ width: 20 }];
|
||||||
];
|
|
||||||
addBanner(sheet, 'Earnings', 'B');
|
addBanner(sheet, 'Earnings', 'B');
|
||||||
|
|
||||||
const first = sheet.rowCount + 1;
|
const first = sheet.rowCount + 1;
|
||||||
@@ -205,23 +212,31 @@ export class Oss2RoeComponent {
|
|||||||
const sheet = wb.addWorksheet('Interest Receivable Not Recog');
|
const sheet = wb.addWorksheet('Interest Receivable Not Recog');
|
||||||
sheet.columns = [
|
sheet.columns = [
|
||||||
{ width: 72 },
|
{ width: 72 },
|
||||||
{ width: 22 }
|
{ width: 22 }];
|
||||||
];
|
|
||||||
addBanner(sheet, 'Interest Receivable Not Recognized', 'B');
|
addBanner(sheet, 'Interest Receivable Not Recognized', 'B');
|
||||||
|
|
||||||
const start = sheet.rowCount + 1;
|
const start = sheet.rowCount + 1;
|
||||||
const unit = sheet.addRow(['', 'Rs in Thousand']);
|
const unit = sheet.addRow([
|
||||||
|
'',
|
||||||
|
'Rs in Thousand'
|
||||||
|
]);
|
||||||
unit.font = { bold: true };
|
unit.font = { bold: true };
|
||||||
unit.alignment = { horizontal: 'center' };
|
unit.alignment = { horizontal: 'center' };
|
||||||
unit.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
|
unit.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
|
||||||
|
|
||||||
const head = sheet.addRow(['Interest receivable on NPAs not recognized as income', this.quarterEndDate]);
|
const head = sheet.addRow([
|
||||||
|
'Interest receivable on NPAs not recognized as income',
|
||||||
|
this.quarterEndDate
|
||||||
|
]);
|
||||||
head.font = { bold: true };
|
head.font = { bold: true };
|
||||||
head.getCell(2).alignment = { horizontal: 'center' };
|
head.getCell(2).alignment = { horizontal: 'center' };
|
||||||
head.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
|
head.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
|
||||||
|
|
||||||
for (const r of this.interestNotRecognized) {
|
for (const r of this.interestNotRecognized) {
|
||||||
const row = sheet.addRow([r.label, r.value]);
|
const row = sheet.addRow([
|
||||||
|
r.label,
|
||||||
|
r.value
|
||||||
|
]);
|
||||||
row.getCell(1).alignment = { wrapText: true, vertical: 'middle' };
|
row.getCell(1).alignment = { wrapText: true, vertical: 'middle' };
|
||||||
row.getCell(1).fill = solidFill(XL.headerGrey);
|
row.getCell(1).fill = solidFill(XL.headerGrey);
|
||||||
row.getCell(2).numFmt = '#,##0.00';
|
row.getCell(2).numFmt = '#,##0.00';
|
||||||
@@ -238,28 +253,43 @@ export class Oss2RoeComponent {
|
|||||||
sheet.columns = [
|
sheet.columns = [
|
||||||
{ width: 10 },
|
{ width: 10 },
|
||||||
{ width: 56 },
|
{ width: 56 },
|
||||||
{ width: 20 }
|
{ width: 20 }];
|
||||||
];
|
|
||||||
addBanner(sheet, 'Other Operating Expenses', 'C');
|
addBanner(sheet, 'Other Operating Expenses', 'C');
|
||||||
|
|
||||||
const start = sheet.rowCount + 1;
|
const start = sheet.rowCount + 1;
|
||||||
const title = sheet.addRow(['Details of Other Operating Expenses', '', 'Rs in Thousand']);
|
const title = sheet.addRow([
|
||||||
|
'Details of Other Operating Expenses',
|
||||||
|
'',
|
||||||
|
'Rs in Thousand'
|
||||||
|
]);
|
||||||
sheet.mergeCells(`A${title.number}:B${title.number}`);
|
sheet.mergeCells(`A${title.number}:B${title.number}`);
|
||||||
title.font = { bold: true };
|
title.font = { bold: true };
|
||||||
title.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
|
title.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
|
||||||
title.getCell(3).alignment = { horizontal: 'center' };
|
title.getCell(3).alignment = { horizontal: 'center' };
|
||||||
|
|
||||||
const head = sheet.addRow(['Sr No', 'Particulars', 'Amount']);
|
const head = sheet.addRow([
|
||||||
|
'Sr No',
|
||||||
|
'Particulars',
|
||||||
|
'Amount'
|
||||||
|
]);
|
||||||
head.font = { bold: true };
|
head.font = { bold: true };
|
||||||
head.alignment = { horizontal: 'center' };
|
head.alignment = { horizontal: 'center' };
|
||||||
head.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
|
head.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
|
||||||
|
|
||||||
for (const e of this.otherExpenses) {
|
for (const e of this.otherExpenses) {
|
||||||
const row = sheet.addRow([e.srNo, e.particulars, e.amount]);
|
const row = sheet.addRow([
|
||||||
|
e.srNo,
|
||||||
|
e.particulars,
|
||||||
|
e.amount
|
||||||
|
]);
|
||||||
row.getCell(3).numFmt = '#,##0.00';
|
row.getCell(3).numFmt = '#,##0.00';
|
||||||
}
|
}
|
||||||
|
|
||||||
const total = sheet.addRow(['Total', '', this.otherExpensesTotal]);
|
const total = sheet.addRow([
|
||||||
|
'Total',
|
||||||
|
'',
|
||||||
|
this.otherExpensesTotal
|
||||||
|
]);
|
||||||
sheet.mergeCells(`A${total.number}:B${total.number}`);
|
sheet.mergeCells(`A${total.number}:B${total.number}`);
|
||||||
total.font = { bold: true };
|
total.font = { bold: true };
|
||||||
total.getCell(1).fill = solidFill(XL.headerGrey);
|
total.getCell(1).fill = solidFill(XL.headerGrey);
|
||||||
@@ -268,7 +298,11 @@ export class Oss2RoeComponent {
|
|||||||
|
|
||||||
boxAllCells(sheet, start, sheet.rowCount, 3);
|
boxAllCells(sheet, start, sheet.rowCount, 3);
|
||||||
|
|
||||||
const ref = sheet.addRow(['', '', 'Refer Earnings Sheet — line 12 ix) Others']);
|
const ref = sheet.addRow([
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'Refer Earnings Sheet — line 12 ix) Others'
|
||||||
|
]);
|
||||||
ref.getCell(3).font = { italic: true, size: 10 };
|
ref.getCell(3).font = { italic: true, size: 10 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -52,6 +52,46 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- GL mapping toolbar (applies to the first figure column of each statement) -->
|
||||||
|
@if (activeTab !== 'filing' && activeTab !== 'signatory') {
|
||||||
|
<div class="oss-gl-bar" [class.on]="glMapMode">
|
||||||
|
<div class="oss-gl-bar-l">
|
||||||
|
<fa-icon icon="project-diagram"></fa-icon>
|
||||||
|
<span>
|
||||||
|
<strong>General Ledger mapping</strong>
|
||||||
|
@if (glMappedCount) {
|
||||||
|
<span class="oss-gl-count">{{ glMappedCount }} line(s) mapped</span>
|
||||||
|
} @else {
|
||||||
|
<span class="muted"> — pin GL accounts to a line and its first figure comes from the ledger</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="oss-gl-bar-r">
|
||||||
|
@if (glMapMode) {
|
||||||
|
@if (canSelectGlBranch) {
|
||||||
|
<select class="oss-gl-inp" [(ngModel)]="glBranchCode">
|
||||||
|
<option value="">All branches</option>
|
||||||
|
@for (b of glBranches; track b.code) {
|
||||||
|
<option [value]="b.code">{{ b.name }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
} @else {
|
||||||
|
<span class="oss-gl-scope" title="Your login is restricted to this branch">
|
||||||
|
<fa-icon icon="university"></fa-icon> {{ glScopeName }}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
@if (glMappedCount) {
|
||||||
|
<button class="oss-btn oss-btn-clear" (click)="clearAllGlMappings()">Clear all</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
<button class="oss-btn" [class.oss-btn-xls]="!glMapMode" (click)="toggleGlMapMode()">
|
||||||
|
<fa-icon [icon]="glMapMode ? 'times' : 'plus'"></fa-icon>
|
||||||
|
{{ glMapMode ? 'Done' : 'Add / Remove GLs' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<!-- FILING INFORMATION -->
|
<!-- FILING INFORMATION -->
|
||||||
@if (activeTab === 'filing') {
|
@if (activeTab === 'filing') {
|
||||||
<div class="oss-section">
|
<div class="oss-section">
|
||||||
@@ -74,7 +114,9 @@
|
|||||||
<!-- PORTFOLIO ANALYSIS -->
|
<!-- PORTFOLIO ANALYSIS -->
|
||||||
@if (activeTab === 'portfolio') {
|
@if (activeTab === 'portfolio') {
|
||||||
<div class="oss-section">
|
<div class="oss-section">
|
||||||
<div class="oss-sheet-banner">PortfolioAnalysis(loans_advncs) — Portfolio Analysis (By delinquency in Interest Payment)</div>
|
<div class="oss-sheet-banner">
|
||||||
|
PortfolioAnalysis(loans_advncs) — Portfolio Analysis (By delinquency in Interest Payment)
|
||||||
|
</div>
|
||||||
<ng-container [ngTemplateOutlet]="matrix" [ngTemplateOutletContext]="{ t: portfolioAnalysis }"></ng-container>
|
<ng-container [ngTemplateOutlet]="matrix" [ngTemplateOutletContext]="{ t: portfolioAnalysis }"></ng-container>
|
||||||
<ng-container [ngTemplateOutlet]="matrix" [ngTemplateOutletContext]="{ t: portfolioMemo }"></ng-container>
|
<ng-container [ngTemplateOutlet]="matrix" [ngTemplateOutletContext]="{ t: portfolioMemo }"></ng-container>
|
||||||
</div>
|
</div>
|
||||||
@@ -117,7 +159,9 @@
|
|||||||
<ng-container [ngTemplateOutlet]="matrix" [ngTemplateOutletContext]="{ t: capitalMemo }"></ng-container>
|
<ng-container [ngTemplateOutlet]="matrix" [ngTemplateOutletContext]="{ t: capitalMemo }"></ng-container>
|
||||||
|
|
||||||
<div class="oss-tbl-wrap">
|
<div class="oss-tbl-wrap">
|
||||||
<div class="oss-tbl-h">Table 5: Top Fifty Impaired Credit or more than Rs.100 lakhs — Rs in Thousands</div>
|
<div class="oss-tbl-h">
|
||||||
|
Table 5: Top Fifty Impaired Credit or more than Rs.100 lakhs — Rs in Thousands
|
||||||
|
</div>
|
||||||
<table class="oss-tbl">
|
<table class="oss-tbl">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -240,7 +284,8 @@
|
|||||||
Sectoral gross advances <strong>₹{{ sectoralGross | number: '1.2-2' }}</strong> and NPAs
|
Sectoral gross advances <strong>₹{{ sectoralGross | number: '1.2-2' }}</strong> and NPAs
|
||||||
<strong>₹{{ sectoralNpa | number: '1.2-2' }}</strong> do not match the closing position reported by
|
<strong>₹{{ sectoralNpa | number: '1.2-2' }}</strong> do not match the closing position reported by
|
||||||
Classification (<strong>₹{{ classificationTotal | number: '1.2-2' }}</strong> /
|
Classification (<strong>₹{{ classificationTotal | number: '1.2-2' }}</strong> /
|
||||||
<strong>₹{{ grossNpa | number: '1.2-2' }}</strong>).
|
<strong>₹{{ grossNpa | number: '1.2-2' }}</strong
|
||||||
|
>).
|
||||||
@if (sectoralMatchesOpening) {
|
@if (sectoralMatchesOpening) {
|
||||||
They match the <em>opening</em> balances in Movement Parts 1 and 2 instead — this statement appears to
|
They match the <em>opening</em> balances in Movement Parts 1 and 2 instead — this statement appears to
|
||||||
have been prepared on the previous quarter's book.
|
have been prepared on the previous quarter's book.
|
||||||
@@ -326,7 +371,10 @@
|
|||||||
<ng-template #matrix let-t="t">
|
<ng-template #matrix let-t="t">
|
||||||
<div class="oss-tbl-wrap">
|
<div class="oss-tbl-wrap">
|
||||||
<div class="oss-tbl-h">
|
<div class="oss-tbl-h">
|
||||||
{{ t.title }}@if (t.unit) {<span class="oss-tbl-unit">{{ t.unit }}</span>}
|
{{ t.title }}
|
||||||
|
@if (t.unit) {
|
||||||
|
<span class="oss-tbl-unit">{{ t.unit }}</span>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
@if (t.subtitle) {
|
@if (t.subtitle) {
|
||||||
<div class="oss-tbl-sub">{{ t.subtitle }}</div>
|
<div class="oss-tbl-sub">{{ t.subtitle }}</div>
|
||||||
@@ -335,6 +383,9 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="oss-col-label">Particulars</th>
|
<th class="oss-col-label">Particulars</th>
|
||||||
|
@if (glMapMode) {
|
||||||
|
<th class="oss-gl-col">Mapped GL accounts</th>
|
||||||
|
}
|
||||||
@for (c of t.columns; track $index) {
|
@for (c of t.columns; track $index) {
|
||||||
<th class="ta-r">{{ c }}</th>
|
<th class="ta-r">{{ c }}</th>
|
||||||
}
|
}
|
||||||
@@ -344,12 +395,48 @@
|
|||||||
@for (row of t.rows; track $index) {
|
@for (row of t.rows; track $index) {
|
||||||
<tr [class.total-row]="row.total" [class.section-row]="row.header">
|
<tr [class.total-row]="row.total" [class.section-row]="row.header">
|
||||||
<td [class]="'lvl-' + row.level">{{ row.label }}</td>
|
<td [class]="'lvl-' + row.level">{{ row.label }}</td>
|
||||||
|
@if (glMapMode) {
|
||||||
|
<td>
|
||||||
|
@if (!row.header) {
|
||||||
|
<div class="oss-gl-cell">
|
||||||
|
@for (code of glCodesFor(t.title + ' / ' + row.label); track code) {
|
||||||
|
<span class="oss-gl-chip">
|
||||||
|
<span class="mono">{{ code }}</span>
|
||||||
|
<button
|
||||||
|
class="oss-gl-chip-x"
|
||||||
|
(click)="removeGlFromLine(t.title + ' / ' + row.label, code)"
|
||||||
|
[attr.aria-label]="'Remove ' + code"
|
||||||
|
>
|
||||||
|
<fa-icon icon="times"></fa-icon>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
<button
|
||||||
|
class="oss-gl-add"
|
||||||
|
(click)="openGlPicker(t.title + ' / ' + row.label, row.values[0] ?? null)"
|
||||||
|
>
|
||||||
|
<fa-icon icon="plus"></fa-icon>
|
||||||
|
{{ glCodesFor(t.title + ' / ' + row.label).length ? 'Edit' : 'Add GL' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
}
|
||||||
@if (row.header) {
|
@if (row.header) {
|
||||||
<td [attr.colspan]="t.columns.length"></td>
|
<td [attr.colspan]="t.columns.length"></td>
|
||||||
} @else {
|
} @else {
|
||||||
@for (v of row.values; track $index) {
|
@for (v of row.values; track $index) {
|
||||||
<td class="ta-r mono" [class.derived]="row.total">
|
<td
|
||||||
{{ v === null ? '' : (v | number: '1.2-2') }}{{ v !== null && row.percent ? '%' : '' }}
|
class="ta-r mono"
|
||||||
|
[class.derived]="row.total"
|
||||||
|
[class.gl-sourced]="$index === 0 && isGlMapped(t.title + ' / ' + row.label)"
|
||||||
|
[class.gl-diff]="$index === 0 && glDiffers(t.title + ' / ' + row.label, v)"
|
||||||
|
>
|
||||||
|
@if ($index === 0 && isGlMapped(t.title + ' / ' + row.label)) {
|
||||||
|
{{ effectiveValue(t.title + ' / ' + row.label, v) | number: '1.2-2' }}{{ row.percent ? '%' : '' }}
|
||||||
|
} @else {
|
||||||
|
{{ v === null ? '' : (v | number: '1.2-2') }}{{ v !== null && row.percent ? '%' : '' }}
|
||||||
|
}
|
||||||
</td>
|
</td>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
|||||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
import * as ExcelJS from 'exceljs';
|
import * as ExcelJS from 'exceljs';
|
||||||
import { MatrixTable } from '../oss-return.model';
|
import { MatrixTable } from '../oss-return.model';
|
||||||
|
import { GlMappingHost } from '../gl-mapping.host';
|
||||||
import {
|
import {
|
||||||
addBanner,
|
addBanner,
|
||||||
addMatrixTable,
|
addMatrixTable,
|
||||||
@@ -77,7 +78,14 @@ type Oss3Tab =
|
|||||||
FormsModule
|
FormsModule
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class Oss3RaqComponent {
|
export class Oss3RaqComponent extends GlMappingHost {
|
||||||
|
readonly glReturnCode = 'R323';
|
||||||
|
|
||||||
|
/** RAQ reports the quarter; mapped figures are drawn from the same span. */
|
||||||
|
get glRange() {
|
||||||
|
return { from: '2026-04-01', to: '2026-06-30' };
|
||||||
|
}
|
||||||
|
|
||||||
activeTab: Oss3Tab = 'filing';
|
activeTab: Oss3Tab = 'filing';
|
||||||
|
|
||||||
/** Tab strip definition — keeps the template free of a dozen near-identical buttons. */
|
/** Tab strip definition — keeps the template free of a dozen near-identical buttons. */
|
||||||
@@ -313,8 +321,7 @@ export class Oss3RaqComponent {
|
|||||||
{ width: 16 },
|
{ width: 16 },
|
||||||
{ width: 18 },
|
{ width: 18 },
|
||||||
{ width: 18 },
|
{ width: 18 },
|
||||||
{ width: 18 }
|
{ width: 18 }];
|
||||||
];
|
|
||||||
addBanner(sheet, 'TopImpairedCredits - Top Impaired Credits', 'I');
|
addBanner(sheet, 'TopImpairedCredits - Top Impaired Credits', 'I');
|
||||||
addMatrixTable(sheet, this.capitalMemo);
|
addMatrixTable(sheet, this.capitalMemo);
|
||||||
for (const t of this.recoveryTables) {
|
for (const t of this.recoveryTables) {
|
||||||
@@ -398,8 +405,7 @@ export class Oss3RaqComponent {
|
|||||||
{ width: 18 },
|
{ width: 18 },
|
||||||
{ width: 20 },
|
{ width: 20 },
|
||||||
{ width: 22 },
|
{ width: 22 },
|
||||||
{ width: 20 }
|
{ width: 20 }];
|
||||||
];
|
|
||||||
addBanner(sheet, 'ExposureLimits - Single and Group Borrower Exposure Limit', 'G');
|
addBanner(sheet, 'ExposureLimits - Single and Group Borrower Exposure Limit', 'G');
|
||||||
addMatrixTable(sheet, this.capitalPreviousMarch);
|
addMatrixTable(sheet, this.capitalPreviousMarch);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,703 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Dummy data layer for the "Reconciliation" mega-menu — 16 screens across 5 groups (Core &
|
||||||
|
// Books / Bank & Clearing / Digital & Payments / Instruments & Members / Compliance &
|
||||||
|
// Financials). Every reconciliation type shares one generic two-sided model (Side A vs Side B,
|
||||||
|
// difference, match status) so the UI, KPIs and "Mark as Reconciled" action are all driven by
|
||||||
|
// the same pure functions below — no report can duplicate/contradict another's numbers.
|
||||||
|
//
|
||||||
|
// Branches are the SAME 8 used by the NPA module (Branch 1/Branch 2 + 6 named branches) so a
|
||||||
|
// real bank demo never shows two different branch lists in two different menus.
|
||||||
|
|
||||||
|
import { BRANCHES, BranchMaster } from '../npa/npa-data';
|
||||||
|
|
||||||
|
export { BRANCHES };
|
||||||
|
export type { BranchMaster };
|
||||||
|
|
||||||
|
export type ReconGroupId = 'core' | 'bank' | 'digital' | 'instruments' | 'compliance';
|
||||||
|
export type ReconScope = 'BRANCH' | 'HEAD_OFFICE';
|
||||||
|
export type ReconStatus = 'MATCHED' | 'UNMATCHED' | 'PENDING' | 'RESOLVED';
|
||||||
|
|
||||||
|
export const GROUP_LABELS: Record<ReconGroupId, string> = {
|
||||||
|
core: 'Core & Books',
|
||||||
|
bank: 'Bank & Clearing',
|
||||||
|
digital: 'Digital & Payments',
|
||||||
|
instruments: 'Instruments & Members',
|
||||||
|
compliance: 'Compliance & Financials'
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ReconTypeMeta {
|
||||||
|
id: string;
|
||||||
|
group: ReconGroupId;
|
||||||
|
label: string;
|
||||||
|
/**
|
||||||
|
* BRANCH — the underlying activity is booked at a specific branch (vouchers, cheques,
|
||||||
|
* customer digital transactions, share members, fixed assets), so Head Office sees a branch
|
||||||
|
* selector + consolidated view and a branch login is locked to its own branch — same pattern
|
||||||
|
* as the NPA module.
|
||||||
|
* HEAD_OFFICE — the activity has no branch dimension in real life (bank-wide P&L/GL control,
|
||||||
|
* treasury's own bank accounts, statutory tax filing, vendor procurement, inter-branch
|
||||||
|
* consolidation) — only Head Office can open these; a branch login sees a restricted notice.
|
||||||
|
*/
|
||||||
|
scope: ReconScope;
|
||||||
|
sideALabel: string;
|
||||||
|
sideBLabel: string;
|
||||||
|
referenceLabel: string;
|
||||||
|
/** Only set for the one genuinely three-sided report (Three-way Digital Reconciliation). */
|
||||||
|
extraLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RECON_TYPES: ReconTypeMeta[] = [
|
||||||
|
{
|
||||||
|
id: 'voucher',
|
||||||
|
group: 'core',
|
||||||
|
label: 'Voucher Reconciliation',
|
||||||
|
scope: 'BRANCH',
|
||||||
|
sideALabel: 'System Voucher Amount',
|
||||||
|
sideBLabel: 'Physical / Scanned Voucher Amount',
|
||||||
|
referenceLabel: 'Voucher No'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sundry-dr-cr',
|
||||||
|
group: 'core',
|
||||||
|
label: 'Sundry Dr / Cr Reconciliation',
|
||||||
|
scope: 'BRANCH',
|
||||||
|
sideALabel: 'GL Balance',
|
||||||
|
sideBLabel: 'Party Ledger Balance',
|
||||||
|
referenceLabel: 'Party Code'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pnl-impact',
|
||||||
|
group: 'core',
|
||||||
|
label: 'P&L Impact Reconciliation',
|
||||||
|
scope: 'HEAD_OFFICE',
|
||||||
|
sideALabel: 'P&L as per GL',
|
||||||
|
sideBLabel: 'P&L as per MIS Schedule',
|
||||||
|
referenceLabel: 'Income / Expense Head'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gl-reconciliation',
|
||||||
|
group: 'core',
|
||||||
|
label: 'GL Reconciliation',
|
||||||
|
scope: 'HEAD_OFFICE',
|
||||||
|
sideALabel: 'Total Debit',
|
||||||
|
sideBLabel: 'Total Credit',
|
||||||
|
referenceLabel: 'GL Code'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fixed-assets',
|
||||||
|
group: 'core',
|
||||||
|
label: 'Fixed Assets Reconciliation',
|
||||||
|
scope: 'BRANCH',
|
||||||
|
sideALabel: 'GL Asset Value (WDV)',
|
||||||
|
sideBLabel: 'Physical Verification Value',
|
||||||
|
referenceLabel: 'Asset Tag No'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'inter-company',
|
||||||
|
group: 'core',
|
||||||
|
label: 'Inter-Company Reconciliation',
|
||||||
|
scope: 'HEAD_OFFICE',
|
||||||
|
sideALabel: 'HO Books (Inter-Branch A/c)',
|
||||||
|
sideBLabel: 'Branch Books (HO A/c)',
|
||||||
|
referenceLabel: 'Branch'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bank-reco',
|
||||||
|
group: 'bank',
|
||||||
|
label: 'Bank Reconciliation',
|
||||||
|
scope: 'HEAD_OFFICE',
|
||||||
|
sideALabel: 'Balance per Bank GL (Books)',
|
||||||
|
sideBLabel: 'Balance per Bank Statement',
|
||||||
|
referenceLabel: 'Bank Account'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'api-bank-reco',
|
||||||
|
group: 'bank',
|
||||||
|
label: 'API Based Bank Reconciliation',
|
||||||
|
scope: 'HEAD_OFFICE',
|
||||||
|
sideALabel: 'Core Banking Balance',
|
||||||
|
sideBLabel: 'API Feed Balance',
|
||||||
|
referenceLabel: 'Bank Account (API)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'neft-rtgs',
|
||||||
|
group: 'bank',
|
||||||
|
label: 'NEFT / RTGS Reconciliation',
|
||||||
|
scope: 'BRANCH',
|
||||||
|
sideALabel: 'Amount per CBS',
|
||||||
|
sideBLabel: 'Amount per RBI Settlement File',
|
||||||
|
referenceLabel: 'UTR No'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'clearing-reco',
|
||||||
|
group: 'bank',
|
||||||
|
label: 'Clearing Reconciliation',
|
||||||
|
scope: 'BRANCH',
|
||||||
|
sideALabel: 'Cheque Amount per CBS',
|
||||||
|
sideBLabel: 'Amount per Clearing House (CTS)',
|
||||||
|
referenceLabel: 'Cheque No'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'udir',
|
||||||
|
group: 'digital',
|
||||||
|
label: 'UDIR (IMPS / UPI / ATM)',
|
||||||
|
scope: 'BRANCH',
|
||||||
|
sideALabel: 'Disputed Amount (Customer Claim)',
|
||||||
|
sideBLabel: 'NPCI UDIR Resolution Amount',
|
||||||
|
referenceLabel: 'UDIR Ticket No'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'three-way-digital',
|
||||||
|
group: 'digital',
|
||||||
|
label: 'Three-way Digital Reconciliation',
|
||||||
|
scope: 'HEAD_OFFICE',
|
||||||
|
sideALabel: 'Core Banking Record',
|
||||||
|
sideBLabel: 'Settlement Bank Record',
|
||||||
|
extraLabel: 'Switch / NPCI Record',
|
||||||
|
referenceLabel: 'Transaction Ref (RRN)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nach-reco',
|
||||||
|
group: 'digital',
|
||||||
|
label: 'NACH Reconciliation',
|
||||||
|
scope: 'BRANCH',
|
||||||
|
sideALabel: 'Mandate Amount per CBS',
|
||||||
|
sideBLabel: 'Amount per NPCI Response File',
|
||||||
|
referenceLabel: 'UMRN'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'share-member',
|
||||||
|
group: 'instruments',
|
||||||
|
label: 'Share Member Reconciliation',
|
||||||
|
scope: 'BRANCH',
|
||||||
|
sideALabel: 'Share GL Balance',
|
||||||
|
sideBLabel: 'Member Share Ledger Total',
|
||||||
|
referenceLabel: 'Folio No'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'invoice-payment',
|
||||||
|
group: 'compliance',
|
||||||
|
label: 'Invoice Payment',
|
||||||
|
scope: 'HEAD_OFFICE',
|
||||||
|
sideALabel: 'Invoice Amount (Vendor Bill)',
|
||||||
|
sideBLabel: 'Amount Paid per GL',
|
||||||
|
referenceLabel: 'Invoice No'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tax-reco',
|
||||||
|
group: 'compliance',
|
||||||
|
label: 'Tax Reconciliation',
|
||||||
|
scope: 'HEAD_OFFICE',
|
||||||
|
sideALabel: 'Deducted / Collected per Books',
|
||||||
|
sideBLabel: 'Remitted to Government (Challan)',
|
||||||
|
referenceLabel: 'Challan No'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DEFAULT_RECON_ID = 'voucher';
|
||||||
|
|
||||||
|
export function reconMeta(id: string): ReconTypeMeta {
|
||||||
|
return RECON_TYPES.find((t) => t.id === id) || RECON_TYPES[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReconItem {
|
||||||
|
id: string;
|
||||||
|
reconTypeId: string;
|
||||||
|
branchCode: string;
|
||||||
|
branchName: string;
|
||||||
|
groupLabel: string;
|
||||||
|
reference: string;
|
||||||
|
description: string;
|
||||||
|
date: string;
|
||||||
|
sideAAmount: number;
|
||||||
|
sideBAmount: number;
|
||||||
|
extraAmount?: number;
|
||||||
|
status: ReconStatus;
|
||||||
|
remarks: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function difference(item: ReconItem): number {
|
||||||
|
return +(item.sideAAmount - item.sideBAmount).toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isOpen(item: ReconItem): boolean {
|
||||||
|
return item.status === 'UNMATCHED' || item.status === 'PENDING';
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Date helpers (same shape as npa-data.ts, kept local so this module has no
|
||||||
|
// runtime dependency beyond the shared BRANCHES list)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
function fmt(d: Date): string {
|
||||||
|
return `${String(d.getDate()).padStart(2, '0')}-${String(d.getMonth() + 1).padStart(2, '0')}-${d.getFullYear()}`;
|
||||||
|
}
|
||||||
|
function addDays(d: Date, days: number): Date {
|
||||||
|
const r = new Date(d);
|
||||||
|
r.setDate(r.getDate() + days);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
export function todayDDMMYYYY(): string {
|
||||||
|
return fmt(new Date());
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Seeded RNG — deterministic dataset across reloads (own seed, independent of npa-data.ts)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
function mulberry32(seed: number): () => number {
|
||||||
|
return function random(): number {
|
||||||
|
seed |= 0;
|
||||||
|
seed = (seed + 0x6d2b79f5) | 0;
|
||||||
|
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
|
||||||
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const rng = mulberry32(20260213);
|
||||||
|
const pick = <T>(arr: T[]): T => arr[Math.floor(rng() * arr.length)];
|
||||||
|
const between = (min: number, max: number): number => min + rng() * (max - min);
|
||||||
|
const round = (n: number): number => Math.round(n);
|
||||||
|
|
||||||
|
const VENDOR_NAMES = [
|
||||||
|
'Om Stationery Suppliers',
|
||||||
|
'Shubham Printers',
|
||||||
|
'Shree Balaji AMC Services',
|
||||||
|
'Metro Security Services',
|
||||||
|
'Sai Computers & Networking',
|
||||||
|
'Deccan Furniture Works',
|
||||||
|
'Unity Housekeeping Services',
|
||||||
|
'Precision Fire & Safety',
|
||||||
|
'Konark Electricals',
|
||||||
|
'Vasant Stationery Mart'
|
||||||
|
];
|
||||||
|
const PARTY_NAMES = [
|
||||||
|
'Kiran Hardware',
|
||||||
|
'Vishal Electronics',
|
||||||
|
'Nilesh Electricals',
|
||||||
|
'Ramdas Plumbing Works',
|
||||||
|
'Anita Traders',
|
||||||
|
'Ganesh Enterprises',
|
||||||
|
'Om Textiles',
|
||||||
|
'Balaji Agro Traders',
|
||||||
|
'Sunil Auto Parts',
|
||||||
|
'Meera Fabricators'
|
||||||
|
];
|
||||||
|
const ASSET_NAMES = [
|
||||||
|
'Office Furniture Set',
|
||||||
|
'Desktop Computer (Branch Counter)',
|
||||||
|
'UPS 3 KVA',
|
||||||
|
'Currency Counting Machine',
|
||||||
|
'Air Conditioner 1.5 Ton',
|
||||||
|
'CCTV Camera System',
|
||||||
|
'Branch Generator 15 KVA',
|
||||||
|
'Server Rack Cabinet',
|
||||||
|
'Biometric Attendance Device',
|
||||||
|
'Two-Wheeler (Recovery Officer)'
|
||||||
|
];
|
||||||
|
const GL_HEADS = [
|
||||||
|
'Interest on Loans & Advances',
|
||||||
|
'Interest on Investments',
|
||||||
|
'Interest Paid on Deposits',
|
||||||
|
'Commission & Exchange',
|
||||||
|
'Locker Rent Income',
|
||||||
|
'Establishment Charges',
|
||||||
|
'Rent, Taxes & Lighting',
|
||||||
|
'Printing & Stationery',
|
||||||
|
'Depreciation on Fixed Assets',
|
||||||
|
'Bad Debts Written Off',
|
||||||
|
'Audit Fees',
|
||||||
|
'Sundry Income'
|
||||||
|
];
|
||||||
|
const BANK_ACCOUNTS = [
|
||||||
|
'SBI Current Account - 40012345678',
|
||||||
|
'HDFC Bank Current Account - 5020012345',
|
||||||
|
'ICICI Bank Current Account - 000305012345',
|
||||||
|
'Axis Bank Current Account - 91020012345',
|
||||||
|
'RBI Current Account (Banking Dept)'
|
||||||
|
];
|
||||||
|
|
||||||
|
interface GenOptions {
|
||||||
|
reconTypeId: string;
|
||||||
|
refPrefix: string;
|
||||||
|
refDigits: number;
|
||||||
|
descriptions: string[];
|
||||||
|
minAmt: number;
|
||||||
|
maxAmt: number;
|
||||||
|
unmatchedRate: number;
|
||||||
|
branchScoped: boolean;
|
||||||
|
countPerBranch?: number;
|
||||||
|
totalCount?: number;
|
||||||
|
today: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusFor(unmatchedRate: number): ReconStatus {
|
||||||
|
const r = rng();
|
||||||
|
if (r < unmatchedRate * 0.6) {
|
||||||
|
return 'UNMATCHED';
|
||||||
|
}
|
||||||
|
if (r < unmatchedRate) {
|
||||||
|
return 'PENDING';
|
||||||
|
}
|
||||||
|
return 'MATCHED';
|
||||||
|
}
|
||||||
|
|
||||||
|
function amountsFor(status: ReconStatus, minAmt: number, maxAmt: number): { a: number; b: number } {
|
||||||
|
const a = round(between(minAmt, maxAmt));
|
||||||
|
if (status === 'MATCHED') {
|
||||||
|
return { a, b: a };
|
||||||
|
}
|
||||||
|
const gapPct = between(0.02, 0.35);
|
||||||
|
const sign = rng() < 0.5 ? 1 : -1;
|
||||||
|
const b = round(a * (1 - sign * gapPct));
|
||||||
|
return { a, b };
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateGeneric(opts: GenOptions): ReconItem[] {
|
||||||
|
const items: ReconItem[] = [];
|
||||||
|
const meta = reconMeta(opts.reconTypeId);
|
||||||
|
const branchLoop = opts.branchScoped ? BRANCHES : [{ code: 'HO', name: 'Head Office' }];
|
||||||
|
const perBranch = opts.countPerBranch ?? opts.totalCount ?? 20;
|
||||||
|
|
||||||
|
branchLoop.forEach((branch, branchIdx) => {
|
||||||
|
const count = opts.branchScoped ? perBranch : (opts.totalCount ?? perBranch);
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const status = statusFor(opts.unmatchedRate);
|
||||||
|
const { a, b } = amountsFor(status, opts.minAmt, opts.maxAmt);
|
||||||
|
const daysAgo = round(between(1, 75));
|
||||||
|
const seq = String(branchIdx * 1000 + i + 1).padStart(opts.refDigits, '0');
|
||||||
|
items.push({
|
||||||
|
id: `${opts.reconTypeId}-${branch.code}-${i}`,
|
||||||
|
reconTypeId: opts.reconTypeId,
|
||||||
|
branchCode: branch.code,
|
||||||
|
branchName: branch.name,
|
||||||
|
groupLabel: GROUP_LABELS[meta.group],
|
||||||
|
reference: `${opts.refPrefix}${seq}`,
|
||||||
|
description: pick(opts.descriptions),
|
||||||
|
date: fmt(addDays(opts.today, -daysAgo)),
|
||||||
|
sideAAmount: a,
|
||||||
|
sideBAmount: status === 'MATCHED' ? a : b,
|
||||||
|
status,
|
||||||
|
remarks: status === 'MATCHED' ? '' : ''
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateInterCompany(today: Date): ReconItem[] {
|
||||||
|
return BRANCHES.map((b): ReconItem => {
|
||||||
|
const status = statusFor(0.25);
|
||||||
|
const { a, b: bAmt } = amountsFor(status, 200000, 3500000);
|
||||||
|
return {
|
||||||
|
id: `inter-company-${b.code}`,
|
||||||
|
reconTypeId: 'inter-company',
|
||||||
|
branchCode: 'HO',
|
||||||
|
branchName: 'Head Office',
|
||||||
|
groupLabel: GROUP_LABELS.core,
|
||||||
|
reference: b.name,
|
||||||
|
description: `Inter-branch settlement account — ${b.name}`,
|
||||||
|
date: fmt(addDays(today, -round(between(1, 10)))),
|
||||||
|
sideAAmount: a,
|
||||||
|
sideBAmount: status === 'MATCHED' ? a : bAmt,
|
||||||
|
status,
|
||||||
|
remarks: ''
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateBankReco(reconTypeId: string, today: Date, refPrefix: string): ReconItem[] {
|
||||||
|
const templates = [
|
||||||
|
'Cheque issued, not yet presented',
|
||||||
|
'Cheque deposited, not yet credited by bank',
|
||||||
|
'Bank charges debited, not yet booked in CBS',
|
||||||
|
'Interest credited by bank, not yet booked in CBS',
|
||||||
|
'Direct credit by remitter, not yet identified',
|
||||||
|
'Standing instruction debited by bank, pending booking',
|
||||||
|
'RTGS outward debited by bank, in-transit in CBS'
|
||||||
|
];
|
||||||
|
const items: ReconItem[] = [];
|
||||||
|
BANK_ACCOUNTS.forEach((acct, idx) => {
|
||||||
|
const lineCount = reconTypeId === 'api-bank-reco' ? 5 : 6;
|
||||||
|
for (let i = 0; i < lineCount; i++) {
|
||||||
|
const status = statusFor(0.55);
|
||||||
|
const { a, b } = amountsFor(status, 15000, 850000);
|
||||||
|
items.push({
|
||||||
|
id: `${reconTypeId}-${idx}-${i}`,
|
||||||
|
reconTypeId,
|
||||||
|
branchCode: 'HO',
|
||||||
|
branchName: 'Head Office',
|
||||||
|
groupLabel: GROUP_LABELS.bank,
|
||||||
|
reference: `${refPrefix}${String(idx + 1).padStart(2, '0')}${String(i + 1).padStart(3, '0')}`,
|
||||||
|
description: `${acct} — ${pick(templates)}`,
|
||||||
|
date: fmt(addDays(today, -round(between(1, 30)))),
|
||||||
|
sideAAmount: a,
|
||||||
|
sideBAmount: status === 'MATCHED' ? a : b,
|
||||||
|
status,
|
||||||
|
remarks: ''
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateThreeWayDigital(today: Date): ReconItem[] {
|
||||||
|
const channels = [
|
||||||
|
'IMPS',
|
||||||
|
'UPI',
|
||||||
|
'NEFT',
|
||||||
|
'RTGS',
|
||||||
|
'ATM (NFS)'
|
||||||
|
];
|
||||||
|
const items: ReconItem[] = [];
|
||||||
|
for (let i = 0; i < 45; i++) {
|
||||||
|
const status = statusFor(0.12);
|
||||||
|
const core = round(between(500, 250000));
|
||||||
|
const switchAmt = status === 'MATCHED' ? core : round(core * (1 - between(0.02, 0.2)));
|
||||||
|
const settlement = status === 'MATCHED' ? core : round(core * (1 - between(0.02, 0.15)));
|
||||||
|
items.push({
|
||||||
|
id: `three-way-digital-${i}`,
|
||||||
|
reconTypeId: 'three-way-digital',
|
||||||
|
branchCode: 'HO',
|
||||||
|
branchName: 'Head Office',
|
||||||
|
groupLabel: GROUP_LABELS.digital,
|
||||||
|
reference: `RRN${String(700000 + i)}`,
|
||||||
|
description: `${pick(channels)} transaction — Core vs Switch vs Settlement Bank`,
|
||||||
|
date: fmt(addDays(today, -round(between(1, 20)))),
|
||||||
|
sideAAmount: core,
|
||||||
|
sideBAmount: settlement,
|
||||||
|
extraAmount: switchAmt,
|
||||||
|
status,
|
||||||
|
remarks: ''
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateAll(): ReconItem[] {
|
||||||
|
const today = new Date();
|
||||||
|
return [
|
||||||
|
...generateGeneric({
|
||||||
|
reconTypeId: 'voucher',
|
||||||
|
refPrefix: 'JV/26/',
|
||||||
|
refDigits: 5,
|
||||||
|
descriptions: [
|
||||||
|
'Cash deposit voucher',
|
||||||
|
'Cash withdrawal voucher',
|
||||||
|
'Journal entry — interest provision',
|
||||||
|
'Journal entry — expense booking',
|
||||||
|
'Transfer voucher — inter-GL',
|
||||||
|
'Debit voucher — charges recovery',
|
||||||
|
'Credit voucher — refund adjustment'
|
||||||
|
],
|
||||||
|
minAmt: 500,
|
||||||
|
maxAmt: 250000,
|
||||||
|
unmatchedRate: 0.08,
|
||||||
|
branchScoped: true,
|
||||||
|
countPerBranch: 15,
|
||||||
|
today
|
||||||
|
}),
|
||||||
|
...generateGeneric({
|
||||||
|
reconTypeId: 'sundry-dr-cr',
|
||||||
|
refPrefix: 'PTY-',
|
||||||
|
refDigits: 4,
|
||||||
|
descriptions: PARTY_NAMES,
|
||||||
|
minAmt: 5000,
|
||||||
|
maxAmt: 180000,
|
||||||
|
unmatchedRate: 0.18,
|
||||||
|
branchScoped: true,
|
||||||
|
countPerBranch: 10,
|
||||||
|
today
|
||||||
|
}),
|
||||||
|
...generateGeneric({
|
||||||
|
reconTypeId: 'pnl-impact',
|
||||||
|
refPrefix: 'GLH-',
|
||||||
|
refDigits: 3,
|
||||||
|
descriptions: GL_HEADS,
|
||||||
|
minAmt: 40000,
|
||||||
|
maxAmt: 4200000,
|
||||||
|
unmatchedRate: 0.15,
|
||||||
|
branchScoped: false,
|
||||||
|
totalCount: 24,
|
||||||
|
today
|
||||||
|
}),
|
||||||
|
...generateGeneric({
|
||||||
|
reconTypeId: 'gl-reconciliation',
|
||||||
|
refPrefix: 'GL-',
|
||||||
|
refDigits: 6,
|
||||||
|
descriptions: [
|
||||||
|
'Suspense account clearance check',
|
||||||
|
'Control account tally — deposits',
|
||||||
|
'Control account tally — advances',
|
||||||
|
'Inter-branch adjustment head',
|
||||||
|
'Sundry assets/liabilities head',
|
||||||
|
...GL_HEADS
|
||||||
|
],
|
||||||
|
minAmt: 20000,
|
||||||
|
maxAmt: 5200000,
|
||||||
|
unmatchedRate: 0.1,
|
||||||
|
branchScoped: false,
|
||||||
|
totalCount: 42,
|
||||||
|
today
|
||||||
|
}),
|
||||||
|
...generateGeneric({
|
||||||
|
reconTypeId: 'fixed-assets',
|
||||||
|
refPrefix: 'FA-',
|
||||||
|
refDigits: 4,
|
||||||
|
descriptions: ASSET_NAMES,
|
||||||
|
minAmt: 8000,
|
||||||
|
maxAmt: 650000,
|
||||||
|
unmatchedRate: 0.12,
|
||||||
|
branchScoped: true,
|
||||||
|
countPerBranch: 8,
|
||||||
|
today
|
||||||
|
}),
|
||||||
|
...generateInterCompany(today),
|
||||||
|
...generateBankReco('bank-reco', today, 'BRS-'),
|
||||||
|
...generateBankReco('api-bank-reco', today, 'API-'),
|
||||||
|
...generateGeneric({
|
||||||
|
reconTypeId: 'neft-rtgs',
|
||||||
|
refPrefix: 'UTR',
|
||||||
|
refDigits: 12,
|
||||||
|
descriptions: [
|
||||||
|
'RTGS outward — beneficiary bank settlement',
|
||||||
|
'RTGS inward — remitting bank settlement',
|
||||||
|
'NEFT outward — beneficiary bank settlement',
|
||||||
|
'NEFT inward — remitting bank settlement'
|
||||||
|
],
|
||||||
|
minAmt: 25000,
|
||||||
|
maxAmt: 1800000,
|
||||||
|
unmatchedRate: 0.06,
|
||||||
|
branchScoped: true,
|
||||||
|
countPerBranch: 12,
|
||||||
|
today
|
||||||
|
}),
|
||||||
|
...generateGeneric({
|
||||||
|
reconTypeId: 'clearing-reco',
|
||||||
|
refPrefix: 'CHQ-',
|
||||||
|
refDigits: 6,
|
||||||
|
descriptions: [
|
||||||
|
'Outward clearing — cheque sent',
|
||||||
|
'Inward clearing — cheque received',
|
||||||
|
'CTS grid settlement adjustment',
|
||||||
|
'Cheque return — insufficient funds'
|
||||||
|
],
|
||||||
|
minAmt: 10000,
|
||||||
|
maxAmt: 620000,
|
||||||
|
unmatchedRate: 0.09,
|
||||||
|
branchScoped: true,
|
||||||
|
countPerBranch: 10,
|
||||||
|
today
|
||||||
|
}),
|
||||||
|
...generateGeneric({
|
||||||
|
reconTypeId: 'udir',
|
||||||
|
refPrefix: 'UDIR-',
|
||||||
|
refDigits: 5,
|
||||||
|
descriptions: [
|
||||||
|
'IMPS failed but debited — customer dispute',
|
||||||
|
'UPI transaction disputed — merchant not credited',
|
||||||
|
'ATM cash-not-dispensed but account debited',
|
||||||
|
'UPI duplicate debit dispute'
|
||||||
|
],
|
||||||
|
minAmt: 500,
|
||||||
|
maxAmt: 45000,
|
||||||
|
unmatchedRate: 0.35,
|
||||||
|
branchScoped: true,
|
||||||
|
countPerBranch: 4,
|
||||||
|
today
|
||||||
|
}),
|
||||||
|
...generateThreeWayDigital(today),
|
||||||
|
...generateGeneric({
|
||||||
|
reconTypeId: 'nach-reco',
|
||||||
|
refPrefix: 'UMRN',
|
||||||
|
refDigits: 10,
|
||||||
|
descriptions: [
|
||||||
|
'NACH debit — loan EMI collection',
|
||||||
|
'NACH debit — gold loan EMI collection',
|
||||||
|
'NACH credit — salary/DBT disbursement',
|
||||||
|
'NACH mandate return — insufficient funds'
|
||||||
|
],
|
||||||
|
minAmt: 1500,
|
||||||
|
maxAmt: 85000,
|
||||||
|
unmatchedRate: 0.14,
|
||||||
|
branchScoped: true,
|
||||||
|
countPerBranch: 8,
|
||||||
|
today
|
||||||
|
}),
|
||||||
|
...generateGeneric({
|
||||||
|
reconTypeId: 'share-member',
|
||||||
|
refPrefix: 'FOL-',
|
||||||
|
refDigits: 5,
|
||||||
|
descriptions: [
|
||||||
|
'Regular member — share balance',
|
||||||
|
'Nominal member — share balance',
|
||||||
|
'Share transfer on account closure',
|
||||||
|
'Share application pending allotment'
|
||||||
|
],
|
||||||
|
minAmt: 500,
|
||||||
|
maxAmt: 25000,
|
||||||
|
unmatchedRate: 0.07,
|
||||||
|
branchScoped: true,
|
||||||
|
countPerBranch: 9,
|
||||||
|
today
|
||||||
|
}),
|
||||||
|
...generateGeneric({
|
||||||
|
reconTypeId: 'invoice-payment',
|
||||||
|
refPrefix: 'INV-',
|
||||||
|
refDigits: 5,
|
||||||
|
descriptions: VENDOR_NAMES,
|
||||||
|
minAmt: 3000,
|
||||||
|
maxAmt: 380000,
|
||||||
|
unmatchedRate: 0.13,
|
||||||
|
branchScoped: false,
|
||||||
|
totalCount: 30,
|
||||||
|
today
|
||||||
|
}),
|
||||||
|
...generateGeneric({
|
||||||
|
reconTypeId: 'tax-reco',
|
||||||
|
refPrefix: 'CHLN-',
|
||||||
|
refDigits: 4,
|
||||||
|
descriptions: [
|
||||||
|
'TDS on FD/RD interest payouts',
|
||||||
|
'TDS on salary payments',
|
||||||
|
'TDS on contractor/vendor payments',
|
||||||
|
'GST on service charges collected',
|
||||||
|
'GST on locker rent collected',
|
||||||
|
'TCS on cash transactions'
|
||||||
|
],
|
||||||
|
minAmt: 8000,
|
||||||
|
maxAmt: 620000,
|
||||||
|
unmatchedRate: 0.11,
|
||||||
|
branchScoped: false,
|
||||||
|
totalCount: 18,
|
||||||
|
today
|
||||||
|
})
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RECON_ITEMS: ReconItem[] = generateAll();
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// KPI helpers — always computed from the item list handed in, so a filtered
|
||||||
|
// (branch-scoped) view and the full book can never disagree on a total.
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
export function totalSideA(items: ReconItem[]): number {
|
||||||
|
return items.reduce((s, i) => s + i.sideAAmount, 0);
|
||||||
|
}
|
||||||
|
export function totalSideB(items: ReconItem[]): number {
|
||||||
|
return items.reduce((s, i) => s + i.sideBAmount, 0);
|
||||||
|
}
|
||||||
|
export function totalDifference(items: ReconItem[]): number {
|
||||||
|
return items.reduce((s, i) => s + Math.abs(difference(i)), 0);
|
||||||
|
}
|
||||||
|
export function countByStatus(items: ReconItem[], status: ReconStatus): number {
|
||||||
|
return items.filter((i) => i.status === status).length;
|
||||||
|
}
|
||||||
|
export function matchRate(items: ReconItem[]): number {
|
||||||
|
return items.length ? (countByStatus(items, 'MATCHED') / items.length) * 100 : 100;
|
||||||
|
}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
<!--
|
||||||
|
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="pg">
|
||||||
|
<header class="pg-head">
|
||||||
|
<div>
|
||||||
|
<div class="pg-eyebrow">
|
||||||
|
RECONCILIATION · {{ groupLabels[activeGroup] | uppercase }}
|
||||||
|
<span class="recon-scope-chip">{{ meta.scope === 'HEAD_OFFICE' ? 'Head Office Only' : 'Branch-wise' }}</span>
|
||||||
|
</div>
|
||||||
|
<h1 class="pg-title">{{ meta.label }}</h1>
|
||||||
|
<p class="pg-sub">
|
||||||
|
{{ meta.sideALabel }} vs {{ meta.sideBLabel }}
|
||||||
|
@if (meta.extraLabel) {
|
||||||
|
vs {{ meta.extraLabel }}
|
||||||
|
}
|
||||||
|
— as on {{ asOnDate }}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="pg-toolbar">
|
||||||
|
@if (meta.scope === 'BRANCH') {
|
||||||
|
@if (isHeadOffice) {
|
||||||
|
<label class="pg-field">
|
||||||
|
<span>Branch</span>
|
||||||
|
<select class="pg-select" [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>
|
||||||
|
</label>
|
||||||
|
} @else {
|
||||||
|
<label class="pg-field">
|
||||||
|
<span>Branch</span>
|
||||||
|
<span class="pg-chip">{{ scopeName }}</span>
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
<label class="pg-field">
|
||||||
|
<span>Scope</span>
|
||||||
|
<span class="pg-chip">Head Office (Whole Bank)</span>
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
<label class="pg-field">
|
||||||
|
<span>As On</span>
|
||||||
|
<input type="text" [(ngModel)]="asOnDate" placeholder="DD-MM-YYYY" />
|
||||||
|
</label>
|
||||||
|
<button type="button" class="pg-btn pg-btn--primary" (click)="generate()">Generate</button>
|
||||||
|
@if (!isRestricted) {
|
||||||
|
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="generating" (click)="openExportModal()">
|
||||||
|
{{ generating ? 'Exporting…' : 'Export to Excel' }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
@if (!isRestricted) {
|
||||||
|
<div class="inv-kpis">
|
||||||
|
<div class="inv-kpi">
|
||||||
|
<span class="inv-kpi__label">{{ meta.sideALabel }}</span>
|
||||||
|
<span class="inv-kpi__value">₹{{ totalA | number: '1.0-0' }}</span>
|
||||||
|
<span class="inv-kpi__sub">{{ items.length }} items</span>
|
||||||
|
</div>
|
||||||
|
<div class="inv-kpi">
|
||||||
|
<span class="inv-kpi__label">{{ meta.sideBLabel }}</span>
|
||||||
|
<span class="inv-kpi__value">₹{{ totalB | number: '1.0-0' }}</span>
|
||||||
|
<span class="inv-kpi__sub">{{ scopeName }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="inv-kpi">
|
||||||
|
<span class="inv-kpi__label">Absolute Difference</span>
|
||||||
|
<span class="inv-kpi__value" [class.npa-warn]="totalDiff > 0">₹{{ totalDiff | number: '1.0-0' }}</span>
|
||||||
|
<span class="inv-kpi__sub">{{ matchPct | number: '1.1-1' }}% matched</span>
|
||||||
|
</div>
|
||||||
|
<div class="inv-kpi">
|
||||||
|
<span class="inv-kpi__label">Open Items</span>
|
||||||
|
<span class="inv-kpi__value npa-warn">{{ unmatchedCount + pendingCount }}</span>
|
||||||
|
<span class="inv-kpi__sub"
|
||||||
|
>{{ unmatchedCount }} unmatched · {{ pendingCount }} pending · {{ resolvedCount }} resolved</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="cctr-tabs">
|
||||||
|
@for (g of groups; track g) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--ghost"
|
||||||
|
[class.pg-btn--active]="activeGroup === g"
|
||||||
|
(click)="goToReport(groupOf(g)[0].id)"
|
||||||
|
>
|
||||||
|
{{ groupLabels[g] }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cctr-tabs">
|
||||||
|
@for (m of groupOf(activeGroup); track m.id) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--ghost"
|
||||||
|
[class.pg-btn--active]="activeReport === m.id"
|
||||||
|
(click)="goToReport(m.id)"
|
||||||
|
>
|
||||||
|
{{ m.label }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (isRestricted) {
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="recon-restricted">
|
||||||
|
<fa-icon icon="university" class="recon-restricted__icon"></fa-icon>
|
||||||
|
<div class="recon-restricted__title">Managed Centrally by Head Office</div>
|
||||||
|
<p class="recon-restricted__body">
|
||||||
|
{{ meta.label }} has no branch-level dimension in real-world banking — it is reconciled centrally by the Head
|
||||||
|
Office Accounts/Treasury team. Your login ({{ currentOfficeName }}) does not have access to this screen.
|
||||||
|
Contact Head Office if you need a figure from this reconciliation.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">{{ meta.label }} — {{ scopeName }}</div>
|
||||||
|
<div class="pg-table-scroll">
|
||||||
|
<table class="pg-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ meta.referenceLabel }}</th>
|
||||||
|
<th>Description</th>
|
||||||
|
@if (meta.scope === 'BRANCH') {
|
||||||
|
<th>Branch</th>
|
||||||
|
}
|
||||||
|
<th>Date</th>
|
||||||
|
<th class="num">{{ meta.sideALabel }}</th>
|
||||||
|
<th class="num">{{ meta.sideBLabel }}</th>
|
||||||
|
@if (meta.extraLabel) {
|
||||||
|
<th class="num">{{ meta.extraLabel }}</th>
|
||||||
|
}
|
||||||
|
<th class="num">Difference</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Remarks / Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (item of paged(items); track item.id) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span class="pg-chip">{{ item.reference }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ item.description }}</td>
|
||||||
|
@if (meta.scope === 'BRANCH') {
|
||||||
|
<td>{{ item.branchName }}</td>
|
||||||
|
}
|
||||||
|
<td>{{ item.date }}</td>
|
||||||
|
<td class="num">₹{{ item.sideAAmount | number: '1.0-0' }}</td>
|
||||||
|
<td class="num">₹{{ item.sideBAmount | number: '1.0-0' }}</td>
|
||||||
|
@if (meta.extraLabel) {
|
||||||
|
<td class="num">₹{{ item.extraAmount | number: '1.0-0' }}</td>
|
||||||
|
}
|
||||||
|
<td class="num" [class.col-out]="diff(item) !== 0" [class.col-in]="diff(item) === 0">
|
||||||
|
₹{{ diff(item) | number: '1.0-0' }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span [class]="badgeClass(item.status)">{{ item.status }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if (isItemOpen(item)) {
|
||||||
|
<div class="recon-action-row">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="recon-remark-input"
|
||||||
|
[(ngModel)]="remarksDraft[item.id]"
|
||||||
|
placeholder="Remarks (optional)"
|
||||||
|
/>
|
||||||
|
<button type="button" class="pg-btn pg-btn--ghost" (click)="markReconciled(item)">
|
||||||
|
Mark as Reconciled
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
} @else if (item.status === 'RESOLVED') {
|
||||||
|
<span class="pg-sub">{{ item.remarks }}</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
[attr.colspan]="meta.scope === 'BRANCH' ? (meta.extraLabel ? 10 : 9) : meta.extraLabel ? 9 : 8"
|
||||||
|
class="pg-empty"
|
||||||
|
>
|
||||||
|
No {{ meta.label }} items for this branch.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
@if (items.length > pageSize) {
|
||||||
|
<div class="pg-pagination">
|
||||||
|
<span class="pg-pagination__info"
|
||||||
|
>Showing {{ rangeStart(items) }}–{{ rangeEnd(items) }} of {{ items.length }}</span
|
||||||
|
>
|
||||||
|
<div class="pg-pagination__controls">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--ghost"
|
||||||
|
[disabled]="currentPage === 1"
|
||||||
|
(click)="goToPage(currentPage - 1, items)"
|
||||||
|
>
|
||||||
|
‹ Prev
|
||||||
|
</button>
|
||||||
|
<span class="pg-pagination__page">Page {{ currentPage }} of {{ pageCount(items) }}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pg-btn pg-btn--ghost"
|
||||||
|
[disabled]="currentPage === pageCount(items)"
|
||||||
|
(click)="goToPage(currentPage + 1, items)"
|
||||||
|
>
|
||||||
|
Next ›
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (showExportModal) {
|
||||||
|
<div class="recon-modal-backdrop" (click)="closeExportModal()">
|
||||||
|
<div class="recon-modal" (click)="$event.stopPropagation()">
|
||||||
|
<div class="recon-modal__header">
|
||||||
|
<span>Export to Excel — Sign-off (Optional)</span>
|
||||||
|
<button type="button" class="recon-modal__close" (click)="closeExportModal()" aria-label="Close">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="pg-sub" style="padding: 0 20px">
|
||||||
|
Fill in Prepared / Checked / Approved details if this copy needs them for the record — or leave them blank and
|
||||||
|
download straight away.
|
||||||
|
</p>
|
||||||
|
<div class="recon-signoff-grid">
|
||||||
|
<div class="recon-signoff-col">
|
||||||
|
<span class="recon-signoff-role">Prepared By</span>
|
||||||
|
<input type="text" placeholder="Name" [(ngModel)]="exportSignOff.preparedByName" />
|
||||||
|
<input type="text" placeholder="Designation" [(ngModel)]="exportSignOff.preparedByDesg" />
|
||||||
|
</div>
|
||||||
|
<div class="recon-signoff-col">
|
||||||
|
<span class="recon-signoff-role">Checked By</span>
|
||||||
|
<input type="text" placeholder="Name" [(ngModel)]="exportSignOff.checkedByName" />
|
||||||
|
<input type="text" placeholder="Designation" [(ngModel)]="exportSignOff.checkedByDesg" />
|
||||||
|
</div>
|
||||||
|
<div class="recon-signoff-col">
|
||||||
|
<span class="recon-signoff-role">Approved By</span>
|
||||||
|
<input type="text" placeholder="Name" [(ngModel)]="exportSignOff.approvedByName" />
|
||||||
|
<input type="text" placeholder="Designation" [(ngModel)]="exportSignOff.approvedByDesg" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="inv-form-actions" style="padding: 0 20px 20px">
|
||||||
|
<button type="button" class="pg-btn pg-btn--ghost" (click)="closeExportModal()">Cancel</button>
|
||||||
|
<button type="button" class="pg-btn pg-btn--primary" (click)="confirmExport()">Download</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="inv-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
|
||||||
@@ -0,0 +1,418 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, inject, OnDestroy, OnInit } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||||
|
import { Subscription } from 'rxjs';
|
||||||
|
import * as ExcelJS from 'exceljs';
|
||||||
|
import { AuthenticationService } from '../../core/authentication/authentication.service';
|
||||||
|
import {
|
||||||
|
RECON_TYPES,
|
||||||
|
RECON_ITEMS,
|
||||||
|
BRANCHES,
|
||||||
|
GROUP_LABELS,
|
||||||
|
DEFAULT_RECON_ID,
|
||||||
|
ReconGroupId,
|
||||||
|
ReconTypeMeta,
|
||||||
|
ReconItem,
|
||||||
|
ReconStatus,
|
||||||
|
reconMeta,
|
||||||
|
difference,
|
||||||
|
isOpen,
|
||||||
|
totalSideA,
|
||||||
|
totalSideB,
|
||||||
|
totalDifference,
|
||||||
|
countByStatus,
|
||||||
|
matchRate,
|
||||||
|
todayDDMMYYYY
|
||||||
|
} from '../reconciliation-data';
|
||||||
|
|
||||||
|
/** Optional "Prepared / Checked / Approved" details captured in the Export to Excel popup —
|
||||||
|
* purely a one-off snapshot for that download, not a persisted workflow/sign-off state. */
|
||||||
|
interface ReconExportSignOff {
|
||||||
|
preparedByName: string;
|
||||||
|
preparedByDesg: string;
|
||||||
|
checkedByName: string;
|
||||||
|
checkedByDesg: string;
|
||||||
|
approvedByName: string;
|
||||||
|
approvedByDesg: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function blankExportSignOff(): ReconExportSignOff {
|
||||||
|
return {
|
||||||
|
preparedByName: '',
|
||||||
|
preparedByDesg: '',
|
||||||
|
checkedByName: '',
|
||||||
|
checkedByDesg: '',
|
||||||
|
approvedByName: '',
|
||||||
|
approvedByDesg: ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The "Reconciliation" mega-menu — 16 screens across 5 groups (Core & Books / Bank & Clearing /
|
||||||
|
* Digital & Payments / Instruments & Members / Compliance & Financials) — collapsed into one
|
||||||
|
* route-param-driven component, mirroring the NPA module's pattern. Every screen shares the same
|
||||||
|
* generic two-sided (Side A vs Side B) reconciliation model in reconciliation-data.ts, and the
|
||||||
|
* same Head-Office-vs-branch access split used by NPA and Cash/Clearing/Transfer: activity that
|
||||||
|
* genuinely happens at a branch (vouchers, cheques, digital transactions, share members, fixed
|
||||||
|
* assets) is branch-scoped; activity with no branch dimension in real life (bank-wide P&L/GL
|
||||||
|
* control, treasury's own bank accounts, statutory tax filing, vendor procurement, inter-branch
|
||||||
|
* consolidation) is Head-Office-only.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-reconciliation-report',
|
||||||
|
templateUrl: './reconciliation-report.component.html',
|
||||||
|
styleUrls: [
|
||||||
|
'../../cashier/cashier-shared.scss',
|
||||||
|
'../../investment/investment-shared.scss',
|
||||||
|
'../../npa/npa-shared.scss',
|
||||||
|
'../reconciliation-shared.scss'
|
||||||
|
],
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
RouterModule
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class ReconciliationReportComponent implements OnInit, OnDestroy {
|
||||||
|
readonly reportMeta = RECON_TYPES;
|
||||||
|
readonly groupLabels = GROUP_LABELS;
|
||||||
|
readonly branches = BRANCHES;
|
||||||
|
readonly groups: ReconGroupId[] = [
|
||||||
|
'core',
|
||||||
|
'bank',
|
||||||
|
'digital',
|
||||||
|
'instruments',
|
||||||
|
'compliance'
|
||||||
|
];
|
||||||
|
asOnDate = todayDDMMYYYY();
|
||||||
|
|
||||||
|
activeGroup: ReconGroupId = 'core';
|
||||||
|
activeReport = DEFAULT_RECON_ID;
|
||||||
|
|
||||||
|
isHeadOffice = false;
|
||||||
|
currentOfficeName = 'Branch Office';
|
||||||
|
private ownBranchCode: string | null = null;
|
||||||
|
|
||||||
|
/** '' = All Branches (Head Office consolidated view); otherwise a BranchMaster.code. */
|
||||||
|
branchFilter = '';
|
||||||
|
|
||||||
|
readonly pageSize = 20;
|
||||||
|
currentPage = 1;
|
||||||
|
|
||||||
|
remarksDraft: Record<string, string> = {};
|
||||||
|
|
||||||
|
generating = false;
|
||||||
|
showExportModal = false;
|
||||||
|
exportSignOff: ReconExportSignOff = blankExportSignOff();
|
||||||
|
|
||||||
|
toastMsg = '';
|
||||||
|
toastVisible = false;
|
||||||
|
private toastTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
private sub?: Subscription;
|
||||||
|
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
private readonly auth = inject(AuthenticationService);
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.sub = this.route.paramMap.subscribe((params) => {
|
||||||
|
const id = params.get('reportId') || DEFAULT_RECON_ID;
|
||||||
|
const meta = this.reportMeta.find((m) => m.id === id) || this.reportMeta[0];
|
||||||
|
this.activeReport = meta.id;
|
||||||
|
this.activeGroup = meta.group;
|
||||||
|
this.currentPage = 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
const creds = this.auth.getCredentials();
|
||||||
|
this.isHeadOffice = this.auth.isHeadOfficeUser();
|
||||||
|
this.currentOfficeName = creds?.officeName || 'Branch Office';
|
||||||
|
if (this.isHeadOffice) {
|
||||||
|
this.branchFilter = '';
|
||||||
|
} else {
|
||||||
|
this.ownBranchCode =
|
||||||
|
this.branches.find((b) => this.currentOfficeName.toLowerCase().includes(b.name.toLowerCase()))?.code || null;
|
||||||
|
this.branchFilter = this.ownBranchCode || this.branches[0].code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.sub?.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
goToReport(reportId: string): void {
|
||||||
|
this.router.navigate([
|
||||||
|
'/reconciliation',
|
||||||
|
reportId
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
groupOf(group: ReconGroupId): ReconTypeMeta[] {
|
||||||
|
return this.reportMeta.filter((m) => m.group === group);
|
||||||
|
}
|
||||||
|
|
||||||
|
setBranch(code: string): void {
|
||||||
|
this.branchFilter = code;
|
||||||
|
this.currentPage = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
get meta(): ReconTypeMeta {
|
||||||
|
return reconMeta(this.activeReport);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when a branch-level login has opened a Head-Office-only reconciliation type. */
|
||||||
|
get isRestricted(): boolean {
|
||||||
|
return this.meta.scope === 'HEAD_OFFICE' && !this.isHeadOffice;
|
||||||
|
}
|
||||||
|
|
||||||
|
get scopeName(): string {
|
||||||
|
if (this.meta.scope === 'HEAD_OFFICE') {
|
||||||
|
return 'Head Office';
|
||||||
|
}
|
||||||
|
if (!this.isHeadOffice) {
|
||||||
|
return this.currentOfficeName;
|
||||||
|
}
|
||||||
|
return this.branchFilter ? this.branches.find((b) => b.code === this.branchFilter)?.name || '' : 'All Branches';
|
||||||
|
}
|
||||||
|
|
||||||
|
get items(): ReconItem[] {
|
||||||
|
const all = RECON_ITEMS.filter((i) => i.reconTypeId === this.activeReport);
|
||||||
|
if (this.meta.scope !== 'BRANCH' || !this.branchFilter) {
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
return all.filter((i) => i.branchCode === this.branchFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
diff(item: ReconItem): number {
|
||||||
|
return difference(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
isItemOpen(item: ReconItem): boolean {
|
||||||
|
return isOpen(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
badgeClass(status: ReconStatus): string {
|
||||||
|
return `recon-badge recon-badge--${status.toLowerCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------- KPIs (always over the full `items` list, never the paged slice) --------------------
|
||||||
|
get totalA(): number {
|
||||||
|
return totalSideA(this.items);
|
||||||
|
}
|
||||||
|
get totalB(): number {
|
||||||
|
return totalSideB(this.items);
|
||||||
|
}
|
||||||
|
get totalDiff(): number {
|
||||||
|
return totalDifference(this.items);
|
||||||
|
}
|
||||||
|
get matchedCount(): number {
|
||||||
|
return countByStatus(this.items, 'MATCHED');
|
||||||
|
}
|
||||||
|
get unmatchedCount(): number {
|
||||||
|
return countByStatus(this.items, 'UNMATCHED');
|
||||||
|
}
|
||||||
|
get pendingCount(): number {
|
||||||
|
return countByStatus(this.items, 'PENDING');
|
||||||
|
}
|
||||||
|
get resolvedCount(): number {
|
||||||
|
return countByStatus(this.items, 'RESOLVED');
|
||||||
|
}
|
||||||
|
get matchPct(): number {
|
||||||
|
return matchRate(this.items);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------- Pagination --------------------
|
||||||
|
paged<T>(list: T[]): T[] {
|
||||||
|
const start = (this.currentPage - 1) * this.pageSize;
|
||||||
|
return list.slice(start, start + this.pageSize);
|
||||||
|
}
|
||||||
|
pageCount(list: unknown[]): number {
|
||||||
|
return Math.max(1, Math.ceil(list.length / this.pageSize));
|
||||||
|
}
|
||||||
|
rangeStart(list: unknown[]): number {
|
||||||
|
return list.length === 0 ? 0 : (this.currentPage - 1) * this.pageSize + 1;
|
||||||
|
}
|
||||||
|
rangeEnd(list: unknown[]): number {
|
||||||
|
return Math.min(this.currentPage * this.pageSize, list.length);
|
||||||
|
}
|
||||||
|
goToPage(page: number, list: unknown[]): void {
|
||||||
|
this.currentPage = Math.min(Math.max(1, page), this.pageCount(list));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------- Actions --------------------
|
||||||
|
markReconciled(item: ReconItem): void {
|
||||||
|
const remark = (this.remarksDraft[item.id] || '').trim();
|
||||||
|
item.status = 'RESOLVED';
|
||||||
|
item.remarks = remark || 'Reconciled — difference verified and squared off.';
|
||||||
|
delete this.remarksDraft[item.id];
|
||||||
|
this.toast(`${this.meta.referenceLabel} ${item.reference} marked as reconciled.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
generate(): void {
|
||||||
|
this.currentPage = 1;
|
||||||
|
this.toast(`${this.meta.label} refreshed as on ${this.asOnDate}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------- Export to Excel --------------------
|
||||||
|
/** "Export to Excel" opens this popup first — Prepared/Checked/Approved details are entirely
|
||||||
|
* optional; leaving them blank and clicking Download exports immediately with no sign-off
|
||||||
|
* rows added. */
|
||||||
|
openExportModal(): void {
|
||||||
|
this.exportSignOff = blankExportSignOff();
|
||||||
|
this.showExportModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeExportModal(): void {
|
||||||
|
this.showExportModal = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmExport(): void {
|
||||||
|
this.showExportModal = false;
|
||||||
|
void this.exportExcel();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strips characters Excel forbids in worksheet names (\ / ? * [ ]) and caps the length. */
|
||||||
|
private safeSheetName(name: string): string {
|
||||||
|
return name.replace(/[\\/?*[\]]/g, '-').slice(0, 31);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async exportExcel(): Promise<void> {
|
||||||
|
this.generating = true;
|
||||||
|
try {
|
||||||
|
const wb = new ExcelJS.Workbook();
|
||||||
|
wb.creator = 'CBSNexora';
|
||||||
|
wb.created = new Date();
|
||||||
|
// Excel worksheet names can't contain \ / ? * [ ] or exceed 31 chars — several report
|
||||||
|
// labels here have a "/" (e.g. "NEFT / RTGS Reconciliation"), which made addWorksheet()
|
||||||
|
// throw and silently abort the whole export.
|
||||||
|
const sheet = wb.addWorksheet(this.safeSheetName(this.meta.label));
|
||||||
|
|
||||||
|
const headers = [
|
||||||
|
this.meta.referenceLabel,
|
||||||
|
'Description',
|
||||||
|
...(this.meta.scope === 'BRANCH' ? ['Branch'] : []),
|
||||||
|
'Date',
|
||||||
|
this.meta.sideALabel,
|
||||||
|
this.meta.sideBLabel,
|
||||||
|
...(this.meta.extraLabel ? [this.meta.extraLabel] : []),
|
||||||
|
'Difference',
|
||||||
|
'Status',
|
||||||
|
'Remarks'
|
||||||
|
];
|
||||||
|
const widths = [
|
||||||
|
18,
|
||||||
|
34,
|
||||||
|
...(this.meta.scope === 'BRANCH' ? [16] : []),
|
||||||
|
12,
|
||||||
|
20,
|
||||||
|
20,
|
||||||
|
...(this.meta.extraLabel ? [20] : []),
|
||||||
|
16,
|
||||||
|
14,
|
||||||
|
30
|
||||||
|
];
|
||||||
|
sheet.columns = widths.map((w) => ({ width: w }));
|
||||||
|
|
||||||
|
const title = sheet.addRow([this.meta.label]);
|
||||||
|
sheet.mergeCells(title.number, 1, title.number, headers.length);
|
||||||
|
title.font = { bold: true, size: 14, color: { argb: 'FFFFFFFF' } };
|
||||||
|
title.alignment = { horizontal: 'center', vertical: 'middle' };
|
||||||
|
title.height = 24;
|
||||||
|
title.getCell(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF0F4F3C' } };
|
||||||
|
|
||||||
|
const meta = sheet.addRow([`Scope: ${this.scopeName} · As On: ${this.asOnDate}`]);
|
||||||
|
sheet.mergeCells(meta.number, 1, meta.number, headers.length);
|
||||||
|
meta.font = { italic: true, size: 10 };
|
||||||
|
sheet.addRow([]);
|
||||||
|
|
||||||
|
const head = sheet.addRow(headers);
|
||||||
|
head.font = { bold: true };
|
||||||
|
head.alignment = { wrapText: true, horizontal: 'center' };
|
||||||
|
head.eachCell((c) => {
|
||||||
|
c.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE2E8F0' } };
|
||||||
|
c.border = { bottom: { style: 'thin' } };
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const item of this.items) {
|
||||||
|
const row: (string | number | null)[] = [
|
||||||
|
item.reference,
|
||||||
|
item.description,
|
||||||
|
...(this.meta.scope === 'BRANCH' ? [item.branchName] : []),
|
||||||
|
item.date,
|
||||||
|
item.sideAAmount,
|
||||||
|
item.sideBAmount,
|
||||||
|
...(this.meta.extraLabel ? [item.extraAmount ?? null] : []),
|
||||||
|
this.diff(item),
|
||||||
|
item.status,
|
||||||
|
item.remarks
|
||||||
|
];
|
||||||
|
const excelRow = sheet.addRow(row);
|
||||||
|
row.forEach((v, idx) => {
|
||||||
|
if (typeof v === 'number') {
|
||||||
|
excelRow.getCell(idx + 1).numFmt = '#,##0.00';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
sheet.views = [{ state: 'frozen', ySplit: 4 }];
|
||||||
|
|
||||||
|
const so = this.exportSignOff;
|
||||||
|
const hasSignOff = [
|
||||||
|
so.preparedByName,
|
||||||
|
so.checkedByName,
|
||||||
|
so.approvedByName
|
||||||
|
].some((v) => v.trim());
|
||||||
|
if (hasSignOff) {
|
||||||
|
sheet.addRow([]);
|
||||||
|
sheet.addRow([]);
|
||||||
|
const signHead = sheet.addRow([
|
||||||
|
'Prepared By',
|
||||||
|
so.preparedByName ? `${so.preparedByName}${so.preparedByDesg ? ' (' + so.preparedByDesg + ')' : ''}` : '—',
|
||||||
|
'',
|
||||||
|
'Checked By',
|
||||||
|
so.checkedByName ? `${so.checkedByName}${so.checkedByDesg ? ' (' + so.checkedByDesg + ')' : ''}` : '—',
|
||||||
|
'',
|
||||||
|
'Approved By',
|
||||||
|
so.approvedByName ? `${so.approvedByName}${so.approvedByDesg ? ' (' + so.approvedByDesg + ')' : ''}` : '—'
|
||||||
|
]);
|
||||||
|
signHead.font = { bold: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = await wb.xlsx.writeBuffer();
|
||||||
|
const blob = new Blob([buffer], {
|
||||||
|
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||||
|
});
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${this.meta.label.replace(/[^\w]+/g, '-')}-${this.scopeName.replace(/[^\w]+/g, '-')}-${this.asOnDate}.xlsx`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
this.toast(`${this.meta.label} exported (${this.items.length} rows).`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Reconciliation export failed', err);
|
||||||
|
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,41 @@
|
|||||||
|
/**
|
||||||
|
* 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 { NgModule } from '@angular/core';
|
||||||
|
import { Routes, RouterModule } from '@angular/router';
|
||||||
|
|
||||||
|
import { Route } from '../core/route/route.service';
|
||||||
|
import { ReconciliationReportComponent } from './reconciliation-report/reconciliation-report.component';
|
||||||
|
import { DEFAULT_RECON_ID } from './reconciliation-data';
|
||||||
|
|
||||||
|
const routes: Routes = [
|
||||||
|
Route.withShell([
|
||||||
|
{
|
||||||
|
path: 'reconciliation',
|
||||||
|
data: { title: 'Reconciliation', breadcrumb: 'Reconciliation' },
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
redirectTo: DEFAULT_RECON_ID,
|
||||||
|
pathMatch: 'full'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ':reportId',
|
||||||
|
component: ReconciliationReportComponent,
|
||||||
|
data: { title: 'Reconciliation', breadcrumb: 'Reconciliation' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
])
|
||||||
|
];
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
imports: [RouterModule.forChild(routes)],
|
||||||
|
exports: [RouterModule]
|
||||||
|
})
|
||||||
|
export class ReconciliationRoutingModule {}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Reconciliation-status colouring layered on top of ../cashier/cashier-shared.scss (pg-*,
|
||||||
|
// cctr-*), ../investment/investment-shared.scss (inv-kpis, inv-form-*, inv-toast) and
|
||||||
|
// ../npa/npa-shared.scss (pg-pagination) — reused as-is since they're generic primitives.
|
||||||
|
|
||||||
|
.recon-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&--matched {
|
||||||
|
background: #f0fdf4;
|
||||||
|
color: #15803d;
|
||||||
|
border: 1px solid #bbf7d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--unmatched {
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #dc2626;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--pending {
|
||||||
|
background: #fefce8;
|
||||||
|
color: #a16207;
|
||||||
|
border: 1px solid #fde68a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--resolved {
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #1d4ed8;
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.recon-scope-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #475569;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recon-action-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recon-remark-input {
|
||||||
|
height: 32px;
|
||||||
|
min-width: 160px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: inherit;
|
||||||
|
color: #0d1b2a;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recon-signoff-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recon-signoff-col {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #f8fafc;
|
||||||
|
|
||||||
|
input {
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
color: #0d1b2a;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.recon-signoff-role {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: #0f4f3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recon-modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgb(15 23 42 / 55%);
|
||||||
|
z-index: 2000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recon-modal {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 820px;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 20px 60px rgb(0 0 0 / 30%);
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 20px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__close {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1;
|
||||||
|
color: #64748b;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 4px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.recon-restricted {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 56px 24px;
|
||||||
|
text-align: center;
|
||||||
|
color: #64748b;
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
font-size: 28px;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__body {
|
||||||
|
max-width: 440px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* 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 { NgModule } from '@angular/core';
|
||||||
|
|
||||||
|
import { SharedModule } from '../shared/shared.module';
|
||||||
|
import { ReconciliationRoutingModule } from './reconciliation-routing.module';
|
||||||
|
import { ReconciliationReportComponent } from './reconciliation-report/reconciliation-report.component';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
imports: [
|
||||||
|
SharedModule,
|
||||||
|
ReconciliationRoutingModule,
|
||||||
|
ReconciliationReportComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class ReconciliationModule {}
|
||||||
@@ -43,6 +43,22 @@ const routes: Routes = [
|
|||||||
data: { title: 'Sanctions Logs', breadcrumb: 'Sanctions Logs' },
|
data: { title: 'Sanctions Logs', breadcrumb: 'Sanctions Logs' },
|
||||||
component: SanctionsLogsComponent
|
component: SanctionsLogsComponent
|
||||||
},
|
},
|
||||||
|
// Must sit above ':filter', which would otherwise match this path and
|
||||||
|
// render an empty report category instead.
|
||||||
|
{
|
||||||
|
path: 'form-97-98',
|
||||||
|
data: { title: 'Form 97 / 98 Transaction Report', breadcrumb: 'Form 97 / 98 Transaction Report' },
|
||||||
|
loadComponent: () =>
|
||||||
|
import('../form-declarations/form-declaration-report.component').then(
|
||||||
|
(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')}` };
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
faCalendar,
|
faCalendar,
|
||||||
faCalendarAlt,
|
faCalendarAlt,
|
||||||
faCaretDown,
|
faCaretDown,
|
||||||
|
faCaretRight,
|
||||||
faChartBar,
|
faChartBar,
|
||||||
faCheck,
|
faCheck,
|
||||||
faCheckCircle,
|
faCheckCircle,
|
||||||
@@ -52,6 +53,7 @@ import {
|
|||||||
faFileUpload,
|
faFileUpload,
|
||||||
faFileWord,
|
faFileWord,
|
||||||
faFillDrip,
|
faFillDrip,
|
||||||
|
faGavel,
|
||||||
faGlobe,
|
faGlobe,
|
||||||
faHandHoldingUsd,
|
faHandHoldingUsd,
|
||||||
faHome,
|
faHome,
|
||||||
@@ -261,6 +263,7 @@ export class IconsModule {
|
|||||||
faCalendarAlt,
|
faCalendarAlt,
|
||||||
faCalendarCheck,
|
faCalendarCheck,
|
||||||
faCaretDown,
|
faCaretDown,
|
||||||
|
faCaretRight,
|
||||||
faChartBar,
|
faChartBar,
|
||||||
faCheck,
|
faCheck,
|
||||||
faCheckCircle,
|
faCheckCircle,
|
||||||
@@ -295,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': 'bd753f49'
|
'hash': 'b43ed877'
|
||||||
},
|
},
|
||||||
'allow_switching_backend_instance': true
|
'allow_switching_backend_instance': true
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user