Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14c5f5070d | ||
|
|
fcf6665772 | ||
|
|
f72abb0921 | ||
|
|
a1a50ab8a3 | ||
|
|
858c277ae3 | ||
|
|
a5124ca3e8 | ||
|
|
1a33f9d6ce | ||
|
|
983c613b8e | ||
|
|
ddb215decf | ||
|
|
2cc8d979ba | ||
|
|
569f17ef46 | ||
|
|
cc5cd4c6ab | ||
|
|
504d7ee7f6 | ||
|
|
26091c68b0 | ||
|
|
f8c532f74a | ||
|
|
44b41e504a | ||
|
|
e8d91c8f60 | ||
|
|
8dd9767bdf | ||
|
|
c868beb344 | ||
|
|
b43ed87771 | ||
|
|
ebe67cc410 | ||
|
|
d7f5bfe5f5 | ||
|
|
be6103e0b3 | ||
|
|
5cd9998cfc | ||
|
|
0ca6842bde | ||
|
|
3030e2f34e | ||
|
|
b4608cbbe4 | ||
|
|
27cdaf7fc2 | ||
|
|
90beea64cb | ||
|
|
1317a93838 | ||
|
|
0832017508 | ||
|
|
8ac4010967 | ||
|
|
1c125d97c5 | ||
|
|
c935e2a62a | ||
|
|
2a9df828ca | ||
|
|
bd753f4983 | ||
|
|
4261f9659e | ||
|
|
63ac900e42 | ||
|
|
fd28788d23 | ||
|
|
b9b37397fe | ||
|
|
f2525080c8 | ||
|
|
a982083b14 | ||
|
|
43e3c546a7 | ||
|
|
b68b25f789 | ||
|
|
1dc378c2dd |
+2
-2
@@ -69,8 +69,8 @@
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "13mb",
|
||||
"maximumError": "14mb"
|
||||
"maximumWarning": "15mb",
|
||||
"maximumError": "17mb"
|
||||
},
|
||||
{
|
||||
"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>
|
||||
|
||||
@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">
|
||||
<mat-table [dataSource]="dataSource" matSort class="gl-mat-table">
|
||||
<!-- #ID -->
|
||||
@@ -692,6 +700,50 @@
|
||||
</mat-cell>
|
||||
</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 -->
|
||||
<ng-container matColumnDef="actions">
|
||||
<mat-header-cell *matHeaderCellDef class="gl-th"></mat-header-cell>
|
||||
|
||||
@@ -1328,6 +1328,17 @@
|
||||
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 {
|
||||
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 ───────────────────────────────────────────────────────────────
|
||||
.gl-empty {
|
||||
display: flex;
|
||||
|
||||
@@ -41,9 +41,20 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
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';
|
||||
|
||||
/** 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({
|
||||
selector: 'mifosx-search-journal-entry',
|
||||
standalone: true,
|
||||
@@ -87,6 +98,8 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView
|
||||
private settingsService = inject(SettingsService);
|
||||
private dateUtils = inject(Dates);
|
||||
private route = inject(ActivatedRoute);
|
||||
private amlService = inject(AmlService);
|
||||
private dialog = inject(MatDialog);
|
||||
|
||||
// ── Misc ──────────────────────────────────────────────────────────────────
|
||||
minDate = new Date(2000, 0, 1);
|
||||
@@ -129,6 +142,7 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView
|
||||
'manualEntry',
|
||||
'createdByUserName',
|
||||
'submittedOnDate',
|
||||
'amlMark',
|
||||
'actions'
|
||||
];
|
||||
dayWiseColumns = [
|
||||
@@ -157,6 +171,14 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView
|
||||
dayWiseData: any[] = [];
|
||||
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 ──────────────────────────────────────────────────────────────
|
||||
activeQuickFilter = '';
|
||||
activeFiltersCount = 0;
|
||||
@@ -212,6 +234,103 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView
|
||||
this.setFilteredOffices();
|
||||
this.getJournalEntries();
|
||||
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() {
|
||||
|
||||
@@ -34,6 +34,28 @@ export class AmlService {
|
||||
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) ═══
|
||||
|
||||
getStrCases(): Observable<any[]> {
|
||||
|
||||
@@ -64,7 +64,12 @@ import { BankCalendarModule } from './bank-calendar/bank-calendar.module';
|
||||
import { DeafModule } from './deaf/deaf.module';
|
||||
import { DicgcModule } from './dicgc/dicgc.module';
|
||||
import { FiuModule } from './fiu/fiu.module';
|
||||
import { GlReportsModule } from './gl-reports/gl-reports.module';
|
||||
import { SftModule } from './sft/sft.module';
|
||||
import { InvestmentModule } from './investment/investment.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 { LetterMgmtModule } from './letter-mgmt/letter-mgmt.module';
|
||||
import { AccountsModule } from './accounts/accounts.module';
|
||||
@@ -171,7 +176,12 @@ export function HttpLoaderFactory(http: HttpClient) {
|
||||
DeafModule,
|
||||
DicgcModule,
|
||||
FiuModule,
|
||||
GlReportsModule,
|
||||
SftModule,
|
||||
InvestmentModule,
|
||||
NpaModule,
|
||||
ReconciliationModule,
|
||||
LoanOriginationModule,
|
||||
DigitalModule,
|
||||
LetterMgmtModule,
|
||||
AccountsModule,
|
||||
|
||||
+292
@@ -0,0 +1,292 @@
|
||||
<!--
|
||||
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">SUB LEDGER · TRANSACTIONS · {{ scopeName | uppercase }}</div>
|
||||
<h1 class="pg-title">Cash / Clearing / Transfer - Summary Report</h1>
|
||||
<p class="pg-sub">
|
||||
Opening, credit, debit and closing position across Cash, Transfer, Clearing and Digital channels as on
|
||||
{{ asOnDate }}.
|
||||
</p>
|
||||
</div>
|
||||
<div class="pg-toolbar">
|
||||
@if (isHeadOffice) {
|
||||
<label class="pg-field">
|
||||
<span>Branch</span>
|
||||
<select class="pg-select" [ngModel]="selectedBranchCode" (ngModelChange)="setBranch($event)">
|
||||
<option value="ALL">All Branches (Consolidated)</option>
|
||||
@for (b of branches; track b.code) {
|
||||
<option [value]="b.code">{{ b.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
}
|
||||
<button type="button" class="pg-btn" [class.pg-btn--active]="showDetails" (click)="toggleDetails()">
|
||||
{{ showDetails ? 'Summary View' : 'Transaction Details' }}
|
||||
</button>
|
||||
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="generating" (click)="exportExcel()">
|
||||
{{ generating ? 'Exporting…' : 'Export to Excel' }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="cctr-grand-summary">
|
||||
<div class="cctr-grand-summary__item">
|
||||
<span class="cctr-grand-summary__label">Opening</span>
|
||||
<span class="cctr-grand-summary__value">₹{{ grandOpening | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="cctr-grand-summary__item">
|
||||
<span class="cctr-grand-summary__label">Credit</span>
|
||||
<span class="cctr-grand-summary__value col-in">₹{{ grandCredit | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="cctr-grand-summary__item">
|
||||
<span class="cctr-grand-summary__label">Debit</span>
|
||||
<span class="cctr-grand-summary__value col-out">₹{{ grandDebit | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="cctr-grand-summary__item">
|
||||
<span class="cctr-grand-summary__label">Closing</span>
|
||||
<span class="cctr-grand-summary__value">₹{{ grandClosing | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (isHeadOffice && selectedBranchCode === 'ALL') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Branch-wise Break-up — click a branch to drill in</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Branch</th>
|
||||
<th class="num">Opening</th>
|
||||
<th class="num col-in">Credit</th>
|
||||
<th class="num col-out">Debit</th>
|
||||
<th class="num col-net">Closing</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (b of branchBreakup; track b.code) {
|
||||
<tr class="cctr-branch-row" (click)="setBranch(b.code)">
|
||||
<td>{{ b.name }}</td>
|
||||
<td class="num">₹{{ b.opening | number: '1.2-2' }}</td>
|
||||
<td class="num col-in">₹{{ b.credit | number: '1.2-2' }}</td>
|
||||
<td class="num col-out">₹{{ b.debit | number: '1.2-2' }}</td>
|
||||
<td class="num col-net">₹{{ b.closing | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="cctr-total-row">
|
||||
<td>All Branches</td>
|
||||
<td class="num">₹{{ grandOpening | number: '1.2-2' }}</td>
|
||||
<td class="num">₹{{ grandCredit | number: '1.2-2' }}</td>
|
||||
<td class="num">₹{{ grandDebit | number: '1.2-2' }}</td>
|
||||
<td class="num">₹{{ grandClosing | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="cctr-tabs">
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeTab === 'cash'"
|
||||
(click)="setTab('cash')"
|
||||
>
|
||||
Cash
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeTab === 'transfer'"
|
||||
(click)="setTab('transfer')"
|
||||
>
|
||||
Transfer
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeTab === 'clearing'"
|
||||
(click)="setTab('clearing')"
|
||||
>
|
||||
Clearing
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeTab === 'digital'"
|
||||
(click)="setTab('digital')"
|
||||
>
|
||||
Digital
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Summary (Opening | Credit | Debit | Closing) -->
|
||||
@if (!showDetails) {
|
||||
<div class="pg-card">
|
||||
@if (activeTab === 'digital') {
|
||||
<div class="pos-section-label">RTGS · NEFT · UPI · ATM · NACH — Overall Position</div>
|
||||
}
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@if (activeTab !== 'digital') {
|
||||
<th>GL Code</th>
|
||||
}
|
||||
<th>Particulars</th>
|
||||
<th class="num">Opening</th>
|
||||
<th class="num col-in">Credit</th>
|
||||
<th class="num col-out">Debit</th>
|
||||
<th class="num col-net">Closing</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of rowsForActiveTab; track r.code) {
|
||||
<tr>
|
||||
@if (activeTab !== 'digital') {
|
||||
<td>
|
||||
<span class="pg-chip">{{ r.code }}</span>
|
||||
</td>
|
||||
}
|
||||
<td>{{ r.name }}</td>
|
||||
<td class="num">₹{{ r.opening | number: '1.2-2' }}</td>
|
||||
<td class="num col-in">₹{{ rowCredit(r, txnsForActiveTab) | number: '1.2-2' }}</td>
|
||||
<td class="num col-out">₹{{ rowDebit(r, txnsForActiveTab) | number: '1.2-2' }}</td>
|
||||
<td class="num col-net">₹{{ closing(r, txnsForActiveTab) | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="cctr-total-row">
|
||||
<td [attr.colspan]="activeTab !== 'digital' ? 2 : 1">Total</td>
|
||||
<td class="num">₹{{ total(rowsForActiveTab, txnsForActiveTab, 'opening') | number: '1.2-2' }}</td>
|
||||
<td class="num">₹{{ total(rowsForActiveTab, txnsForActiveTab, 'credit') | number: '1.2-2' }}</td>
|
||||
<td class="num">₹{{ total(rowsForActiveTab, txnsForActiveTab, 'debit') | number: '1.2-2' }}</td>
|
||||
<td class="num">₹{{ totalClosing(rowsForActiveTab, txnsForActiveTab) | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Transaction Details (all txns for the selected category, optionally filtered by GL/channel) -->
|
||||
@if (showDetails) {
|
||||
@if (rowsForActiveTab.length > 1) {
|
||||
<div class="cctr-tabs">
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="glFilter === null"
|
||||
(click)="setGlFilter(null)"
|
||||
>
|
||||
All
|
||||
</button>
|
||||
@for (r of rowsForActiveTab; track r.code) {
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="glFilter === r.code"
|
||||
(click)="setGlFilter(r.code)"
|
||||
>
|
||||
{{ filterLabel(r) }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">
|
||||
{{
|
||||
activeTab === 'cash'
|
||||
? 'Cash'
|
||||
: activeTab === 'transfer'
|
||||
? 'Transfer'
|
||||
: activeTab === 'clearing'
|
||||
? 'Clearing'
|
||||
: 'Digital'
|
||||
}}
|
||||
— Transaction Details (Full Double Entry)
|
||||
@if (glFilter !== null) {
|
||||
· {{ glFilter }}
|
||||
}
|
||||
</div>
|
||||
<table class="pg-table cctr-journal">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Voucher / Ref No</th>
|
||||
<th>Dr / Cr</th>
|
||||
<th>GL / Account</th>
|
||||
<th class="num col-out">Debit</th>
|
||||
<th class="num col-in">Credit</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (t of filteredTxns; track t.voucherNo) {
|
||||
<tr class="cctr-journal-row cctr-journal-row--first">
|
||||
<td>{{ asOnDate }}</td>
|
||||
<td>
|
||||
<span class="pg-chip">{{ display(t.voucherNo) }}</span>
|
||||
</td>
|
||||
<td><span class="cctr-drcr cctr-drcr--dr">Dr</span></td>
|
||||
<td>
|
||||
@if (t.debit > 0) {
|
||||
<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 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) {
|
||||
<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 class="num"></td>
|
||||
<td class="num col-in">₹{{ legAmount(t) | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="7" class="pg-empty">No transactions for this filter.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="cctr-total-row">
|
||||
<td colspan="4">Total ({{ filteredTxns.length }} vouchers)</td>
|
||||
<td class="num">
|
||||
₹{{ 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>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
.cctr-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cctr-total-row {
|
||||
td {
|
||||
background: #f1f5f9;
|
||||
font-weight: 800;
|
||||
border-top: 2px solid #cbd5e1;
|
||||
border-bottom: none;
|
||||
color: #0f172a;
|
||||
}
|
||||
}
|
||||
|
||||
.cctr-grand-summary {
|
||||
display: flex;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
|
||||
&__item {
|
||||
flex: 1;
|
||||
padding: 14px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
border-right: 1px solid #e2e8f0;
|
||||
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #0f172a;
|
||||
}
|
||||
}
|
||||
|
||||
.cctr-subtext {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.cctr-branch-row {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover td {
|
||||
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;
|
||||
}
|
||||
+1802
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
@@ -179,6 +179,17 @@ const routes: Routes = [
|
||||
loadComponent: () =>
|
||||
import('./my-deposit-requests/my-deposit-requests.component').then((m) => m.MyDepositRequestsComponent),
|
||||
data: { title: 'My Deposit Requests', breadcrumb: 'My Deposit Requests' }
|
||||
},
|
||||
{
|
||||
path: 'cash-clearing-transfer-summary',
|
||||
loadComponent: () =>
|
||||
import('./cash-clearing-transfer-summary/cash-clearing-transfer-summary.component').then(
|
||||
(m) => m.CashClearingTransferSummaryComponent
|
||||
),
|
||||
data: {
|
||||
title: 'Cash / Clearing / Transfer - Summary Report',
|
||||
breadcrumb: 'Cash / Clearing / Transfer - Summary Report'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -12,16 +12,16 @@
|
||||
* have meaningful variety out of the box.
|
||||
*/
|
||||
export const SAMPLE_CKYC_FILE = `10|24401|IN4711|MH|30|31-08-2020|V1.1|||||
|
||||
20|1|01|01|||||||||||01|||01|10078|MR|RAVI|KUMAR|SHARMA|||||||01|MR|RAJESH|KUMAR|SHARMA||MRS|SUNITA|DEVI|||M|02|IN|B-01|10-08-1985|||||||||01|02|||||02|FLAT 402, SUNRISE TOWERS|MAROL ANDHERI EAST|MUMBAI|MUMBAI|MUMBAI|MH|IN|400069|01||Y|02|FLAT 402, SUNRISE TOWERS|MAROL ANDHERI EAST|MUMBAI|MUMBAI|MUMBAI|MH|IN|400069|01||||||||||||||||9892011001|||||31-08-2020|MUMBAI MAIN|31-08-2020|01|ANJALI MEHTA|ASSISTANT MANAGER|HEAD OFFICE|4|BHARAT URBAN BANK|IN4711|1|0||0|4||||||
|
||||
20|1|01|01|||||||||||01|||01|10078|MR|RAVI|KUMAR|SHARMA|||||||01|MR|RAJESH|KUMAR|SHARMA||MRS|SUNITA|DEVI|||M|02|IN|B-01|10-08-1985|||||||||01|02|||||02|FLAT 402, SUNRISE TOWERS|MAROL ANDHERI EAST|MUMBAI|MUMBAI|MUMBAI|MH|IN|400069|01||Y|02|FLAT 402, SUNRISE TOWERS|MAROL ANDHERI EAST|MUMBAI|MUMBAI|MUMBAI|MH|IN|400069|01||||||||||||||||9892011001|||||31-08-2020|MUMBAI MAIN|31-08-2020|01|ANJALI MEHTA|ASSISTANT MANAGER|HEAD OFFICE|4|CBSNEXORA BANK|IN4711|1|0||0|4||||||
|
||||
30|1|C|AAQPS8560J||01|02|||||
|
||||
70|1|10078_Sign.jpeg|09|01|01||||
|
||||
70|1|10078_Photo.jpeg|02|01|01||||
|
||||
70|1|10078_AP.jpg|04|01|01||||
|
||||
20|2|01|01|||||||||||01|||01|10079|MRS|PRIYA||PATEL|||||||01|MR|HARISH|PATEL|||MRS|MEENA|PATEL|||F|01|IN|O-04|22-03-1990|||||||||01|02|||||02|B-12, SHIVAJI NAGAR|FC ROAD|PUNE|PUNE|PUNE|MH|IN|411005|01||Y|02|B-12, SHIVAJI NAGAR|FC ROAD|PUNE|PUNE|PUNE|MH|IN|411005|01||||||||||||||||9822055002|||||31-08-2020|PUNE FC ROAD|31-08-2020|01|VIKRAM SINGH|BRANCH MANAGER|HEAD OFFICE|4|BHARAT URBAN BANK|IN4711|1|0||0|3||||||
|
||||
20|2|01|01|||||||||||01|||01|10079|MRS|PRIYA||PATEL|||||||01|MR|HARISH|PATEL|||MRS|MEENA|PATEL|||F|01|IN|O-04|22-03-1990|||||||||01|02|||||02|B-12, SHIVAJI NAGAR|FC ROAD|PUNE|PUNE|PUNE|MH|IN|411005|01||Y|02|B-12, SHIVAJI NAGAR|FC ROAD|PUNE|PUNE|PUNE|MH|IN|411005|01||||||||||||||||9822055002|||||31-08-2020|PUNE FC ROAD|31-08-2020|01|VIKRAM SINGH|BRANCH MANAGER|HEAD OFFICE|4|CBSNEXORA BANK|IN4711|1|0||0|3||||||
|
||||
30|2|E|XXXXXXXX2210||01|02|||||
|
||||
70|2|10079_Sign.jpeg|09|01|01||||
|
||||
70|2|10079_Photo.jpeg|02|01|01||||
|
||||
20|3|01|01|||||||||||01|||01|10080|MR|ARJUN||DESHMUKH|||||||01|MR|VIJAY|DESHMUKH|||MRS|REKHA|DESHMUKH|||M|01|IN|B-01|15-01-1978|||||||||01|02|||||02|HOUSE NO 7, DHARAMPETH|RAMDASPETH|NAGPUR|NAGPUR|NAGPUR|MH|IN|440010|01||Y|02|HOUSE NO 7, DHARAMPETH|RAMDASPETH|NAGPUR|NAGPUR|NAGPUR|MH|IN|440010|01||||||||||||||||9823033003|||||30-08-2020|NAGPUR DHARAMPETH|30-08-2020|01|RAHUL JOSHI|ASSISTANT MANAGER|HEAD OFFICE|4|BHARAT URBAN BANK|IN4711|1|0||0|3||||||
|
||||
20|3|01|01|||||||||||01|||01|10080|MR|ARJUN||DESHMUKH|||||||01|MR|VIJAY|DESHMUKH|||MRS|REKHA|DESHMUKH|||M|01|IN|B-01|15-01-1978|||||||||01|02|||||02|HOUSE NO 7, DHARAMPETH|RAMDASPETH|NAGPUR|NAGPUR|NAGPUR|MH|IN|440010|01||Y|02|HOUSE NO 7, DHARAMPETH|RAMDASPETH|NAGPUR|NAGPUR|NAGPUR|MH|IN|440010|01||||||||||||||||9823033003|||||30-08-2020|NAGPUR DHARAMPETH|30-08-2020|01|RAHUL JOSHI|ASSISTANT MANAGER|HEAD OFFICE|4|CBSNEXORA BANK|IN4711|1|0||0|3||||||
|
||||
30|3|C|AGTPD1144K||01|02|||||
|
||||
70|3|10080_Sign.jpeg|09|01|01||||
|
||||
70|3|10080_AP.jpg|04|01|01||||
|
||||
|
||||
+52
-6
@@ -533,6 +533,8 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
screeningLogId: number | null = null;
|
||||
/** Snapshot of the name that was last verified — used to invalidate verification when name changes */
|
||||
private verifiedName: string = '';
|
||||
/** True while prefillForEdit() is patching historical monthly/annual values — see linkMonthlyAnnual(). */
|
||||
private suppressMonthlyAnnualLink = false;
|
||||
|
||||
/**
|
||||
* @param {FormBuilder} formBuilder Form Builder
|
||||
@@ -709,6 +711,12 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
const data = this.clientData;
|
||||
this.loadExistingSeniorPwdDocuments();
|
||||
|
||||
// Loading an existing client's independently-saved monthly/annual values must not run
|
||||
// through the auto-calc in linkMonthlyAnnual() — patching monthlyIncome then annualIncome
|
||||
// (or vice versa) would otherwise have each overwrite the other via its valueChanges,
|
||||
// instead of keeping the exact historical values as saved.
|
||||
this.suppressMonthlyAnnualLink = true;
|
||||
|
||||
// Patch all base controls that exist on the form
|
||||
this.createClientForm.patchValue({
|
||||
officeId: data.officeId,
|
||||
@@ -743,6 +751,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
? new Date(data.timeline.submittedOnDate)
|
||||
: this.settingsService.businessDate
|
||||
});
|
||||
this.suppressMonthlyAnnualLink = false;
|
||||
|
||||
// Patch name fields (already created by buildDependencies via legalFormId subscription)
|
||||
const legalFormId = data.legalForm?.id || LegalFormId.PERSON;
|
||||
@@ -954,6 +963,43 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
Validators.required
|
||||
]
|
||||
});
|
||||
|
||||
this.linkMonthlyAnnual('monthlyIncome', 'annualIncome');
|
||||
this.linkMonthlyAnnual('monthlyTurnover', 'annualTurnover');
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps a Monthly/Annual pair of amount fields in sync at a 12x ratio — typing into either
|
||||
* one auto-fills the other. Uses { emitEvent: false } on the write side so the two
|
||||
* subscriptions don't trigger each other back and forth.
|
||||
*/
|
||||
/** Rounds to at most 2 decimals without padding whole numbers with a trailing ".00". */
|
||||
private round2(n: number): string {
|
||||
return (Math.round(n * 100) / 100).toString();
|
||||
}
|
||||
|
||||
private linkMonthlyAnnual(monthlyControlName: string, annualControlName: string): void {
|
||||
const monthly = this.createClientForm.get(monthlyControlName);
|
||||
const annual = this.createClientForm.get(annualControlName);
|
||||
if (!monthly || !annual) {
|
||||
return;
|
||||
}
|
||||
|
||||
monthly.valueChanges.subscribe((value) => {
|
||||
if (this.suppressMonthlyAnnualLink) {
|
||||
return;
|
||||
}
|
||||
const num = parseFloat(value);
|
||||
annual.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 12) : '', { emitEvent: false });
|
||||
});
|
||||
|
||||
annual.valueChanges.subscribe((value) => {
|
||||
if (this.suppressMonthlyAnnualLink) {
|
||||
return;
|
||||
}
|
||||
const num = parseFloat(value);
|
||||
monthly.setValue(!isNaN(num) && num >= 0 ? this.round2(num / 12) : '', { emitEvent: false });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2190,26 +2236,26 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
}
|
||||
|
||||
// Match monthly income ranges
|
||||
if (rule.minMonthlyIncome != null && monthlyIncome < rule.minMonthlyIncome) {
|
||||
if (rule.minMonthlyIncome != null && rule.minMonthlyIncome > 0 && monthlyIncome < rule.minMonthlyIncome) {
|
||||
continue;
|
||||
}
|
||||
if (rule.maxMonthlyIncome != null && monthlyIncome > rule.maxMonthlyIncome) {
|
||||
if (rule.maxMonthlyIncome != null && rule.maxMonthlyIncome > 0 && monthlyIncome > rule.maxMonthlyIncome) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Match annual income ranges (evaluated against totalAnnualIncome)
|
||||
if (rule.minAnnualIncome != null && totalAnnualIncome < rule.minAnnualIncome) {
|
||||
if (rule.minAnnualIncome != null && rule.minAnnualIncome > 0 && totalAnnualIncome < rule.minAnnualIncome) {
|
||||
continue;
|
||||
}
|
||||
if (rule.maxAnnualIncome != null && totalAnnualIncome > rule.maxAnnualIncome) {
|
||||
if (rule.maxAnnualIncome != null && rule.maxAnnualIncome > 0 && totalAnnualIncome > rule.maxAnnualIncome) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Match turnover ranges
|
||||
if (rule.minAnnualTurnover != null && annualTurnover < rule.minAnnualTurnover) {
|
||||
if (rule.minAnnualTurnover != null && rule.minAnnualTurnover > 0 && annualTurnover < rule.minAnnualTurnover) {
|
||||
continue;
|
||||
}
|
||||
if (rule.maxAnnualTurnover != null && annualTurnover > rule.maxAnnualTurnover) {
|
||||
if (rule.maxAnnualTurnover != null && rule.maxAnnualTurnover > 0 && annualTurnover > rule.maxAnnualTurnover) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -440,6 +440,18 @@ export class AuthenticationService {
|
||||
return roleNames.some((n) => n === 'cashier');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the logged-in user belongs to the Head Office.
|
||||
* Fineract seeds Head Office as officeId 1 with hierarchy "."; office name is
|
||||
* checked too since some deployments rename/reseed the root office.
|
||||
*/
|
||||
isHeadOfficeUser(): boolean {
|
||||
const creds = this.getCredentials();
|
||||
if (!creds) return false;
|
||||
if (creds.officeId === 1) return true;
|
||||
return (creds.officeName || '').toLowerCase().includes('head office');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the user credentials.
|
||||
*
|
||||
|
||||
@@ -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="caret-down" class="ni-caret"></fa-icon>
|
||||
</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'">
|
||||
<fa-icon icon="hand-holding-usd" class="ni-ico"></fa-icon>Advances
|
||||
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
||||
@@ -362,6 +366,10 @@
|
||||
<fa-icon icon="university" class="ni-ico"></fa-icon>RBI
|
||||
<fa-icon [icon]="rbiMenuOpen ? 'caret-up' : 'caret-down'" class="ni-caret"></fa-icon>
|
||||
</a>
|
||||
<a class="ni" tabindex="0" (click)="investmentMenuOpen = !investmentMenuOpen; rbiMenuOpen = false; glMenuOpen = false" *mifosxHasPermission="'MENU_RBI'">
|
||||
<fa-icon icon="chart-line" class="ni-ico"></fa-icon>Investment
|
||||
<fa-icon [icon]="investmentMenuOpen ? 'caret-up' : 'caret-down'" class="ni-caret"></fa-icon>
|
||||
</a>
|
||||
<a class="ni" tabindex="0" [matMenuTriggerFor]="digitalMenu" *mifosxHasPermission="'MENU_DIGITAL'">
|
||||
<fa-icon icon="mobile-alt" class="ni-ico"></fa-icon>Digital
|
||||
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
||||
@@ -438,6 +446,10 @@
|
||||
<fa-icon icon="exclamation-triangle" class="ni-ico"></fa-icon>NPA
|
||||
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
||||
</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"
|
||||
@@ -492,6 +504,15 @@
|
||||
<fa-icon icon="university" class="ni-ico"></fa-icon>RBI
|
||||
<fa-icon [icon]="rbiMenuOpen ? 'caret-up' : 'caret-down'" class="ni-caret"></fa-icon>
|
||||
</a>
|
||||
<a
|
||||
class="ni"
|
||||
tabindex="0"
|
||||
(click)="investmentMenuOpen = !investmentMenuOpen; rbiMenuOpen = false; glMenuOpen = false"
|
||||
*mifosxHasPermission="'MENU_RBI'"
|
||||
>
|
||||
<fa-icon icon="chart-line" class="ni-ico"></fa-icon>Investment
|
||||
<fa-icon [icon]="investmentMenuOpen ? 'caret-up' : 'caret-down'" class="ni-caret"></fa-icon>
|
||||
</a>
|
||||
<a class="ni" tabindex="0" [matMenuTriggerFor]="reportsMenu" *mifosxHasPermission="'MENU_REPORTS'">
|
||||
<fa-icon icon="chart-bar" class="ni-ico"></fa-icon>Reports
|
||||
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
||||
@@ -566,6 +587,13 @@
|
||||
<button mat-menu-item [routerLink]="['/reports', 'Accounting']" tabindex="0">
|
||||
<i class="fa fa-calculator menu-item-icon"></i> Accounting
|
||||
</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>
|
||||
|
||||
<!-- Admin Mega Menu — 3-column rectangular panel grouping Setup,
|
||||
@@ -653,6 +681,7 @@
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesSubLedgerSearch('Cash / Clearing / Transfer')"
|
||||
routerLink="/cashier/cash-clearing-transfer-summary"
|
||||
(click)="subLedgerOpen = false"
|
||||
><fa-icon icon="money-bill" class="gl-icon"></fa-icon> Cash / Clearing / Transfer</a
|
||||
>
|
||||
@@ -701,8 +730,11 @@
|
||||
</div>
|
||||
<div class="gl-group">
|
||||
<div class="gl-group-title">Deposits</div>
|
||||
<a class="gl-link" [class.hidden]="!matchesSubLedgerSearch('FD / RD Entry')" (click)="subLedgerOpen = false"
|
||||
><fa-icon icon="piggy-bank" class="gl-icon"></fa-icon> FD / RD Entry</a
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesSubLedgerSearch('FD / RD Entry')"
|
||||
(click)="subLedgerOpen = false; openFdRdEntryDialog()"
|
||||
><fa-icon icon="piggy-bank" class="gl-icon" style="color: #0f766e"></fa-icon> FD / RD Entry</a
|
||||
>
|
||||
<a class="gl-link" [class.hidden]="!matchesSubLedgerSearch('DDS Entry')" (click)="subLedgerOpen = false"
|
||||
><fa-icon icon="coins" class="gl-icon"></fa-icon> DDS Entry</a
|
||||
@@ -808,50 +840,72 @@
|
||||
<div class="gl-columns">
|
||||
<div class="gl-group">
|
||||
<div class="gl-group-title">Financial Statements</div>
|
||||
<a class="gl-link" [class.hidden]="!matchesGlSearch('Trial Balance')" (click)="glMenuOpen = false"
|
||||
><fa-icon icon="balance-scale" class="gl-icon"></fa-icon> Trial Balance</a
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesGlSearch('Trial Balance')"
|
||||
routerLink="/gl/reports/trial-balance"
|
||||
(click)="glMenuOpen = false"
|
||||
><fa-icon icon="balance-scale" class="gl-icon" style="color: #b45309"></fa-icon> Trial Balance</a
|
||||
>
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesGlSearch('Profit and Loss')"
|
||||
(click)="glMenuOpen = false; openAccountingReportsDialog('PNL')"
|
||||
routerLink="/gl/reports/profit-and-loss"
|
||||
(click)="glMenuOpen = false"
|
||||
><fa-icon icon="chart-line" class="gl-icon" style="color: #b45309"></fa-icon> Profit and Loss</a
|
||||
>
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesGlSearch('Consolidate Balance Sheet')"
|
||||
(click)="glMenuOpen = false; openAccountingReportsDialog('BALANCE-SHEET')"
|
||||
><fa-icon icon="file-alt" class="gl-icon" style="color: #b45309"></fa-icon> Consolidate Balance Sheet</a
|
||||
routerLink="/gl/reports/balance-sheet"
|
||||
(click)="glMenuOpen = false"
|
||||
><fa-icon icon="file-alt" class="gl-icon" style="color: #b45309"></fa-icon> Consolidated Balance Sheet</a
|
||||
>
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesGlSearch('Comparative Balance Sheet')"
|
||||
(click)="glMenuOpen = false; openAccountingReportsDialog('COMPARATIVE-BS')"
|
||||
routerLink="/gl/reports/comparative-balance-sheet"
|
||||
(click)="glMenuOpen = false"
|
||||
><fa-icon icon="balance-scale" class="gl-icon" style="color: #b45309"></fa-icon> Comparative Balance
|
||||
Sheet</a
|
||||
>
|
||||
</div>
|
||||
<div class="gl-group">
|
||||
<div class="gl-group-title">GL Reports</div>
|
||||
<a class="gl-link" [class.hidden]="!matchesGlSearch('GL Average Balance')" (click)="glMenuOpen = false"
|
||||
><fa-icon icon="calculator" class="gl-icon"></fa-icon> GL Average Balance</a
|
||||
<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"
|
||||
><fa-icon icon="project-diagram" class="gl-icon"></fa-icon> GL Mapping for Report</a
|
||||
<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
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesGlSearch('Account wise Balance List')"
|
||||
routerLink="/gl/reports/account-wise-balance"
|
||||
(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"
|
||||
><fa-icon icon="chart-pie" class="gl-icon"></fa-icon> Distribution of Balance</a
|
||||
<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
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesGlSearch('GL Wise DR CR')"
|
||||
(click)="glMenuOpen = false; openAccountingReportsDialog('GL-DR-CR')"
|
||||
routerLink="/gl/reports/gl-wise-dr-cr"
|
||||
(click)="glMenuOpen = false"
|
||||
><fa-icon icon="project-diagram" class="gl-icon" style="color: #b45309"></fa-icon> GL Wise DR / CR
|
||||
Report</a
|
||||
>
|
||||
@@ -861,28 +915,47 @@
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesGlSearch('Client wise Balance Report')"
|
||||
routerLink="/gl/reports/client-wise-balance"
|
||||
(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 class="gl-link" [class.hidden]="!matchesGlSearch('Contingent Expense')" (click)="glMenuOpen = false"
|
||||
><fa-icon icon="exclamation-circle" class="gl-icon"></fa-icon> Contingent Expense</a
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesGlSearch('Contingent Liabilities')"
|
||||
routerLink="/gl/reports/contingent-liabilities"
|
||||
(click)="glMenuOpen = false"
|
||||
><fa-icon icon="exclamation-circle" class="gl-icon" style="color: #b45309"></fa-icon> Contingent
|
||||
Liabilities</a
|
||||
>
|
||||
<a class="gl-link" [class.hidden]="!matchesGlSearch('Banker Head Report')" (click)="glMenuOpen = false"
|
||||
><fa-icon icon="university" class="gl-icon"></fa-icon> Banker Head Report</a
|
||||
<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 class="gl-group">
|
||||
<div class="gl-group-title">Registers</div>
|
||||
<a class="gl-link" [class.hidden]="!matchesGlSearch('Sundry Register')" (click)="glMenuOpen = false"
|
||||
><fa-icon icon="clipboard-list" class="gl-icon"></fa-icon> Sundry Register</a
|
||||
<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"
|
||||
><fa-icon icon="receipt" class="gl-icon"></fa-icon> Voucher Register</a
|
||||
<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
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesGlSearch('Misc Expense Ledger')"
|
||||
(click)="glMenuOpen = false; openAccountingReportsDialog('MISC-EXP')"
|
||||
routerLink="/gl/reports/misc-expense-ledger"
|
||||
(click)="glMenuOpen = false"
|
||||
><fa-icon icon="receipt" class="gl-icon" style="color: #b45309"></fa-icon> Misc Expense Ledger</a
|
||||
>
|
||||
</div>
|
||||
@@ -1155,7 +1228,7 @@
|
||||
<fa-icon icon="user-slash" class="menu-item-icon" style="color: #c2410c"></fa-icon> Non-Customer Entry
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0"><fa-icon icon="lock" class="menu-item-icon"></fa-icon> Locker</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item routerLink="/investment/bonds" tabindex="0">
|
||||
<fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> SLR and Non SLR Investment Report
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" (click)="openCicReportingDialog()">
|
||||
@@ -1171,13 +1244,13 @@
|
||||
<!-- Investment -->
|
||||
<div class="deposit-menu-group">
|
||||
<div class="deposit-group-title">Investment</div>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item routerLink="/investment/dashboard" tabindex="0">
|
||||
<fa-icon icon="calendar-alt" class="menu-item-icon"></fa-icon> Due Date Diary for All Investment
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item routerLink="/investment/reports" tabindex="0">
|
||||
<fa-icon icon="dollar-sign" class="menu-item-icon"></fa-icon> Total Int Received & Receivable Report
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item routerLink="/investment/reports" tabindex="0">
|
||||
<fa-icon icon="piggy-bank" class="menu-item-icon"></fa-icon> Bucket Wise Investment
|
||||
</button>
|
||||
<button mat-menu-item routerLink="/deaf" tabindex="0">
|
||||
@@ -1208,39 +1281,96 @@
|
||||
<!-- NPA Reports -->
|
||||
<div class="deposit-menu-group">
|
||||
<div class="deposit-group-title">NPA Reports</div>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> NPA Interest
|
||||
<button mat-menu-item tabindex="0" routerLink="/npa/reports/interest">
|
||||
<fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> NPA Report
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" routerLink="/npa/reports/status">
|
||||
<fa-icon icon="file-alt" class="menu-item-icon"></fa-icon> NPA Status Report
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" routerLink="/npa/reports/recovery">
|
||||
<fa-icon icon="undo" class="menu-item-icon"></fa-icon> NPA Recovery Report
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" routerLink="/npa/reports/summary">
|
||||
<fa-icon icon="clipboard-list" class="menu-item-icon"></fa-icon> NPA Summary Report
|
||||
</button>
|
||||
</div>
|
||||
<!-- NPA Analysis -->
|
||||
<div class="deposit-menu-group">
|
||||
<div class="deposit-group-title">NPA Analysis</div>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" routerLink="/npa/reports/account-detail">
|
||||
<fa-icon icon="list-alt" class="menu-item-icon"></fa-icon> NPA Account Detail Report
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" routerLink="/npa/reports/movement">
|
||||
<fa-icon icon="exchange" class="menu-item-icon"></fa-icon> NPA Movement Report
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<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
|
||||
</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>
|
||||
<!-- NPA Classification -->
|
||||
<div class="deposit-menu-group">
|
||||
<div class="deposit-group-title">NPA Classification</div>
|
||||
<button mat-menu-item tabindex="0">
|
||||
<button mat-menu-item tabindex="0" routerLink="/npa/reports/probable">
|
||||
<fa-icon icon="exclamation-circle" class="menu-item-icon"></fa-icon> Probable NPA
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0"><fa-icon icon="flag" class="menu-item-icon"></fa-icon> SMA Report</button>
|
||||
<button mat-menu-item tabindex="0" routerLink="/npa/reports/sma">
|
||||
<fa-icon icon="flag" class="menu-item-icon"></fa-icon> SMA Report
|
||||
</button>
|
||||
</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>
|
||||
</mat-menu>
|
||||
@@ -1273,27 +1403,27 @@
|
||||
<!-- Core / Books -->
|
||||
<div class="deposit-menu-group">
|
||||
<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>
|
||||
Voucher Reconciliation
|
||||
</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>
|
||||
Sundry Dr / Cr Reconciliation
|
||||
</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>
|
||||
P&L Impact Reconciliation
|
||||
</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>
|
||||
GL Reconciliation
|
||||
</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>
|
||||
Fixed Assets Reconciliation
|
||||
</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>
|
||||
Inter-Company Reconciliation
|
||||
</button>
|
||||
@@ -1302,19 +1432,19 @@
|
||||
<!-- Bank & Clearing -->
|
||||
<div class="deposit-menu-group">
|
||||
<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>
|
||||
Bank Reconciliation
|
||||
</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>
|
||||
API Based Bank Reconciliation
|
||||
</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>
|
||||
NEFT / RTGS Reconciliation
|
||||
</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>
|
||||
Clearing Reconciliation
|
||||
</button>
|
||||
@@ -1323,15 +1453,15 @@
|
||||
<!-- Digital / Payments -->
|
||||
<div class="deposit-menu-group">
|
||||
<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>
|
||||
UDIR (IMPS / UPI / ATM)
|
||||
</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>
|
||||
Three-way Digital Reconciliation
|
||||
</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>
|
||||
NACH Reconciliation
|
||||
</button>
|
||||
@@ -1344,7 +1474,7 @@
|
||||
<fa-icon icon="money-check-alt" class="menu-item-icon" style="color: #0d9488"></fa-icon>
|
||||
DD / Pay Order Reconciliation
|
||||
</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>
|
||||
Share Member Reconciliation
|
||||
</button>
|
||||
@@ -1353,11 +1483,11 @@
|
||||
<!-- Compliance & Financials -->
|
||||
<div class="deposit-menu-group">
|
||||
<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>
|
||||
Invoice Payment
|
||||
</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>
|
||||
Tax Reconciliation
|
||||
</button>
|
||||
@@ -1469,6 +1599,9 @@
|
||||
<button mat-menu-item tabindex="0">
|
||||
<fa-icon icon="file-signature" class="menu-item-icon" style="color: #15803d"></fa-icon> Form 98
|
||||
</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">
|
||||
<fa-icon icon="file-signature" class="menu-item-icon" style="color: #15803d"></fa-icon> Form 121
|
||||
</button>
|
||||
@@ -1673,28 +1806,41 @@
|
||||
>
|
||||
<fa-icon icon="shield-alt" class="gl-icon"></fa-icon> AML
|
||||
</a>
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesRbiSearch('Risk Review Report')"
|
||||
routerLink="/reports/risk-review"
|
||||
(click)="rbiMenuOpen = false"
|
||||
>
|
||||
<fa-icon icon="user-shield" class="gl-icon"></fa-icon> Risk Review Report
|
||||
</a>
|
||||
</div>
|
||||
<!-- OSS Reports -->
|
||||
<div class="gl-group">
|
||||
<div class="gl-group-title">OSS Reports</div>
|
||||
<a class="gl-link" [class.hidden]="!matchesRbiSearch('OSS 1 XBRL/CIMS')" (click)="rbiMenuOpen = false">
|
||||
<fa-icon icon="file-alt" class="gl-icon"></fa-icon> OSS 1 XBRL/CIMS
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesRbiSearch('OSS 1 XBRL/CIMS')"
|
||||
routerLink="/rbi/oss1"
|
||||
(click)="rbiMenuOpen = false"
|
||||
>
|
||||
<fa-icon icon="file-alt" class="gl-icon" style="color: #0891b2"></fa-icon> OSS 1 XBRL/CIMS
|
||||
</a>
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesRbiSearch('OSS 2 XBRL')"
|
||||
(click)="rbiMenuOpen = false; openCimsXbrlDialog('OSS-2')"
|
||||
routerLink="/rbi/oss2"
|
||||
(click)="rbiMenuOpen = false"
|
||||
>
|
||||
<fa-icon icon="file-alt" class="gl-icon" style="color: #0891b2"></fa-icon> OSS 2 XBRL
|
||||
</a>
|
||||
<a class="gl-link" [class.hidden]="!matchesRbiSearch('OSS 3 XBRL/CIMS')" (click)="rbiMenuOpen = false">
|
||||
<fa-icon icon="file-alt" class="gl-icon"></fa-icon> OSS 3 XBRL/CIMS
|
||||
</a>
|
||||
<a class="gl-link" [class.hidden]="!matchesRbiSearch('OSS 4/OSS 5/OSS 6')" (click)="rbiMenuOpen = false">
|
||||
<fa-icon icon="file-alt" class="gl-icon"></fa-icon> OSS 4 / OSS 5 / OSS 6
|
||||
</a>
|
||||
<a class="gl-link" [class.hidden]="!matchesRbiSearch('OSS 7')" (click)="rbiMenuOpen = false">
|
||||
<fa-icon icon="file-alt" class="gl-icon"></fa-icon> OSS 7
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesRbiSearch('OSS 3 XBRL/CIMS')"
|
||||
routerLink="/rbi/oss3"
|
||||
(click)="rbiMenuOpen = false"
|
||||
>
|
||||
<fa-icon icon="file-alt" class="gl-icon" style="color: #0891b2"></fa-icon> OSS 3 XBRL/CIMS
|
||||
</a>
|
||||
</div>
|
||||
<!-- CIMS / XBRL Returns -->
|
||||
@@ -1825,6 +1971,161 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Investment (Treasury) Mega Menu -->
|
||||
@if (investmentMenuOpen) {
|
||||
<div class="gl-backdrop" (click)="investmentMenuOpen = false"></div>
|
||||
<div class="gl-mega-menu rbi-mega-menu">
|
||||
<div class="gl-mega-menu-header">
|
||||
<fa-icon icon="chart-line" class="gl-mega-menu-header-icon"></fa-icon>
|
||||
<span>Investment</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
class="gl-search"
|
||||
placeholder="Search Investment..."
|
||||
(input)="investmentSearch = $any($event.target).value"
|
||||
/>
|
||||
<div class="rbi-columns">
|
||||
<!-- Dashboard -->
|
||||
<div class="gl-group">
|
||||
<div class="gl-group-title">Dashboard</div>
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesInvestmentSearch('Investment Dashboard')"
|
||||
routerLink="/investment/dashboard"
|
||||
(click)="investmentMenuOpen = false"
|
||||
>
|
||||
<fa-icon icon="chart-pie" class="gl-icon"></fa-icon> Portfolio Dashboard
|
||||
</a>
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesInvestmentSearch('Due Date Diary for All Investment')"
|
||||
routerLink="/investment/dashboard"
|
||||
(click)="investmentMenuOpen = false"
|
||||
>
|
||||
<fa-icon icon="calendar-check" class="gl-icon"></fa-icon> Due Date Diary for All Investment
|
||||
</a>
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesInvestmentSearch('Bucket Wise Investment')"
|
||||
routerLink="/investment/reports"
|
||||
(click)="investmentMenuOpen = false"
|
||||
>
|
||||
<fa-icon icon="layer-group" class="gl-icon"></fa-icon> Bucket Wise Investment
|
||||
</a>
|
||||
</div>
|
||||
<!-- Entry -->
|
||||
<div class="gl-group">
|
||||
<div class="gl-group-title">Entry</div>
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesInvestmentSearch('FD Investment')"
|
||||
routerLink="/investment/fd"
|
||||
(click)="investmentMenuOpen = false"
|
||||
>
|
||||
<fa-icon icon="piggy-bank" class="gl-icon"></fa-icon> FD Investment
|
||||
</a>
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesInvestmentSearch('Govt Security / Non-SLR / Bond / Mutual Fund')"
|
||||
routerLink="/investment/bonds"
|
||||
(click)="investmentMenuOpen = false"
|
||||
>
|
||||
<fa-icon icon="university" class="gl-icon"></fa-icon> Govt Security / Non-SLR / Bond / Mutual Fund
|
||||
</a>
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesInvestmentSearch('SLR and Non SLR Investment Report')"
|
||||
routerLink="/investment/bonds"
|
||||
(click)="investmentMenuOpen = false"
|
||||
>
|
||||
<fa-icon icon="balance-scale" class="gl-icon"></fa-icon> SLR and Non SLR Investment Report
|
||||
</a>
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesInvestmentSearch('Share Entry')"
|
||||
routerLink="/investment/share"
|
||||
(click)="investmentMenuOpen = false"
|
||||
>
|
||||
<fa-icon icon="chart-line" class="gl-icon"></fa-icon> Share Entry
|
||||
</a>
|
||||
</div>
|
||||
<!-- Masters -->
|
||||
<div class="gl-group">
|
||||
<div class="gl-group-title">Masters</div>
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="
|
||||
!matchesInvestmentSearch(
|
||||
'Bank Party Script Broker SubType Control Parameter Daily Script LIEN Reason Mutual Fund Product SGL Bank Script Market Detail Masters'
|
||||
)
|
||||
"
|
||||
routerLink="/investment/masters"
|
||||
(click)="investmentMenuOpen = false"
|
||||
>
|
||||
<fa-icon icon="cogs" class="gl-icon"></fa-icon> Bank/Party, Script, Broker & Other Masters
|
||||
</a>
|
||||
</div>
|
||||
<!-- Interest -->
|
||||
<div class="gl-group">
|
||||
<div class="gl-group-title">Interest</div>
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesInvestmentSearch('FD Interest Calculation')"
|
||||
routerLink="/investment/interest"
|
||||
(click)="investmentMenuOpen = false"
|
||||
>
|
||||
<fa-icon icon="calculator" class="gl-icon"></fa-icon> FD Interest Calculation
|
||||
</a>
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesInvestmentSearch('Govt Bond Interest Calculation')"
|
||||
routerLink="/investment/interest"
|
||||
(click)="investmentMenuOpen = false"
|
||||
>
|
||||
<fa-icon icon="calculator" class="gl-icon"></fa-icon> Govt./Bond Int. Calculation
|
||||
</a>
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesInvestmentSearch('Government Bond Int Register FD Interest Register FD Register')"
|
||||
routerLink="/investment/interest"
|
||||
(click)="investmentMenuOpen = false"
|
||||
>
|
||||
<fa-icon icon="list-alt" class="gl-icon"></fa-icon> Bond Int / FD Int / FD Registers
|
||||
</a>
|
||||
</div>
|
||||
<!-- Reports -->
|
||||
<div class="gl-group">
|
||||
<div class="gl-group-title">Reports</div>
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesInvestmentSearch('Balance Ledger Maturity Investment Reports')"
|
||||
routerLink="/investment/reports"
|
||||
(click)="investmentMenuOpen = false"
|
||||
>
|
||||
<fa-icon icon="file-invoice" class="gl-icon"></fa-icon> Balance / Ledger / Maturity Reports
|
||||
</a>
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesInvestmentSearch('Total Int Received & Receivable Report')"
|
||||
routerLink="/investment/reports"
|
||||
(click)="investmentMenuOpen = false"
|
||||
>
|
||||
<fa-icon icon="hand-holding-usd" class="gl-icon"></fa-icon> Total Int Received & Receivable Report
|
||||
</a>
|
||||
<a
|
||||
class="gl-link"
|
||||
[class.hidden]="!matchesInvestmentSearch('Investment Portfolio')"
|
||||
routerLink="/investment/reports"
|
||||
(click)="investmentMenuOpen = false"
|
||||
>
|
||||
<fa-icon icon="briefcase" class="gl-icon"></fa-icon> Investment Portfolio
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Progress Bar -->
|
||||
@if (progressBarMode !== 'none') {
|
||||
<div>
|
||||
|
||||
@@ -677,6 +677,26 @@ $gray-700: #334155;
|
||||
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 =====
|
||||
::ng-deep .nav-dropdown {
|
||||
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-grid {
|
||||
grid-template-columns: repeat(5, minmax(190px, 1fr));
|
||||
@@ -856,6 +883,20 @@ $gray-700: #334155;
|
||||
}
|
||||
}
|
||||
|
||||
// Marks a menu link as actually wired to a working screen/dialog (vs a still-unwired
|
||||
// placeholder) — a small green dot at the end of the row, so a reviewer can tell at a glance
|
||||
// which items in a mega-menu are functional without having to click every one of them.
|
||||
.gl-link--live::after {
|
||||
content: '';
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
background: #16a34a;
|
||||
box-shadow: 0 0 0 2px rgb(22 163 74 / 15%);
|
||||
}
|
||||
|
||||
/* RBI Mega Menu */
|
||||
.rbi-mega-menu {
|
||||
width: 1100px;
|
||||
|
||||
@@ -98,6 +98,10 @@ export class ShellComponent implements OnInit, OnDestroy {
|
||||
rbiMenuOpen = false;
|
||||
rbiSearch = '';
|
||||
|
||||
/** Investment (Treasury) mega menu state */
|
||||
investmentMenuOpen = false;
|
||||
investmentSearch = '';
|
||||
|
||||
/* Live KPI ticker — markup is static inside the template; the marquee
|
||||
animation, LIVE badge, and pause-on-hover handle the live feel. */
|
||||
|
||||
@@ -116,6 +120,11 @@ export class ShellComponent implements OnInit, OnDestroy {
|
||||
return label.toLowerCase().includes(this.rbiSearch.toLowerCase());
|
||||
}
|
||||
|
||||
matchesInvestmentSearch(label: string): boolean {
|
||||
if (!this.investmentSearch) return true;
|
||||
return label.toLowerCase().includes(this.investmentSearch.toLowerCase());
|
||||
}
|
||||
|
||||
/** Route for a facility entry in the Account Facilities hover dropdown — only "Cheque Book" has a
|
||||
* dedicated action page today; every other facility falls back to the existing overview page. */
|
||||
facilityRoute(label: string): string[] {
|
||||
@@ -242,7 +251,7 @@ export class ShellComponent implements OnInit, OnDestroy {
|
||||
* GL Wise DR/CR / Misc Expense Ledger). Initial report selected via menu link.
|
||||
*/
|
||||
async openAccountingReportsDialog(
|
||||
initialReport: 'PNL' | 'BALANCE-SHEET' | 'COMPARATIVE-BS' | 'GL-DR-CR' | 'MISC-EXP' = 'PNL'
|
||||
initialReport: 'PNL' | 'BALANCE-SHEET' | 'COMPARATIVE-BS' | 'CONTINGENT-LIAB' | 'GL-DR-CR' | 'MISC-EXP' = 'PNL'
|
||||
): Promise<void> {
|
||||
const { AccountingReportsDialogComponent } =
|
||||
await import('../../home/dashboard/accounting-reports-dialog/accounting-reports-dialog.component');
|
||||
@@ -285,7 +294,9 @@ export class ShellComponent implements OnInit, OnDestroy {
|
||||
* is selected based on the menu link the user clicked; user can switch between
|
||||
* forms via the form-pill selector inside the dialog.
|
||||
*/
|
||||
async openCimsXbrlDialog(initialForm: 'OSS-2' | 'PSA' | 'RAL' | 'RCP-UCB' = 'OSS-2'): Promise<void> {
|
||||
async openCimsXbrlDialog(
|
||||
initialForm: 'OSS-2' | 'PSA' | 'RAL' | 'RCP-UCB' | 'OSS-4' | 'OSS-5' | 'OSS-6' | 'OSS-7' = 'OSS-2'
|
||||
): Promise<void> {
|
||||
const { CimsXbrlDialogComponent } =
|
||||
await import('../../home/dashboard/cims-xbrl-dialog/cims-xbrl-dialog.component');
|
||||
this.dialog.open(CimsXbrlDialogComponent, {
|
||||
@@ -301,6 +312,26 @@ export class ShellComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
/** Opens the FD / RD Entry dialog under Sub Ledger → Deposits. */
|
||||
async openFdRdEntryDialog(): Promise<void> {
|
||||
const { FdRdEntryDialogComponent } =
|
||||
await import('../../home/dashboard/fd-rd-entry-dialog/fd-rd-entry-dialog.component');
|
||||
this.dialog.open(FdRdEntryDialogComponent, {
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
maxWidth: '100vw',
|
||||
maxHeight: '100vh',
|
||||
panelClass: [
|
||||
'crr-slr-dialog-panel',
|
||||
'fdrd-fullscreen-panel'
|
||||
],
|
||||
autoFocus: false,
|
||||
disableClose: false,
|
||||
hasBackdrop: true,
|
||||
backdropClass: 'fdrd-dlg-backdrop'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the Asset Liability Management dialog (Form ALM-1).
|
||||
* Covers maturity ladder, monthly snapshot, and structural liquidity returns.
|
||||
|
||||
@@ -266,7 +266,7 @@
|
||||
<a
|
||||
class="subledger-link"
|
||||
[class.hidden]="!matchesSearch('Cash / Clearing / Transfer')"
|
||||
(click)="subLedgerOpen = false"
|
||||
(click)="navigateMenu('/cashier/cash-clearing-transfer-summary'); subLedgerOpen = false"
|
||||
>
|
||||
<fa-icon icon="money-bill" class="subledger-icon"></fa-icon> Cash / Clearing / Transfer
|
||||
</a>
|
||||
@@ -403,17 +403,26 @@
|
||||
<div class="subledger-columns">
|
||||
<div class="subledger-group">
|
||||
<div class="subledger-group-title">Financial Statements</div>
|
||||
<a class="subledger-link" [class.hidden]="!matchesGlSearch('Trial Balance')" (click)="glMenuOpen = false"
|
||||
<a
|
||||
class="subledger-link"
|
||||
[class.hidden]="!matchesGlSearch('Trial Balance')"
|
||||
routerLink="/gl/reports/trial-balance"
|
||||
(click)="glMenuOpen = false"
|
||||
><fa-icon icon="balance-scale" class="subledger-icon"></fa-icon> Trial Balance</a
|
||||
>
|
||||
<a class="subledger-link" [class.hidden]="!matchesGlSearch('Profit and Loss')" (click)="glMenuOpen = false"
|
||||
<a
|
||||
class="subledger-link"
|
||||
[class.hidden]="!matchesGlSearch('Profit and Loss')"
|
||||
routerLink="/gl/reports/profit-and-loss"
|
||||
(click)="glMenuOpen = false"
|
||||
><fa-icon icon="chart-line" class="subledger-icon"></fa-icon> Profit and Loss</a
|
||||
>
|
||||
<a
|
||||
class="subledger-link"
|
||||
[class.hidden]="!matchesGlSearch('Consolidate Balance Sheet')"
|
||||
[class.hidden]="!matchesGlSearch('Consolidated Balance Sheet')"
|
||||
routerLink="/gl/reports/balance-sheet"
|
||||
(click)="glMenuOpen = false"
|
||||
><fa-icon icon="file-alt" class="subledger-icon"></fa-icon> Consolidate Balance Sheet</a
|
||||
><fa-icon icon="file-alt" class="subledger-icon"></fa-icon> Consolidated Balance Sheet</a
|
||||
>
|
||||
</div>
|
||||
<div class="subledger-group">
|
||||
@@ -448,8 +457,12 @@
|
||||
(click)="glMenuOpen = false"
|
||||
><fa-icon icon="users" class="subledger-icon"></fa-icon> Client wise Balance Report</a
|
||||
>
|
||||
<a class="subledger-link" [class.hidden]="!matchesGlSearch('Contingent Expense')" (click)="glMenuOpen = false"
|
||||
><fa-icon icon="exclamation-circle" class="subledger-icon"></fa-icon> Contingent Expense</a
|
||||
<a
|
||||
class="subledger-link"
|
||||
[class.hidden]="!matchesGlSearch('Contingent Liabilities')"
|
||||
routerLink="/gl/reports/contingent-liabilities"
|
||||
(click)="glMenuOpen = false"
|
||||
><fa-icon icon="exclamation-circle" class="subledger-icon"></fa-icon> Contingent Liabilities</a
|
||||
>
|
||||
<a class="subledger-link" [class.hidden]="!matchesGlSearch('Banker Head Report')" (click)="glMenuOpen = false"
|
||||
><fa-icon icon="university" class="subledger-icon"></fa-icon> Banker Head Report</a
|
||||
|
||||
+15
@@ -365,6 +365,21 @@
|
||||
</mat-form-field>
|
||||
</form>
|
||||
}
|
||||
|
||||
<!-- Compliance / AML Action Triage -->
|
||||
<div class="compliance-triage" style="margin-top: 16px; padding: 12px; background: #fff8f8; border: 1px dashed #f5c2c2; border-radius: 8px;">
|
||||
<h4 style="margin: 0 0 8px; font-weight: 600; color: #b91c1c; display: flex; align-items: center; gap: 6px;">
|
||||
Compliance & AML Triage
|
||||
</h4>
|
||||
<div style="display: flex; flex-direction: column; gap: 8px;">
|
||||
<mat-checkbox [(ngModel)]="flagAsSuspicious" [ngModelOptions]="{standalone: true}">
|
||||
Flag as suspicious (escalate to STR cases under RBI compliance)
|
||||
</mat-checkbox>
|
||||
<mat-checkbox [(ngModel)]="markAsWhitelisted" [ngModelOptions]="{standalone: true}">
|
||||
Mark as whitelisted (prevent future AML alerts for this account)
|
||||
</mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div #lightboxRoot style="display: none"></div>
|
||||
|
||||
+86
@@ -131,6 +131,90 @@ export class ItemDecisionDialogComponent implements OnInit, OnDestroy {
|
||||
/** 'any' = mode of operation lets any one signer sign (Either/Anyone or Survivor); 'all' = every mandatory signer must sign. */
|
||||
signerSelectionMode: 'any' | 'all' = 'any';
|
||||
manualOverrideChecked = false;
|
||||
flagAsSuspicious = false;
|
||||
markAsWhitelisted = false;
|
||||
|
||||
handleComplianceActions(): void {
|
||||
if (this.flagAsSuspicious) {
|
||||
const existingStrData = localStorage.getItem('fiu_str_cases');
|
||||
let cases: any[] = [];
|
||||
if (existingStrData) {
|
||||
try {
|
||||
cases = JSON.parse(existingStrData);
|
||||
} catch (e) {
|
||||
cases = [];
|
||||
}
|
||||
}
|
||||
|
||||
const newRef = 101 + cases.length;
|
||||
const newCase: any = {
|
||||
reportType: 'STR',
|
||||
reportRefNo: newRef,
|
||||
branchCode: this.item.presentingBranchCode || '1',
|
||||
accountNumber: this.item.accountNo || this.item.accountId || '110001101000838',
|
||||
txnBranchCode: this.item.presentingBranchCode || '1',
|
||||
depositOrWithdrawal: 'Withdrawal',
|
||||
txnId: this.item.id,
|
||||
txnDate: new Date().toLocaleDateString('en-GB').replace(/\//g, '-'),
|
||||
txnTime: new Date().toLocaleTimeString('en-US', { hour12: false }),
|
||||
txnAmount: this.item.amount,
|
||||
instrumentType: 'Cheque',
|
||||
instrumentId: this.item.chequeNumber,
|
||||
nonCustomerRef: '',
|
||||
relationshipFlag: 'Customer',
|
||||
thirdPartyPan: '',
|
||||
declaration: 'NA',
|
||||
customerName: this.item.clientName || 'UNKNOWN CLIENT',
|
||||
detectedOn: new Date().toLocaleDateString('en-GB').replace(/\//g, '-'),
|
||||
flaggedBy: 'CTS Clearing Officer',
|
||||
riskCategory: 'unusual-pattern',
|
||||
narrative: `CTS Cheque #${this.item.chequeNumber} for amount ₹${this.item.amount} flagged as suspicious by Clearing Officer during inward presentment review. Action: ${this.mode}. Reason/Notes: ${this.mode === 'RETURN' ? this.returnForm.value.reason : this.passForm.value.note || 'Manual compliance escalation'}`,
|
||||
status: 'po-review'
|
||||
};
|
||||
|
||||
cases.unshift(newCase);
|
||||
localStorage.setItem('fiu_str_cases', JSON.stringify(cases));
|
||||
|
||||
const storedCtr = localStorage.getItem('fiu_ctr_txns');
|
||||
if (storedCtr) {
|
||||
try {
|
||||
const ctrList = JSON.parse(storedCtr);
|
||||
const matching = ctrList.find((t: any) => t.accountNumber === newCase.accountNumber);
|
||||
if (matching) {
|
||||
matching.complianceStatus = 'SUSPICIOUS';
|
||||
localStorage.setItem('fiu_ctr_txns', JSON.stringify(ctrList));
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
} else if (this.markAsWhitelisted) {
|
||||
const existingWhitelist = localStorage.getItem('compliance_whitelisted_accounts');
|
||||
let whitelisted: string[] = [];
|
||||
if (existingWhitelist) {
|
||||
try {
|
||||
whitelisted = JSON.parse(existingWhitelist);
|
||||
} catch (e) {
|
||||
whitelisted = [];
|
||||
}
|
||||
}
|
||||
const account = this.item.accountNo || this.item.accountId;
|
||||
if (account && !whitelisted.includes(account.toString())) {
|
||||
whitelisted.push(account.toString());
|
||||
localStorage.setItem('compliance_whitelisted_accounts', JSON.stringify(whitelisted));
|
||||
}
|
||||
|
||||
const storedCtr = localStorage.getItem('fiu_ctr_txns');
|
||||
if (storedCtr) {
|
||||
try {
|
||||
const ctrList = JSON.parse(storedCtr);
|
||||
const matching = ctrList.find((t: any) => t.accountNumber === (account ? account.toString() : ''));
|
||||
if (matching) {
|
||||
matching.complianceStatus = 'WHITELISTED';
|
||||
localStorage.setItem('fiu_ctr_txns', JSON.stringify(ctrList));
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Every automatic validation rule InwardClearingValidationService already computes, shown so the clerk sees WHY, not just a decision. */
|
||||
readonly validationRules: { key: string; label: string; goodWhenTrue: boolean }[] = [
|
||||
@@ -652,6 +736,7 @@ export class ItemDecisionDialogComponent implements OnInit, OnDestroy {
|
||||
this.ctsService.passInwardItem(this.item.id, payload).subscribe({
|
||||
next: () => {
|
||||
this.busy = false;
|
||||
this.handleComplianceActions();
|
||||
this.notify('Cheque passed');
|
||||
this.dialogRef.close({ actioned: true });
|
||||
},
|
||||
@@ -676,6 +761,7 @@ export class ItemDecisionDialogComponent implements OnInit, OnDestroy {
|
||||
this.ctsService.returnInwardItem(this.item.id, payload).subscribe({
|
||||
next: () => {
|
||||
this.busy = false;
|
||||
this.handleComplianceActions();
|
||||
this.notify('Cheque returned');
|
||||
this.dialogRef.close({ actioned: true });
|
||||
},
|
||||
|
||||
@@ -81,7 +81,7 @@ export interface DeafEligibleAccount {
|
||||
|
||||
/** Form I — Dec 2025 cycle */
|
||||
export const FORM_I: DeafForm = {
|
||||
bankName: 'Bharat Urban Bank Ltd, Mumbai Main Branch',
|
||||
bankName: 'CBSNexora Bank Ltd, Mumbai Main Branch',
|
||||
bankDeafCode: '1100',
|
||||
remittedThroughSponsor: 'Y',
|
||||
sponsorBankName: 'State Bank of India',
|
||||
@@ -170,7 +170,7 @@ export const FORM_I: DeafForm = {
|
||||
|
||||
/** Form II — Jan 2026 (head office consolidated) */
|
||||
export const FORM_II: DeafForm = {
|
||||
bankName: 'Bharat Urban Bank Ltd, Head Office',
|
||||
bankName: 'CBSNexora Bank Ltd, Head Office',
|
||||
bankDeafCode: '1100',
|
||||
remittedThroughSponsor: 'Y',
|
||||
sponsorBankName: 'State Bank of India',
|
||||
@@ -271,7 +271,7 @@ export const REFUND_CLAIMS: {
|
||||
auditor: string;
|
||||
rows: DeafClaim[];
|
||||
} = {
|
||||
bankName: 'Bharat Urban Bank Ltd',
|
||||
bankName: 'CBSNexora Bank Ltd',
|
||||
bankDeafCode: '1100',
|
||||
currentAcWithRbi: '03010200099001',
|
||||
sponsorBank: 'State Bank of India',
|
||||
|
||||
@@ -53,7 +53,7 @@ export interface SahayDepositor {
|
||||
}
|
||||
|
||||
export const DICGC_BANK = {
|
||||
name: 'Bharat Urban Bank Ltd',
|
||||
name: 'CBSNexora Bank Ltd',
|
||||
branch: 'Head Office',
|
||||
reportAsOn: '29-01-2026',
|
||||
printDate: '29-01-2026 04:19 PM',
|
||||
|
||||
+732
-300
File diff suppressed because it is too large
Load Diff
+516
-175
@@ -14,12 +14,25 @@
|
||||
<div>
|
||||
<h2 class="fiu-head-t">Financial Intelligence Unit – India (FIU-IND)</h2>
|
||||
<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
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<span class="fiu-meta-l">Principal Officer</span><span class="fiu-meta-v">{{ bank.principalOfficer }}</span>
|
||||
</div>
|
||||
@@ -71,17 +84,19 @@
|
||||
<div class="fiu-kpi fiu-kpi--amber">
|
||||
<div class="fiu-kpi-ic"><fa-icon icon="exclamation-triangle"></fa-icon></div>
|
||||
<div>
|
||||
<div class="fiu-kpi-n">{{ pendingStr | number }}</div>
|
||||
<div class="fiu-kpi-n">{{ liveStrPending | number }}</div>
|
||||
<div class="fiu-kpi-l">STR Pending</div>
|
||||
<div class="fiu-kpi-sub">draft / PO review</div>
|
||||
<div class="fiu-kpi-sub">
|
||||
{{ liveStrOpen }} to investigate · {{ liveStrAwaitingFiling }} awaiting filing
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fiu-kpi fiu-kpi--green">
|
||||
<div class="fiu-kpi-ic"><fa-icon icon="check-circle"></fa-icon></div>
|
||||
<div>
|
||||
<div class="fiu-kpi-n">{{ filedStr | number }}</div>
|
||||
<div class="fiu-kpi-n">{{ liveStrFiled | number }}</div>
|
||||
<div class="fiu-kpi-l">STR Filed</div>
|
||||
<div class="fiu-kpi-sub">acknowledged by FIU</div>
|
||||
<div class="fiu-kpi-sub">{{ liveStrCleared }} concluded clear</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fiu-kpi fiu-kpi--purple">
|
||||
@@ -94,6 +109,52 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- WHEN TO REPORT — the five PMLA obligations -->
|
||||
<div class="ob-block">
|
||||
<div class="ob-head">
|
||||
<div>
|
||||
<h3 class="ob-title"><fa-icon icon="gavel"></fa-icon> When to report to FIU-IND</h3>
|
||||
<p class="ob-sub">
|
||||
The five reports a reporting entity owes under the Prevention of Money Laundering Act. All are filed
|
||||
electronically through the FINnet 2.0 portal.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ob-grid">
|
||||
@for (o of obligations; track o.code) {
|
||||
<div class="ob-card" [class.ob-card--off]="!o.handled">
|
||||
<div class="ob-card-h">
|
||||
<fa-icon [icon]="o.icon" class="ob-ic"></fa-icon>
|
||||
<span class="ob-code">{{ o.code }}</span>
|
||||
<span class="ob-name">{{ o.name }}</span>
|
||||
@if (o.handled) {
|
||||
<span class="ob-flag ob-flag--on">Filed here</span>
|
||||
} @else {
|
||||
<span class="ob-flag">Tracked</span>
|
||||
}
|
||||
</div>
|
||||
<div class="ob-row">
|
||||
<label>Report when</label><span>{{ o.trigger }}</span>
|
||||
</div>
|
||||
<div class="ob-row">
|
||||
<label>Deadline</label><span class="ob-due">{{ o.deadline }}</span>
|
||||
</div>
|
||||
<div class="ob-note">{{ o.note }}</div>
|
||||
@if (o.code === 'CTR') {
|
||||
<button class="dc-btn" (click)="showTab('ctr')">
|
||||
<fa-icon icon="external-link-alt"></fa-icon> Open CTR
|
||||
</button>
|
||||
} @else if (o.code === 'STR') {
|
||||
<button class="dc-btn" (click)="showTab('str')">
|
||||
<fa-icon icon="external-link-alt"></fa-icon> Open STR
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Compliance cards -->
|
||||
<div class="fiu-compliance">
|
||||
<div class="dc-card">
|
||||
@@ -112,7 +173,12 @@
|
||||
</div>
|
||||
<div class="dc-meta">Filing window: <strong>within 7 days of detection</strong></div>
|
||||
<div class="dc-meta">
|
||||
Pending: <strong>{{ pendingStr }}</strong> · Filed: <strong>{{ filedStr }}</strong>
|
||||
To investigate: <strong>{{ liveStrOpen }}</strong> · Awaiting filing:
|
||||
<strong>{{ liveStrAwaitingFiling }}</strong>
|
||||
</div>
|
||||
<div class="dc-meta">
|
||||
Filed: <strong>{{ liveStrFiled }}</strong> · Concluded clear:
|
||||
<strong>{{ liveStrCleared }}</strong>
|
||||
</div>
|
||||
<button class="dc-btn" (click)="showTab('str')"><fa-icon icon="external-link-alt"></fa-icon> Open</button>
|
||||
</div>
|
||||
@@ -129,21 +195,118 @@
|
||||
<!-- CTR -->
|
||||
@if (activeTab === 'ctr') {
|
||||
<div class="fiu-section">
|
||||
<!-- LIVE: cash-related AML alerts awaiting AML Officer review -->
|
||||
<!-- FINNET FILE SEQUENCE — the CTR submission, in prescribed order -->
|
||||
<div class="seq-block">
|
||||
<div class="fiu-form-meta">
|
||||
<div class="fiu-form-title">
|
||||
<h3>CTR Submission Files — FINnet 2.0</h3>
|
||||
<p>
|
||||
The CTR is built in this order: the account is described first, then who is behind it, then the two
|
||||
transaction files. Every grid below is the exact content of its CSV. The customer cards (KC1 / KC2) travel
|
||||
with the same bundle and are worked on the Customer Cards tab.
|
||||
</p>
|
||||
</div>
|
||||
<button class="fiu-btn fiu-btn-xls" (click)="exportAll()">
|
||||
<fa-icon icon="file-csv"></fa-icon> Download full bundle
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Scope: these controls decide which transactions the six files carry. -->
|
||||
<div class="fiu-filters">
|
||||
<div class="fiu-filter">
|
||||
<label>Type</label>
|
||||
<select class="fiu-inp" [(ngModel)]="ctrFilterMode">
|
||||
<option value="">All</option>
|
||||
<option value="Deposit">Deposit</option>
|
||||
<option value="Withdrawal">Withdrawal</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="fiu-filter">
|
||||
<label>Account contains</label>
|
||||
<input class="fiu-inp" type="text" [(ngModel)]="ctrFilterAccount" placeholder="A/c partial" />
|
||||
</div>
|
||||
<div class="fiu-filter">
|
||||
<label>Min Amount (₹)</label>
|
||||
<input class="fiu-inp" type="number" [(ngModel)]="ctrFilterMinAmount" placeholder="0" />
|
||||
</div>
|
||||
<button class="fiu-btn fiu-btn-g" (click)="resetCtrFilters()">Reset</button>
|
||||
</div>
|
||||
|
||||
<!-- Step rail -->
|
||||
<div class="seq-rail">
|
||||
@for (f of finnetSequence; track f.key) {
|
||||
<button class="seq-step" [class.active]="activeFinnetFile === f.key" (click)="showFinnetFile(f.key)">
|
||||
<span class="seq-n">{{ f.step }}</span>
|
||||
<span class="seq-txt">
|
||||
<strong>{{ f.name }}</strong>
|
||||
<em>{{ f.table.rows.length }} {{ f.table.rows.length === 1 ? 'row' : 'rows' }}</em>
|
||||
</span>
|
||||
</button>
|
||||
@if (!$last) {
|
||||
<fa-icon icon="chevron-right" class="seq-arrow"></fa-icon>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Open file -->
|
||||
<div class="seq-panel">
|
||||
<div class="seq-panel-h">
|
||||
<div>
|
||||
<h4>Step {{ openFinnetFile.step }} — {{ openFinnetFile.title }}</h4>
|
||||
<p>{{ openFinnetFile.purpose }}</p>
|
||||
</div>
|
||||
<button class="fiu-btn fiu-btn-csv" (click)="downloadOpenFinnetFile()">
|
||||
<fa-icon icon="file-csv"></fa-icon> {{ openFinnetFile.name }}.csv
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="seq-tbl-wrap">
|
||||
<table class="seq-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="seq-rownum">#</th>
|
||||
@for (h of openFinnetFile.table.headers; track $index) {
|
||||
<th>{{ h }}</th>
|
||||
}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of openFinnetFile.table.rows; track $index) {
|
||||
<tr>
|
||||
<td class="seq-rownum">{{ $index + 1 }}</td>
|
||||
@for (c of r; track $index) {
|
||||
<td>{{ c }}</td>
|
||||
}
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td [attr.colspan]="openFinnetFile.table.headers.length + 1" class="seq-empty">
|
||||
@if (openFinnetFile.key === 'tc1') {
|
||||
No ATM or card transactions match the current scope. When the bank books none at all, TC1 is
|
||||
still submitted as a header-only file — that is what FIU-IND expects.
|
||||
} @else {
|
||||
No rows for the current filters.
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- STR -->
|
||||
@if (activeTab === 'str') {
|
||||
<div class="fiu-section">
|
||||
<!-- LIVE: suspicious AML alerts awaiting AML Officer review (Structuring, PEP, etc.) -->
|
||||
<div class="fiu-form-meta">
|
||||
<div class="fiu-form-title">
|
||||
<h3>Live Cash Alerts</h3>
|
||||
<p>
|
||||
Raised by the transaction-monitoring rule engine — review and whitelist or escalate to Principal Officer
|
||||
</p>
|
||||
<h3>Live AML Alerts (Awaiting Triage)</h3>
|
||||
<p>Flagged by the monitoring engine for suspicious behavioral patterns — triage and whitelist or escalate</p>
|
||||
</div>
|
||||
<select class="fiu-inp" [(ngModel)]="amlAlertStatusFilter" (ngModelChange)="loadAmlAlerts()">
|
||||
<option value="">All statuses</option>
|
||||
<option value="RAISED">Raised</option>
|
||||
<option value="UNDER_REVIEW">Under Review</option>
|
||||
<option value="WHITELISTED">Whitelisted</option>
|
||||
<option value="ESCALATED">Escalated</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="fiu-tbl-wrap">
|
||||
<table class="fiu-tbl">
|
||||
@@ -156,12 +319,13 @@
|
||||
<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 amlAlerts; track alert.id) {
|
||||
@for (alert of amlSuspiciousAlerts; track alert.id) {
|
||||
<tr>
|
||||
<td>{{ alert.id }}</td>
|
||||
<td>{{ alert.clientDisplayName }}</td>
|
||||
@@ -172,6 +336,9 @@
|
||||
<td>
|
||||
<span [class]="amlStatusClass(alert.status)">{{ alert.status }}</span>
|
||||
</td>
|
||||
<td class="ta-c">
|
||||
<span [class]="alertRouteClass(alert.status, false)">{{ alertRouteLabel(alert.status, false) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
@if (alert.status === 'RAISED' || alert.status === 'UNDER_REVIEW') {
|
||||
<input
|
||||
@@ -195,153 +362,20 @@
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="9" class="fiu-empty">No live alerts.</td>
|
||||
<td colspan="10" class="fiu-empty">No suspicious behavioral 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-cleared">Cleared</span> whitelisted — no report filed ·
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fiu-tbl-wrap">
|
||||
<table class="fiu-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Month</th>
|
||||
<th>Generated On</th>
|
||||
<th class="ta-c">Records</th>
|
||||
<th>Status</th>
|
||||
<th>Submitted On</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of amlCtrReports; track r.id) {
|
||||
<tr>
|
||||
<td>{{ r.reportMonth }}</td>
|
||||
<td>{{ r.generatedOn | date: 'medium' }}</td>
|
||||
<td class="ta-c">{{ r.recordCount }}</td>
|
||||
<td>
|
||||
<span [class]="amlStatusClass(r.status)">{{ r.status }}</span>
|
||||
</td>
|
||||
<td>{{ r.submittedOn ? (r.submittedOn | date: 'medium') : '—' }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="5" class="fiu-empty">No CTR reports generated yet.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<hr style="border: 0; border-top: 1px solid var(--f-g200); margin: 8px 0" />
|
||||
|
||||
<!-- FINnet export template (separate concern — TC1/TC2 file-format generation) -->
|
||||
<div class="fiu-form-meta">
|
||||
<div class="fiu-form-title">
|
||||
<h3>FINnet TC2 Export Template</h3>
|
||||
<p>
|
||||
All cash transactions ≥ ₹{{ bank.cashThreshold | number }} (single or aggregate per month per account)
|
||||
</p>
|
||||
</div>
|
||||
<button class="fiu-btn fiu-btn-csv" (click)="exportTc2()">
|
||||
<fa-icon icon="file-csv"></fa-icon> Download TC2.csv
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="fiu-filters">
|
||||
<div class="fiu-filter">
|
||||
<label>Type</label>
|
||||
<select class="fiu-inp" [(ngModel)]="ctrFilterMode">
|
||||
<option value="">All</option>
|
||||
<option value="Deposit">Deposit</option>
|
||||
<option value="Withdrawal">Withdrawal</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="fiu-filter">
|
||||
<label>Account contains</label>
|
||||
<input class="fiu-inp" type="text" [(ngModel)]="ctrFilterAccount" placeholder="A/c partial" />
|
||||
</div>
|
||||
<div class="fiu-filter">
|
||||
<label>Min Amount (₹)</label>
|
||||
<input class="fiu-inp" type="number" [(ngModel)]="ctrFilterMinAmount" placeholder="0" />
|
||||
</div>
|
||||
<button class="fiu-btn fiu-btn-g" (click)="resetCtrFilters()">Reset</button>
|
||||
</div>
|
||||
|
||||
<div class="fiu-tbl-wrap">
|
||||
<table class="fiu-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="ta-c">Ref</th>
|
||||
<th>Account</th>
|
||||
<th>Customer</th>
|
||||
<th class="ta-c">Type</th>
|
||||
<th>Date / Time</th>
|
||||
<th class="ta-r">Amount</th>
|
||||
<th>Instrument</th>
|
||||
<th>Branch</th>
|
||||
<th class="ta-c">Relationship</th>
|
||||
</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>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="9" class="fiu-empty">No transactions match the filters.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- STR -->
|
||||
@if (activeTab === 'str') {
|
||||
<div class="fiu-section">
|
||||
<!-- LIVE: STR cases with full Principal Officer actions -->
|
||||
<div class="fiu-form-meta">
|
||||
<div class="fiu-form-title">
|
||||
@@ -407,6 +441,17 @@
|
||||
<button class="fiu-btn fiu-btn-g" (click)="closeStrCaseDetail()">Close</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="case-outcome"
|
||||
[class.case-outcome--clear]="selectedStrCase.status === 'CONCLUDED_CLEAR'"
|
||||
[class.case-outcome--suspicious]="
|
||||
selectedStrCase.status === 'CONCLUDED_SUSPICIOUS' || selectedStrCase.status === 'FILED'
|
||||
"
|
||||
>
|
||||
<span [class]="amlStatusClass(selectedStrCase.status)">{{ selectedStrCase.status }}</span>
|
||||
<span>{{ strCaseOutcome(selectedStrCase) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="case-form" *mifosxHasPermission="'CONCLUDE_AML_STR'">
|
||||
<div class="form-row">
|
||||
<label for="fiuInvestigationNotes">Investigation notes (source-of-transaction history reviewed)</label>
|
||||
@@ -416,17 +461,99 @@
|
||||
<label for="fiuSourceOfFundsNotes">Source-of-funds notes</label>
|
||||
<textarea id="fiuSourceOfFundsNotes" rows="2" [(ngModel)]="concludeSourceOfFundsNotes"></textarea>
|
||||
</div>
|
||||
<div class="form-row conclusion-row">
|
||||
<label>
|
||||
<input type="radio" name="fiuConclusion" value="NOT_SUSPICIOUS" [(ngModel)]="concludeConclusion" />
|
||||
Not suspicious (whitelist)
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="fiuConclusion" value="SUSPICIOUS" [(ngModel)]="concludeConclusion" />
|
||||
Suspicious (file STR)
|
||||
</label>
|
||||
<!-- Verification checklist — the verdict is derived, not chosen. -->
|
||||
<div class="checklist">
|
||||
<div class="checklist-head">
|
||||
<span>Principal Officer verification — mark each point as found</span>
|
||||
<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-pressed]="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-pressed]="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>
|
||||
<button class="fiu-btn fiu-btn-xml" (click)="concludeStrCase()">Conclude</button>
|
||||
|
||||
<!-- Derived verdict -->
|
||||
<div
|
||||
class="verdict"
|
||||
[class.verdict--clear]="allAnswered && derivedConclusion === 'NOT_SUSPICIOUS'"
|
||||
[class.verdict--suspicious]="allAnswered && derivedConclusion === 'SUSPICIOUS'"
|
||||
>
|
||||
@if (!allAnswered) {
|
||||
<fa-icon icon="info-circle"></fa-icon>
|
||||
<span>
|
||||
Answer every verification point to determine the outcome —
|
||||
{{ strChecklist.length - answeredCount }} still pending.
|
||||
</span>
|
||||
} @else if (derivedConclusion === 'NOT_SUSPICIOUS') {
|
||||
<fa-icon icon="check-circle"></fa-icon>
|
||||
<span>
|
||||
<strong>Not suspicious.</strong> All {{ strChecklist.length }} points found genuine — the case will be
|
||||
closed and nothing filed with FIU-IND.
|
||||
</span>
|
||||
} @else {
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
<span>
|
||||
<strong>Suspicious — STR must be filed.</strong>
|
||||
{{ failedFindings.length }} of {{ strChecklist.length }} points not satisfied:
|
||||
{{ failedFindings[0].label }}
|
||||
@if (failedFindings.length > 1) {
|
||||
<span> and {{ failedFindings.length - 1 }} more</span>
|
||||
}
|
||||
.
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<button class="fiu-btn fiu-btn-xml" [disabled]="!allAnswered" (click)="concludeStrCase()">
|
||||
Conclude as
|
||||
{{ allAnswered ? (derivedConclusion === 'SUSPICIOUS' ? 'Suspicious' : 'Not Suspicious') : '…' }}
|
||||
</button>
|
||||
|
||||
@if (selectedStrCase.status === 'CONCLUDED_SUSPICIOUS') {
|
||||
<div class="file-str-row">
|
||||
@@ -457,8 +584,9 @@
|
||||
<option value="">All</option>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="po-review">PO Review</option>
|
||||
<option value="awaiting-filing">Awaiting Filing</option>
|
||||
<option value="filed">Filed</option>
|
||||
<option value="rejected">Rejected</option>
|
||||
<option value="rejected">Closed — Clean</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="fiu-filter">
|
||||
@@ -478,7 +606,12 @@
|
||||
|
||||
<div class="fiu-str-list">
|
||||
@for (s of filteredStr; track s.reportRefNo) {
|
||||
<div class="str-card" [class.str-card--filed]="s.status === 'filed'">
|
||||
<div
|
||||
class="str-card"
|
||||
[id]="'str-card-' + s.reportRefNo"
|
||||
[class.str-card--filed]="s.status === 'filed'"
|
||||
[class.str-card--highlight]="highlightedStrRef === s.reportRefNo"
|
||||
>
|
||||
<div class="str-card-h">
|
||||
<div class="str-card-id">
|
||||
<span class="str-ref">STR-{{ s.reportRefNo }}</span>
|
||||
@@ -510,6 +643,9 @@
|
||||
<div>
|
||||
<label>Instrument</label><span>{{ s.instrumentType }} {{ s.instrumentId }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<label>Branch</label><span class="mono">{{ s.branchCode }} {{ branchNameFor(s.branchCode) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="str-narrative">
|
||||
<label>Narrative</label>
|
||||
@@ -521,6 +657,208 @@
|
||||
<p>{{ s.poRemarks }}</p>
|
||||
</div>
|
||||
}
|
||||
@if (s.poFinding) {
|
||||
<div class="str-finding" [class.str-finding--suspicious]="s.poFinding === 'SUSPICIOUS'">
|
||||
<label>
|
||||
<fa-icon [icon]="s.poFinding === 'CLEAN' ? 'check-circle' : 'exclamation-triangle'"></fa-icon>
|
||||
Principal Officer Finding
|
||||
</label>
|
||||
<p>{{ poFindingLabel(s.poFinding) }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- 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'">
|
||||
<label>Principal Officer investigation — is the source of funds legitimate?</label>
|
||||
<button class="fiu-btn fiu-btn-xml" (click)="investigateLocalStr(s)">
|
||||
<fa-icon icon="search"></fa-icon> Investigate this case
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- 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>
|
||||
</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>
|
||||
} @empty {
|
||||
@@ -626,10 +964,13 @@
|
||||
<div class="fiu-form-meta">
|
||||
<div class="fiu-form-title">
|
||||
<h3>FINnet 2.0 Submission Files</h3>
|
||||
<p>6 CSV files in FIU-IND prescribed schema, plus STR XML</p>
|
||||
<p>
|
||||
6 CSV files in FIU-IND prescribed schema, plus STR XML. Counts reflect any filters set on the CTR and STR
|
||||
tabs, so every file in the bundle covers the same transactions.
|
||||
</p>
|
||||
</div>
|
||||
<button class="fiu-btn fiu-btn-xls" (click)="exportAll()">
|
||||
<fa-icon icon="file-csv"></fa-icon> Download All
|
||||
<fa-icon icon="file-csv"></fa-icon> Download All 7
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -641,7 +982,7 @@
|
||||
</div>
|
||||
<div class="file-card">
|
||||
<div class="file-card-h"><fa-icon icon="file-csv"></fa-icon>TC2.csv</div>
|
||||
<p>Branch / cash transactions ({{ ctrTxns.length }} rows)</p>
|
||||
<p>Branch / cash transactions ({{ filteredCtr.length }} rows)</p>
|
||||
<button class="fiu-btn fiu-btn-csv" (click)="exportTc2()">
|
||||
<fa-icon icon="file-csv"></fa-icon> Download
|
||||
</button>
|
||||
@@ -676,7 +1017,7 @@
|
||||
</div>
|
||||
<div class="file-card file-card--xml">
|
||||
<div class="file-card-h"><fa-icon icon="file-alt"></fa-icon>STR.xml</div>
|
||||
<p>Suspicious Transaction Report ({{ filedStr + pendingStr }} suspects)</p>
|
||||
<p>Suspicious Transaction Report ({{ strSuspects.length }} suspects — cases concluded clean are excluded)</p>
|
||||
<button class="fiu-btn fiu-btn-xml" (click)="exportStrXml()">
|
||||
<fa-icon icon="file-alt"></fa-icon> Download XML
|
||||
</button>
|
||||
|
||||
@@ -386,6 +386,172 @@
|
||||
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 {
|
||||
display: inline-block;
|
||||
margin-top: 3px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--f-red);
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
|
||||
&:hover {
|
||||
color: #9a2f22;
|
||||
}
|
||||
|
||||
&--muted {
|
||||
color: var(--f-g500);
|
||||
|
||||
&:hover {
|
||||
color: var(--f-red);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* PRINCIPAL OFFICER INVESTIGATION */
|
||||
.str-po-action {
|
||||
background: var(--f-amber-l);
|
||||
border: 1px dashed #fcd34d;
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
> label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: #92400e;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
}
|
||||
|
||||
.fiu-btn-clean {
|
||||
background: var(--f-green);
|
||||
border-color: var(--f-green);
|
||||
color: #fff;
|
||||
|
||||
&:hover {
|
||||
background: #166534;
|
||||
border-color: #166534;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.str-finding {
|
||||
background: var(--f-green-l);
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
|
||||
label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: var(--f-green);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #14532d;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
&--suspicious {
|
||||
background: var(--f-red-l);
|
||||
|
||||
label {
|
||||
color: var(--f-red);
|
||||
}
|
||||
|
||||
p {
|
||||
color: #7f1d1d;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* INPUTS */
|
||||
.fiu-inp {
|
||||
width: 100%;
|
||||
@@ -538,6 +704,50 @@
|
||||
}
|
||||
}
|
||||
|
||||
.report-pill {
|
||||
display: inline-block;
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
|
||||
&.report-ctr {
|
||||
background: var(--f-blue-l);
|
||||
color: var(--f-blue);
|
||||
}
|
||||
|
||||
&.report-str {
|
||||
background: var(--f-red-l);
|
||||
color: var(--f-red);
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
&.report-pending {
|
||||
background: var(--f-amber-l);
|
||||
color: var(--f-amber);
|
||||
}
|
||||
|
||||
&.report-cleared {
|
||||
background: var(--f-green-l);
|
||||
color: var(--f-green);
|
||||
}
|
||||
}
|
||||
|
||||
.fiu-tbl-legend {
|
||||
margin: -6px 2px 0;
|
||||
font-size: 11px;
|
||||
color: var(--f-g500);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.report-pill {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.rel-pill {
|
||||
display: inline-block;
|
||||
padding: 2px 9px;
|
||||
@@ -570,6 +780,11 @@
|
||||
color: var(--f-amber);
|
||||
}
|
||||
|
||||
&.st-awaiting-filing {
|
||||
background: #ffedd5;
|
||||
color: #9a3412;
|
||||
}
|
||||
|
||||
&.st-filed {
|
||||
background: var(--f-green-l);
|
||||
color: var(--f-green);
|
||||
@@ -697,6 +912,11 @@
|
||||
&--filed {
|
||||
border-color: #86efac;
|
||||
}
|
||||
|
||||
&--highlight {
|
||||
border-color: var(--f-red);
|
||||
box-shadow: 0 0 0 3px rgb(192 57 43 / 20%);
|
||||
}
|
||||
}
|
||||
|
||||
.str-card-h {
|
||||
@@ -956,6 +1176,29 @@
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.case-outcome {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin: 12px 0;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
background: var(--f-g100);
|
||||
font-size: 13px;
|
||||
color: var(--f-g700);
|
||||
|
||||
&--clear {
|
||||
background: var(--f-green-l);
|
||||
color: #14532d;
|
||||
}
|
||||
|
||||
&--suspicious {
|
||||
background: var(--f-red-l);
|
||||
color: #7f1d1d;
|
||||
}
|
||||
}
|
||||
|
||||
.case-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -995,6 +1238,303 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Principal Officer verification checklist ───────────────────────────── */
|
||||
.checklist {
|
||||
border: 1px solid var(--f-g200);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.checklist-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
background: var(--f-g50);
|
||||
border-bottom: 1px solid var(--f-g200);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--f-g700);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.checklist-progress {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--f-amber-l);
|
||||
color: var(--f-amber);
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
white-space: nowrap;
|
||||
|
||||
&.done {
|
||||
background: var(--f-green-l);
|
||||
color: var(--f-green);
|
||||
}
|
||||
}
|
||||
|
||||
.checklist-tbl {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
||||
th {
|
||||
padding: 8px 12px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: var(--f-g500);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
background: var(--f-g50);
|
||||
border-bottom: 1px solid var(--f-g200);
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
th.col-yes {
|
||||
color: var(--f-green);
|
||||
width: 96px;
|
||||
}
|
||||
|
||||
th.col-no {
|
||||
color: var(--f-red);
|
||||
width: 112px;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 9px 12px;
|
||||
border-bottom: 1px solid var(--f-g100);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Answered rows tint so an unresolved finding is obvious at a glance. */
|
||||
.row-yes td {
|
||||
background: rgb(220 252 231 / 45%);
|
||||
}
|
||||
|
||||
.row-no td {
|
||||
background: rgb(253 236 234 / 60%);
|
||||
}
|
||||
|
||||
.row-pending td {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.chk-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--f-g900);
|
||||
}
|
||||
|
||||
.chk-hint {
|
||||
font-size: 11px;
|
||||
color: var(--f-g500);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
/* Two-sided toggle: one box on the genuine side, one on the not-satisfied side. */
|
||||
.chk-box {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 7px;
|
||||
border: 2px solid var(--f-g300);
|
||||
background: #fff;
|
||||
color: var(--f-g300);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
&--yes:hover {
|
||||
border-color: var(--f-green);
|
||||
color: var(--f-green);
|
||||
}
|
||||
|
||||
&--no:hover {
|
||||
border-color: var(--f-red);
|
||||
color: var(--f-red);
|
||||
}
|
||||
|
||||
&--yes.on {
|
||||
border-color: var(--f-green);
|
||||
background: var(--f-green);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&--no.on {
|
||||
border-color: var(--f-red);
|
||||
background: var(--f-red);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
/* 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 */
|
||||
.verdict {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 11px 15px;
|
||||
border-radius: 9px;
|
||||
font-size: 13px;
|
||||
background: var(--f-g100);
|
||||
border: 1px solid var(--f-g200);
|
||||
color: var(--f-g700);
|
||||
|
||||
&--clear {
|
||||
background: var(--f-green-l);
|
||||
border-color: #86efac;
|
||||
color: #14532d;
|
||||
}
|
||||
|
||||
&--suspicious {
|
||||
background: var(--f-red-l);
|
||||
border-color: #fca5a5;
|
||||
color: #7f1d1d;
|
||||
}
|
||||
}
|
||||
|
||||
.case-form .fiu-btn-xml:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.file-str-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1009,3 +1549,301 @@
|
||||
color: var(--f-g500);
|
||||
}
|
||||
}
|
||||
|
||||
/* ═══ WHEN TO REPORT — the five PMLA obligations ═══ */
|
||||
.ob-block {
|
||||
margin: 18px 0 20px;
|
||||
}
|
||||
|
||||
.ob-head {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.ob-title {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ob-sub {
|
||||
margin: 3px 0 0;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
max-width: 860px;
|
||||
}
|
||||
|
||||
.ob-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.ob-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-left: 4px solid #1a237e;
|
||||
border-radius: 11px;
|
||||
padding: 13px 15px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
|
||||
/* Obligations the module only tracks read back a shade quieter than the
|
||||
two it actually files, so the distinction is visible at a glance. */
|
||||
&--off {
|
||||
border-left-color: #cbd5e1;
|
||||
background: #fcfcfd;
|
||||
}
|
||||
}
|
||||
|
||||
.ob-card-h {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ob-ic {
|
||||
color: #1a237e;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.ob-code {
|
||||
font-weight: 800;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.ob-name {
|
||||
font-size: 12px;
|
||||
color: #334155;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ob-flag {
|
||||
margin-left: auto;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
white-space: nowrap;
|
||||
|
||||
&--on {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
}
|
||||
|
||||
.ob-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
|
||||
label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 12.5px;
|
||||
color: #0f172a;
|
||||
line-height: 1.45;
|
||||
}
|
||||
}
|
||||
|
||||
.ob-due {
|
||||
font-weight: 700;
|
||||
color: #b45309 !important;
|
||||
}
|
||||
|
||||
.ob-note {
|
||||
font-size: 11.5px;
|
||||
color: #64748b;
|
||||
font-style: italic;
|
||||
border-top: 1px dashed #e2e8f0;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
/* ═══ FINNET FILE SEQUENCE ═══ */
|
||||
.seq-block {
|
||||
margin-top: 26px;
|
||||
padding-top: 18px;
|
||||
border-top: 2px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.seq-rail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.seq-step {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 7px 13px;
|
||||
border: 1px solid #cbd5e1;
|
||||
background: #fff;
|
||||
border-radius: 9px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
border-color: #1a237e;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #1a237e;
|
||||
border-color: #1a237e;
|
||||
|
||||
.seq-n {
|
||||
background: #fff;
|
||||
color: #1a237e;
|
||||
}
|
||||
|
||||
.seq-txt strong,
|
||||
.seq-txt em {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.seq-n {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
border-radius: 50%;
|
||||
background: #1a237e;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
flex: 0 0 21px;
|
||||
}
|
||||
|
||||
.seq-txt {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.25;
|
||||
|
||||
strong {
|
||||
font-size: 12.5px;
|
||||
color: #0f172a;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
em {
|
||||
font-size: 10.5px;
|
||||
color: #64748b;
|
||||
font-style: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.seq-arrow {
|
||||
color: #cbd5e1;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.seq-panel {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 11px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.seq-panel-h {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
padding: 12px 16px;
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
flex-wrap: wrap;
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-size: 13.5px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 2px 0 0;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
max-width: 780px;
|
||||
}
|
||||
}
|
||||
|
||||
.seq-tbl-wrap {
|
||||
max-height: 460px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.seq-tbl {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 11.5px;
|
||||
white-space: nowrap;
|
||||
|
||||
th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
background: #eef2f7;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: #334155;
|
||||
border-bottom: 1px solid #cbd5e1;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
font-family: 'DM Mono', monospace;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
tbody tr:hover td {
|
||||
background: #f8fafc;
|
||||
}
|
||||
}
|
||||
|
||||
.seq-rownum {
|
||||
color: #94a3b8 !important;
|
||||
font-weight: 700;
|
||||
text-align: right;
|
||||
position: sticky;
|
||||
left: 0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.seq-tbl thead .seq-rownum {
|
||||
background: #eef2f7;
|
||||
}
|
||||
|
||||
.seq-empty {
|
||||
padding: 36px !important;
|
||||
text-align: center;
|
||||
color: #64748b;
|
||||
font-family: inherit !important;
|
||||
white-space: normal;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
+901
-78
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,723 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Data layer for the General Ledger report suite.
|
||||
*
|
||||
* Rather than hard-coding seven finished reports, this file holds a chart of
|
||||
* accounts plus a generated ledger of double-entry postings. Every report is
|
||||
* then computed from those postings, which is what makes the day / month /
|
||||
* branch filters genuinely recompute instead of just re-labelling a static table.
|
||||
*
|
||||
* The generator is deterministic — a fixed-seed LCG, never Math.random — so the
|
||||
* figures are identical on every reload and across users. Amounts are in rupees.
|
||||
*/
|
||||
|
||||
export type GlAccountType = 'ASSET' | 'LIABILITY' | 'INCOME' | 'EXPENSE' | 'CONTINGENT';
|
||||
|
||||
export interface GlAccount {
|
||||
code: string;
|
||||
name: string;
|
||||
type: GlAccountType;
|
||||
/** Balance-sheet / P&L grouping used as a sub-heading in the reports. */
|
||||
group: string;
|
||||
/** Included in the Misc Expense Ledger report. */
|
||||
miscExpense?: boolean;
|
||||
}
|
||||
|
||||
export interface GlBranch {
|
||||
code: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** One side of a double-entry voucher. */
|
||||
export interface GlPosting {
|
||||
/** ISO yyyy-MM-dd — the posting (value) date. */
|
||||
date: string;
|
||||
branchCode: string;
|
||||
accountCode: string;
|
||||
debit: number;
|
||||
credit: number;
|
||||
voucherNo: string;
|
||||
narration: string;
|
||||
}
|
||||
|
||||
export const GL_BRANCHES: GlBranch[] = [
|
||||
{ code: 'HO', name: 'Head Office' },
|
||||
{ code: '0002', name: 'Andheri (E)' },
|
||||
{ code: '0003', name: 'Bandra (W)' },
|
||||
{ code: '0004', name: 'Borivali' },
|
||||
{ code: '0005', name: 'Dadar' },
|
||||
{ code: '0006', name: 'Thane' }
|
||||
];
|
||||
|
||||
export const GL_ACCOUNTS: GlAccount[] = [
|
||||
// ── Assets ────────────────────────────────────────────────────────────────
|
||||
{ code: '110101', name: 'Cash in Hand', type: 'ASSET', group: 'Cash & Bank Balances' },
|
||||
{ code: '110102', name: 'Balance with RBI', type: 'ASSET', group: 'Cash & Bank Balances' },
|
||||
{ code: '110103', name: 'Balance with Other Banks — Current', type: 'ASSET', group: 'Cash & Bank Balances' },
|
||||
{ code: '110104', name: 'Balance with Other Banks — Fixed', type: 'ASSET', group: 'Cash & Bank Balances' },
|
||||
{ code: '120101', name: 'SLR Investments — Govt Securities', type: 'ASSET', group: 'Investments' },
|
||||
{ code: '120102', name: 'SLR Investments — State Govt Securities', type: 'ASSET', group: 'Investments' },
|
||||
{ code: '120201', name: 'Non-SLR Investments — Bonds', type: 'ASSET', group: 'Investments' },
|
||||
{ code: '120202', name: 'Non-SLR Investments — Mutual Funds', type: 'ASSET', group: 'Investments' },
|
||||
{ code: '130101', name: 'Cash Credit & Overdrafts', type: 'ASSET', group: 'Loans & Advances' },
|
||||
{ code: '130102', name: 'Term Loans — Short Term', type: 'ASSET', group: 'Loans & Advances' },
|
||||
{ code: '130103', name: 'Term Loans — Medium Term', type: 'ASSET', group: 'Loans & Advances' },
|
||||
{ code: '130104', name: 'Term Loans — Long Term', type: 'ASSET', group: 'Loans & Advances' },
|
||||
{ code: '130105', name: 'Housing Loans', type: 'ASSET', group: 'Loans & Advances' },
|
||||
{ code: '130106', name: 'Gold Loans', type: 'ASSET', group: 'Loans & Advances' },
|
||||
{ code: '140101', name: 'Interest Receivable', type: 'ASSET', group: 'Other Assets' },
|
||||
{ code: '140102', name: 'Premises (net of depreciation)', type: 'ASSET', group: 'Fixed Assets' },
|
||||
{ code: '140103', name: 'Furniture & Fixtures (net)', type: 'ASSET', group: 'Fixed Assets' },
|
||||
{ code: '140104', name: 'Sundry Debtors', type: 'ASSET', group: 'Other Assets' },
|
||||
{ code: '140105', name: 'Advance Tax & TDS', type: 'ASSET', group: 'Other Assets' },
|
||||
{ code: '140199', name: 'Opening Balance Suspense (unreconciled)', type: 'ASSET', group: 'Other Assets' },
|
||||
|
||||
// ── Liabilities & Capital ─────────────────────────────────────────────────
|
||||
{ code: '210101', name: 'Share Capital — Individuals', type: 'LIABILITY', group: 'Capital & Reserves' },
|
||||
{ code: '210201', name: 'Statutory Reserve Fund', type: 'LIABILITY', group: 'Capital & Reserves' },
|
||||
{ code: '210202', name: 'Building Fund', type: 'LIABILITY', group: 'Capital & Reserves' },
|
||||
{ code: '210203', name: 'Investment Depreciation Reserve', type: 'LIABILITY', group: 'Capital & Reserves' },
|
||||
{ code: '210204', name: 'BDDR / Special BDDR', type: 'LIABILITY', group: 'Capital & Reserves' },
|
||||
{ code: '220101', name: 'Savings Bank Deposits', type: 'LIABILITY', group: 'Deposits' },
|
||||
{ code: '220102', name: 'Current Deposits', type: 'LIABILITY', group: 'Deposits' },
|
||||
{ code: '220103', name: 'Term Deposits', type: 'LIABILITY', group: 'Deposits' },
|
||||
{ code: '220104', name: 'Recurring Deposits', type: 'LIABILITY', group: 'Deposits' },
|
||||
{ code: '230101', name: 'Interest Payable on Deposits', type: 'LIABILITY', group: 'Other Liabilities' },
|
||||
{ code: '230102', name: 'Bills Payable', type: 'LIABILITY', group: 'Other Liabilities' },
|
||||
{ code: '230103', name: 'Sundry Creditors', type: 'LIABILITY', group: 'Other Liabilities' },
|
||||
{ code: '230104', name: 'TDS / GST Payable', type: 'LIABILITY', group: 'Other Liabilities' },
|
||||
{ code: '230105', name: 'Overdue Interest Reserve', type: 'LIABILITY', group: 'Other Liabilities' },
|
||||
|
||||
// ── Income ────────────────────────────────────────────────────────────────
|
||||
{ code: '310101', name: 'Interest on Loans & Advances', type: 'INCOME', group: 'Interest Income' },
|
||||
{ code: '310102', name: 'Interest on Investments', type: 'INCOME', group: 'Interest Income' },
|
||||
{ code: '310103', name: 'Interest on Inter-bank Deposits', type: 'INCOME', group: 'Interest Income' },
|
||||
{ code: '320101', name: 'Commission & Exchange', type: 'INCOME', group: 'Other Income' },
|
||||
{ code: '320102', name: 'Locker Rent', type: 'INCOME', group: 'Other Income' },
|
||||
{ code: '320103', name: 'Processing Fees', type: 'INCOME', group: 'Other Income' },
|
||||
{ code: '320104', name: 'Profit on Sale of Securities', type: 'INCOME', group: 'Other Income' },
|
||||
{ code: '320105', name: 'Miscellaneous Income', type: 'INCOME', group: 'Other Income' },
|
||||
|
||||
// ── Expenditure ───────────────────────────────────────────────────────────
|
||||
{ code: '410101', name: 'Interest on Savings Deposits', type: 'EXPENSE', group: 'Interest Expended' },
|
||||
{ code: '410102', name: 'Interest on Term Deposits', type: 'EXPENSE', group: 'Interest Expended' },
|
||||
{ code: '410103', name: 'Interest on Borrowings', type: 'EXPENSE', group: 'Interest Expended' },
|
||||
{ code: '420101', name: 'Salaries & Allowances', type: 'EXPENSE', group: 'Operating Expenses' },
|
||||
{ code: '420102', name: "Directors' 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: '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: '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: '430112',
|
||||
name: 'Other Miscellaneous Expenses',
|
||||
type: 'EXPENSE',
|
||||
group: 'Miscellaneous Expenses',
|
||||
miscExpense: true
|
||||
},
|
||||
|
||||
// ── Contingent (memoranda, not part of the balance sheet totals) ──────────
|
||||
{ code: '910101', name: 'Guarantees Issued — Financial', type: 'CONTINGENT', group: 'Guarantees' },
|
||||
{ code: '910102', name: 'Guarantees Issued — Performance', type: 'CONTINGENT', group: 'Guarantees' },
|
||||
{ code: '910103', name: 'Letters of Credit Outstanding', type: 'CONTINGENT', group: 'Letters of Credit' },
|
||||
{ code: '910104', name: 'Bills for Collection', type: 'CONTINGENT', group: 'Bills & Claims' },
|
||||
{ code: '910105', name: 'Claims Against Bank Not Acknowledged as Debt', type: 'CONTINGENT', group: 'Bills & Claims' },
|
||||
{ code: '910106', name: 'Forward Exchange Contracts', type: 'CONTINGENT', group: 'Other Commitments' },
|
||||
{ code: '910107', name: 'Capital Commitments Not Provided For', type: 'CONTINGENT', group: 'Other Commitments' }
|
||||
];
|
||||
|
||||
/** Financial year covered by the generated ledger. */
|
||||
export const GL_PERIOD = {
|
||||
/** FY 2026-27 opens here. */
|
||||
fyStart: '2026-04-01',
|
||||
/** Postings run to the day before the app's "today" (01 Aug 2026). */
|
||||
fyEnd: '2026-07-31',
|
||||
label: 'FY 2026-27'
|
||||
};
|
||||
|
||||
/**
|
||||
* Opening balances as at 01-Apr-2026, keyed by account code. Positive means the
|
||||
* account opens on its natural side (debit for assets/expenses, credit for the
|
||||
* rest). Head Office carries the investments and reserves; branches carry the
|
||||
* customer-facing book.
|
||||
*/
|
||||
const OPENING_BALANCES: Record<string, number> = {
|
||||
'110101': 9_996_000,
|
||||
'110102': 12_400_000,
|
||||
'110103': 13_667_000,
|
||||
// Surplus deposits parked with correspondent banks. Sized so the opening
|
||||
// balance sheet tallies exactly — see the assertion in glPostings().
|
||||
'110104': 54_206_000,
|
||||
'120101': 63_839_000,
|
||||
'120102': 147_895_000,
|
||||
'120201': 22_500_000,
|
||||
'120202': 24_234_000,
|
||||
'130101': 166_614_000,
|
||||
'130102': 125_082_000,
|
||||
'130103': 70_236_000,
|
||||
'130104': 29_356_000,
|
||||
'130105': 10_241_000,
|
||||
'130106': 8_450_000,
|
||||
'140101': 33_541_000,
|
||||
'140102': 3_185_000,
|
||||
'140103': 1_763_000,
|
||||
'140104': 1_240_000,
|
||||
'140105': 657_000,
|
||||
'210101': 27_278_000,
|
||||
'210201': 31_149_000,
|
||||
'210202': 13_642_000,
|
||||
'210203': 3_750_000,
|
||||
'210204': 24_341_000,
|
||||
'220101': 208_116_000,
|
||||
'220102': 19_592_000,
|
||||
'220103': 406_601_000,
|
||||
'220104': 24_880_000,
|
||||
'230101': 731_000,
|
||||
'230102': 291_000,
|
||||
'230103': 5_515_000,
|
||||
'230104': 1_120_000,
|
||||
'230105': 32_096_000,
|
||||
'910101': 1_450_000,
|
||||
'910102': 980_000,
|
||||
'910103': 620_000,
|
||||
'910104': 1_180_000,
|
||||
'910105': 2_638_000,
|
||||
'910106': 0,
|
||||
'910107': 340_000
|
||||
};
|
||||
|
||||
/** 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 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}`;
|
||||
}
|
||||
|
||||
/** Every posting date in the ledger period, Sundays excluded (bank holiday). */
|
||||
export function ledgerDates(): string[] {
|
||||
const out: string[] = [];
|
||||
const cur = new Date(`${GL_PERIOD.fyStart}T00:00:00Z`);
|
||||
const end = new Date(`${GL_PERIOD.fyEnd}T00:00:00Z`);
|
||||
while (cur <= end) {
|
||||
if (cur.getUTCDay() !== 0) {
|
||||
out.push(isoDate(cur));
|
||||
}
|
||||
cur.setUTCDate(cur.getUTCDate() + 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Templates for the vouchers the generator posts each day. */
|
||||
const VOUCHER_TEMPLATES: {
|
||||
debit: string;
|
||||
credit: string;
|
||||
narration: string;
|
||||
/** Rough rupee scale of the voucher. */
|
||||
scale: number;
|
||||
/** Chance the voucher is raised on any given branch-day. */
|
||||
frequency: number;
|
||||
}[] = [
|
||||
{ debit: '110101', credit: '220101', narration: 'Cash deposit — savings accounts', scale: 900_000, frequency: 1 },
|
||||
{ debit: '220101', credit: '110101', narration: 'Cash withdrawal — savings accounts', scale: 780_000, frequency: 1 },
|
||||
{ debit: '110101', credit: '220103', narration: 'Term deposit received', scale: 1_400_000, frequency: 0.75 },
|
||||
{ debit: '220103', credit: '110101', narration: 'Term deposit matured and paid', scale: 1_150_000, frequency: 0.6 },
|
||||
{ debit: '110101', credit: '220102', narration: 'Current account deposit', scale: 620_000, frequency: 0.8 },
|
||||
{ debit: '220102', credit: '110101', narration: 'Current account withdrawal', scale: 560_000, frequency: 0.8 },
|
||||
{ debit: '110101', credit: '220104', narration: 'Recurring deposit instalment', scale: 180_000, frequency: 0.7 },
|
||||
{ debit: '130101', credit: '110101', narration: 'Cash credit disbursed', scale: 1_250_000, frequency: 0.55 },
|
||||
{ debit: '110101', credit: '130101', narration: 'Cash credit repayment', scale: 1_100_000, frequency: 0.6 },
|
||||
{ debit: '130102', credit: '110101', narration: 'Short term loan disbursed', scale: 850_000, frequency: 0.4 },
|
||||
{ debit: '110101', credit: '130102', narration: 'Short term loan repayment', scale: 760_000, frequency: 0.5 },
|
||||
{ debit: '130103', credit: '110101', narration: 'Medium term loan disbursed', scale: 640_000, frequency: 0.3 },
|
||||
{ debit: '110101', credit: '130104', narration: 'Long term loan repayment', scale: 410_000, frequency: 0.35 },
|
||||
{ debit: '130105', credit: '110101', narration: 'Housing loan disbursed', scale: 520_000, frequency: 0.2 },
|
||||
{ debit: '130106', credit: '110101', narration: 'Gold loan disbursed', scale: 260_000, frequency: 0.4 },
|
||||
// Interest on advances is the dominant earning line — sized so the bank runs a
|
||||
// ~13% net margin rather than the loss the first-cut figures produced.
|
||||
{ debit: '110101', credit: '310101', narration: 'Interest recovered on advances', scale: 430_000, frequency: 1 },
|
||||
{ debit: '140101', credit: '310102', narration: 'Interest accrued on investments', scale: 148_000, frequency: 0.5 },
|
||||
{ debit: '110103', credit: '310103', narration: 'Interest on inter-bank deposits', scale: 42_000, frequency: 0.25 },
|
||||
{ debit: '110101', credit: '320101', narration: 'Commission and exchange collected', scale: 26_000, frequency: 0.9 },
|
||||
{ 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: '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: '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: '430101', credit: '110101', narration: 'Printing and stationery purchased', scale: 9_400, frequency: 0.3 },
|
||||
{ debit: '430102', credit: '110101', narration: 'Postage and telephone charges', scale: 6_200, frequency: 0.35 },
|
||||
{ debit: '430103', credit: '110101', narration: 'Advertisement and publicity', scale: 12_500, frequency: 0.15 },
|
||||
{ debit: '430104', credit: '110101', narration: 'Repairs and maintenance', scale: 15_800, frequency: 0.25 },
|
||||
{ debit: '430105', credit: '110101', narration: 'Travelling and conveyance', scale: 7_100, frequency: 0.3 },
|
||||
{ debit: '430106', credit: '110101', narration: 'Computer and software expenses', scale: 22_400, frequency: 0.2 },
|
||||
{ debit: '430107', credit: '110101', narration: 'Legal and professional charges', scale: 11_300, frequency: 0.15 },
|
||||
{ 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: '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
|
||||
}
|
||||
];
|
||||
|
||||
/** Head-Office-only vouchers — investments, reserves and centralised charges. */
|
||||
const HO_TEMPLATES: typeof VOUCHER_TEMPLATES = [
|
||||
{ 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: '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: '420103', credit: '110101', narration: "Statutory auditors' fees", scale: 45_000, frequency: 0.06 },
|
||||
{ debit: '420105', credit: '140103', narration: 'Depreciation charged on assets', scale: 64_000, frequency: 0.12 },
|
||||
{ debit: '430108', credit: '110101', narration: 'DICGC premium and other insurance', scale: 38_000, frequency: 0.1 },
|
||||
{ debit: '430110', credit: '110101', narration: 'AGM and meeting expenses', scale: 26_000, frequency: 0.06 },
|
||||
{ debit: '410103', credit: '230101', narration: 'Interest on borrowings', scale: 54_000, frequency: 0.1 },
|
||||
{ debit: '230104', credit: '110102', narration: 'TDS and GST remitted', scale: 145_000, frequency: 0.15 }
|
||||
];
|
||||
|
||||
/**
|
||||
* Contingent-account movements are memoranda, each posted single-sided.
|
||||
* Guarantees, LCs and collection bills are raised at the counter, so these run
|
||||
* at every branch — not just Head Office.
|
||||
*/
|
||||
const CONTINGENT_TEMPLATES: { account: string; narration: string; scale: number; frequency: number }[] = [
|
||||
{ account: '910101', narration: 'Financial guarantee issued', scale: 320_000, frequency: 0.1 },
|
||||
{ account: '910102', narration: 'Performance guarantee issued', scale: 240_000, frequency: 0.08 },
|
||||
{ account: '910103', narration: 'Letter of credit opened', scale: 180_000, frequency: 0.07 },
|
||||
{ account: '910104', narration: 'Bills lodged for collection', scale: 95_000, frequency: 0.12 },
|
||||
{ account: '910105', narration: 'Claim lodged against the bank', scale: 140_000, frequency: 0.03 },
|
||||
{ account: '910107', narration: 'Capital commitment contracted', scale: 85_000, frequency: 0.04 }
|
||||
];
|
||||
|
||||
/**
|
||||
* How the opening contingent book is spread across the network. Head Office
|
||||
* carries the capital commitments and large claims; the branches carry the
|
||||
* counter-issued guarantees. Weights are applied per account and the rounding
|
||||
* remainder is absorbed by Head Office so each account's total stays exact.
|
||||
*/
|
||||
const CONTINGENT_BRANCH_WEIGHTS: Record<string, number> = {
|
||||
HO: 0.2,
|
||||
'0002': 0.18,
|
||||
'0003': 0.16,
|
||||
'0004': 0.16,
|
||||
'0005': 0.15,
|
||||
'0006': 0.15
|
||||
};
|
||||
|
||||
let cachedPostings: GlPosting[] | null = null;
|
||||
|
||||
/**
|
||||
* The full generated ledger. Built once and memoised — every report reads from
|
||||
* this array, so the reports can never disagree with one another.
|
||||
*/
|
||||
export function glPostings(): GlPosting[] {
|
||||
if (cachedPostings) {
|
||||
return cachedPostings;
|
||||
}
|
||||
const rand = lcg(20260401);
|
||||
const out: GlPosting[] = [];
|
||||
const dates = ledgerDates();
|
||||
let voucherSeq = 1;
|
||||
|
||||
// Opening entries, posted at Head Office on day one so the books start in balance.
|
||||
const openingVoucher = `OB/${GL_PERIOD.label.replace(/\s/g, '')}`;
|
||||
let openingDebit = 0;
|
||||
let openingCredit = 0;
|
||||
|
||||
for (const [
|
||||
code,
|
||||
amount
|
||||
] of Object.entries(OPENING_BALANCES)) {
|
||||
if (!amount) {
|
||||
continue;
|
||||
}
|
||||
const account = GL_ACCOUNTS.find((a) => a.code === code);
|
||||
if (!account) {
|
||||
continue;
|
||||
}
|
||||
const debitSide = account.type === 'ASSET' || account.type === 'EXPENSE' || account.type === 'CONTINGENT';
|
||||
|
||||
// Contingent accounts are single-sided memoranda held at the issuing branch,
|
||||
// so their opening book is spread across the network rather than sat at HO.
|
||||
if (account.type === 'CONTINGENT') {
|
||||
let allocated = 0;
|
||||
for (const branch of GL_BRANCHES) {
|
||||
if (branch.code === 'HO') {
|
||||
continue;
|
||||
}
|
||||
const share = Math.round((amount * (CONTINGENT_BRANCH_WEIGHTS[branch.code] ?? 0)) / 100) * 100;
|
||||
if (!share) {
|
||||
continue;
|
||||
}
|
||||
allocated += share;
|
||||
out.push({
|
||||
date: GL_PERIOD.fyStart,
|
||||
branchCode: branch.code,
|
||||
accountCode: code,
|
||||
debit: share,
|
||||
credit: 0,
|
||||
voucherNo: openingVoucher,
|
||||
narration: 'Opening balance brought forward'
|
||||
});
|
||||
}
|
||||
// Head Office takes its own share plus whatever rounding left behind.
|
||||
out.push({
|
||||
date: GL_PERIOD.fyStart,
|
||||
branchCode: 'HO',
|
||||
accountCode: code,
|
||||
debit: amount - allocated,
|
||||
credit: 0,
|
||||
voucherNo: openingVoucher,
|
||||
narration: 'Opening balance brought forward'
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
out.push({
|
||||
date: GL_PERIOD.fyStart,
|
||||
branchCode: 'HO',
|
||||
accountCode: code,
|
||||
debit: debitSide ? amount : 0,
|
||||
credit: debitSide ? 0 : amount,
|
||||
voucherNo: openingVoucher,
|
||||
narration: 'Opening balance brought forward'
|
||||
});
|
||||
if (debitSide) {
|
||||
openingDebit += amount;
|
||||
} else {
|
||||
openingCredit += amount;
|
||||
}
|
||||
}
|
||||
|
||||
// If OPENING_BALANCES is ever edited so the two sides diverge, park the residual
|
||||
// in a visibly-named suspense account rather than letting the trial balance drift.
|
||||
const openingResidual = openingCredit - openingDebit;
|
||||
if (openingResidual !== 0) {
|
||||
out.push({
|
||||
date: GL_PERIOD.fyStart,
|
||||
branchCode: 'HO',
|
||||
accountCode: '140199',
|
||||
debit: openingResidual > 0 ? openingResidual : 0,
|
||||
credit: openingResidual < 0 ? -openingResidual : 0,
|
||||
voucherNo: openingVoucher,
|
||||
narration: 'Unreconciled opening balance difference'
|
||||
});
|
||||
}
|
||||
|
||||
const round = (n: number): number => Math.round(n / 100) * 100;
|
||||
|
||||
for (const date of dates) {
|
||||
for (const branch of GL_BRANCHES) {
|
||||
// Head Office posts fewer counter transactions than the branches.
|
||||
const weight = branch.code === 'HO' ? 0.45 : 1;
|
||||
const templates = branch.code === 'HO' ? [
|
||||
...VOUCHER_TEMPLATES,
|
||||
...HO_TEMPLATES
|
||||
] : VOUCHER_TEMPLATES;
|
||||
|
||||
for (const tpl of templates) {
|
||||
if (rand() > tpl.frequency * weight) {
|
||||
continue;
|
||||
}
|
||||
// 0.55x .. 1.45x of the template scale keeps daily figures varied but plausible.
|
||||
const amount = round(tpl.scale * (0.55 + rand() * 0.9));
|
||||
if (amount <= 0) {
|
||||
continue;
|
||||
}
|
||||
const voucherNo = `${branch.code}/${date.slice(5, 7)}/${`${voucherSeq++}`.padStart(5, '0')}`;
|
||||
out.push({
|
||||
date,
|
||||
branchCode: branch.code,
|
||||
accountCode: tpl.debit,
|
||||
debit: amount,
|
||||
credit: 0,
|
||||
voucherNo,
|
||||
narration: tpl.narration
|
||||
});
|
||||
out.push({
|
||||
date,
|
||||
branchCode: branch.code,
|
||||
accountCode: tpl.credit,
|
||||
debit: 0,
|
||||
credit: amount,
|
||||
voucherNo,
|
||||
narration: tpl.narration
|
||||
});
|
||||
}
|
||||
|
||||
// Contingent memoranda are raised at every branch, weighted by its size.
|
||||
const contingentWeight = CONTINGENT_BRANCH_WEIGHTS[branch.code] ?? 0.15;
|
||||
for (const tpl of CONTINGENT_TEMPLATES) {
|
||||
// Claims against the bank and capital commitments are Head Office matters.
|
||||
const headOfficeOnly = tpl.account === '910105' || tpl.account === '910107';
|
||||
if (headOfficeOnly && branch.code !== 'HO') {
|
||||
continue;
|
||||
}
|
||||
const frequency = headOfficeOnly ? tpl.frequency : tpl.frequency * (contingentWeight / 0.2);
|
||||
if (rand() > frequency) {
|
||||
continue;
|
||||
}
|
||||
const amount = round(tpl.scale * (0.55 + rand() * 0.9));
|
||||
out.push({
|
||||
date,
|
||||
branchCode: branch.code,
|
||||
accountCode: tpl.account,
|
||||
debit: amount,
|
||||
credit: 0,
|
||||
voucherNo: `CNT/${branch.code}/${date.slice(5, 7)}/${`${voucherSeq++}`.padStart(5, '0')}`,
|
||||
narration: tpl.narration
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cachedPostings = 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 {
|
||||
return GL_ACCOUNTS.find((a) => a.code === code);
|
||||
}
|
||||
|
||||
export function glBranchName(code: string): string {
|
||||
return GL_BRANCHES.find((b) => b.code === code)?.name ?? code;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
const routes: Routes = [
|
||||
Route.withShell([
|
||||
{
|
||||
path: 'gl/reports',
|
||||
// Lazy — the generated ledger and ExcelJS should not weigh down the eager bundle.
|
||||
loadComponent: () => import('./gl-reports.component').then((m) => m.GlReportsComponent),
|
||||
data: { title: 'General Ledger Reports', breadcrumb: 'GL Reports' }
|
||||
},
|
||||
{
|
||||
path: 'gl/reports/:report',
|
||||
loadComponent: () => import('./gl-reports.component').then((m) => m.GlReportsComponent),
|
||||
data: { title: 'General Ledger Reports', breadcrumb: 'GL Reports' }
|
||||
}
|
||||
])
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class GlReportsRoutingModule {}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,560 @@
|
||||
/**
|
||||
* 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 {
|
||||
--g-navy: #1a237e;
|
||||
--g-blue: #1d4ed8;
|
||||
--g-blue-l: #eff6ff;
|
||||
--g-teal: #0e7e6b;
|
||||
--g-teal-l: #e0f4f0;
|
||||
--g-green: #15803d;
|
||||
--g-green-l: #dcfce7;
|
||||
--g-amber: #b45309;
|
||||
--g-amber-l: #fef3c7;
|
||||
--g-red: #c0392b;
|
||||
--g-red-l: #fdecea;
|
||||
--g-g50: #f8fafc;
|
||||
--g-g100: #f1f5f9;
|
||||
--g-g200: #e2e8f0;
|
||||
--g-g300: #cbd5e1;
|
||||
--g-g500: #64748b;
|
||||
--g-g700: #334155;
|
||||
--g-g900: #0f172a;
|
||||
|
||||
display: block;
|
||||
font-family:
|
||||
'DM Sans',
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
background: var(--g-g50);
|
||||
color: var(--g-g900);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.glr-root {
|
||||
padding: 18px 22px;
|
||||
max-width: 1700px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* HEADER */
|
||||
.glr-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.glr-head-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.glr-head-ic {
|
||||
font-size: 28px;
|
||||
color: var(--g-navy);
|
||||
}
|
||||
|
||||
.glr-head-t {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.glr-head-s {
|
||||
font-size: 12px;
|
||||
color: var(--g-g500);
|
||||
}
|
||||
|
||||
/* BUTTONS */
|
||||
.glr-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 9px 16px;
|
||||
border: 1px solid var(--g-g300);
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--g-g700);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--g-g50);
|
||||
border-color: var(--g-g500);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.glr-btn-xls {
|
||||
background: #16a34a;
|
||||
border-color: #16a34a;
|
||||
color: #fff;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #15803d;
|
||||
border-color: #15803d;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
/* FILTER BAR */
|
||||
.glr-filters {
|
||||
background: #fff;
|
||||
border: 1px solid var(--g-g200);
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: 0 1px 3px rgb(0 0 0 / 4%);
|
||||
}
|
||||
|
||||
.glr-filter {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
|
||||
label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--g-g500);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
}
|
||||
|
||||
.glr-inp {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--g-g200);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
background: #fff;
|
||||
font-family: inherit;
|
||||
min-width: 190px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--g-navy);
|
||||
box-shadow: 0 0 0 3px rgb(26 35 126 / 12%);
|
||||
}
|
||||
}
|
||||
|
||||
/* 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 */
|
||||
.glr-seg {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--g-g200);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
button {
|
||||
padding: 8px 14px;
|
||||
border: none;
|
||||
background: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--g-g500);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: all 0.15s;
|
||||
|
||||
+ button {
|
||||
border-left: 1px solid var(--g-g200);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--g-g50);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--g-navy);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* REPORT SELECTOR */
|
||||
.glr-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--g-g200);
|
||||
border-radius: 10px;
|
||||
padding: 4px;
|
||||
margin-bottom: 16px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.glr-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 9px 14px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--g-g500);
|
||||
cursor: pointer;
|
||||
border-radius: 7px;
|
||||
white-space: nowrap;
|
||||
transition: all 0.15s;
|
||||
font-family: inherit;
|
||||
|
||||
&:hover {
|
||||
color: var(--g-g900);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--g-navy);
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 6px rgb(26 35 126 / 25%);
|
||||
}
|
||||
}
|
||||
|
||||
.glr-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* BALANCE BANNER */
|
||||
.glr-balance {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 16px;
|
||||
border-radius: 10px;
|
||||
background: var(--g-green-l);
|
||||
border: 1px solid #86efac;
|
||||
color: #14532d;
|
||||
font-size: 13px;
|
||||
|
||||
&--bad {
|
||||
background: var(--g-red-l);
|
||||
border-color: #fca5a5;
|
||||
color: #7f1d1d;
|
||||
}
|
||||
}
|
||||
|
||||
.glr-note {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--g-g500);
|
||||
}
|
||||
|
||||
/* KPIs */
|
||||
.glr-kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.glr-kpi {
|
||||
background: #fff;
|
||||
border: 1px solid var(--g-g200);
|
||||
border-left: 4px solid var(--g-g300);
|
||||
border-radius: 12px;
|
||||
padding: 14px 18px;
|
||||
box-shadow: 0 1px 3px rgb(0 0 0 / 5%);
|
||||
|
||||
&--teal {
|
||||
border-left-color: var(--g-teal);
|
||||
}
|
||||
|
||||
&--amber {
|
||||
border-left-color: var(--g-amber);
|
||||
}
|
||||
|
||||
&--green {
|
||||
border-left-color: var(--g-green);
|
||||
}
|
||||
|
||||
&--red {
|
||||
border-left-color: var(--g-red);
|
||||
}
|
||||
}
|
||||
|
||||
.glr-kpi-n {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
font-family: 'DM Mono', monospace;
|
||||
color: var(--g-g900);
|
||||
}
|
||||
|
||||
.glr-kpi-l {
|
||||
font-size: 12px;
|
||||
color: var(--g-g700);
|
||||
font-weight: 600;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.glr-kpi-sub {
|
||||
font-size: 11px;
|
||||
color: var(--g-g500);
|
||||
}
|
||||
|
||||
/* TABLES */
|
||||
.glr-two-col {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.glr-tbl-wrap {
|
||||
background: #fff;
|
||||
border: 1px solid var(--g-g200);
|
||||
border-radius: 12px;
|
||||
overflow: auto;
|
||||
box-shadow: 0 1px 3px rgb(0 0 0 / 4%);
|
||||
|
||||
&--tall {
|
||||
max-height: 70vh;
|
||||
}
|
||||
}
|
||||
|
||||
.glr-tbl-h {
|
||||
background: var(--g-blue-l);
|
||||
color: var(--g-navy);
|
||||
padding: 10px 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
border-bottom: 1px solid var(--g-g200);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.glr-tbl {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 9px 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--g-g500);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
background: var(--g-g50);
|
||||
border-bottom: 1px solid var(--g-g200);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--g-g100);
|
||||
font-size: 13px;
|
||||
color: var(--g-g700);
|
||||
}
|
||||
|
||||
tbody tr:hover td {
|
||||
background: var(--g-g50);
|
||||
}
|
||||
|
||||
tfoot td {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.total-row td {
|
||||
background: var(--g-g100) !important;
|
||||
font-weight: 700;
|
||||
color: var(--g-g900);
|
||||
border-top: 2px solid var(--g-g300);
|
||||
}
|
||||
|
||||
.glr-profit-row td {
|
||||
background: var(--g-green-l) !important;
|
||||
color: #14532d;
|
||||
}
|
||||
|
||||
.glr-loss-row td {
|
||||
background: var(--g-red-l) !important;
|
||||
color: #7f1d1d;
|
||||
}
|
||||
|
||||
.ta-c {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ta-r {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: 'DM Mono', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--g-g500);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.var-up {
|
||||
color: var(--g-green);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.var-down {
|
||||
color: var(--g-red);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.glr-empty {
|
||||
text-align: center;
|
||||
padding: 34px;
|
||||
color: var(--g-g500);
|
||||
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 */
|
||||
.type-pill {
|
||||
display: inline-block;
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
background: var(--g-g100);
|
||||
color: var(--g-g700);
|
||||
|
||||
&.type-asset {
|
||||
background: var(--g-blue-l);
|
||||
color: var(--g-blue);
|
||||
}
|
||||
|
||||
&.type-liability {
|
||||
background: var(--g-teal-l);
|
||||
color: var(--g-teal);
|
||||
}
|
||||
|
||||
&.type-income {
|
||||
background: var(--g-green-l);
|
||||
color: var(--g-green);
|
||||
}
|
||||
|
||||
&.type-expense {
|
||||
background: var(--g-amber-l);
|
||||
color: var(--g-amber);
|
||||
}
|
||||
|
||||
&.type-contingent {
|
||||
background: var(--g-red-l);
|
||||
color: var(--g-red);
|
||||
}
|
||||
}
|
||||
|
||||
/* TOAST */
|
||||
.glr-toast {
|
||||
position: fixed;
|
||||
bottom: 32px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(20px);
|
||||
background: var(--g-navy);
|
||||
color: #fff;
|
||||
padding: 12px 22px;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
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) {
|
||||
.glr-two-col {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.glr-kpis {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,843 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Derives all seven General Ledger reports from the posting ledger.
|
||||
*
|
||||
* Each report is a pure function of (postings, date range, branch), so changing
|
||||
* the day / month / branch filter genuinely recomputes the figures. Results are
|
||||
* memoised per filter key because the ledger runs to roughly twenty thousand
|
||||
* postings and Angular re-evaluates template getters on every change detection.
|
||||
*/
|
||||
|
||||
import { GL_ACCOUNTS, GL_PERIOD, GlAccount, GlPosting, glAccount, glCustomers, glPostings } from './gl-reports-data';
|
||||
|
||||
export interface GlDateRange {
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
export interface TrialBalanceRow {
|
||||
code: string;
|
||||
name: string;
|
||||
group: string;
|
||||
openingDebit: number;
|
||||
openingCredit: number;
|
||||
periodDebit: number;
|
||||
periodCredit: number;
|
||||
closingDebit: number;
|
||||
closingCredit: number;
|
||||
}
|
||||
|
||||
export interface PnlRow {
|
||||
code: string;
|
||||
name: string;
|
||||
group: string;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export interface BalanceSheetRow {
|
||||
code: string;
|
||||
name: string;
|
||||
group: string;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export interface ComparativeRow {
|
||||
code: string;
|
||||
name: string;
|
||||
group: string;
|
||||
side: 'ASSET' | 'LIABILITY';
|
||||
current: number;
|
||||
previous: number;
|
||||
variance: number;
|
||||
variancePct: number | null;
|
||||
}
|
||||
|
||||
export interface GlWiseRow {
|
||||
code: string;
|
||||
name: string;
|
||||
group: string;
|
||||
type: string;
|
||||
debit: number;
|
||||
credit: number;
|
||||
net: number;
|
||||
vouchers: number;
|
||||
}
|
||||
|
||||
export interface MiscExpenseRow {
|
||||
date: string;
|
||||
voucherNo: string;
|
||||
branchCode: string;
|
||||
code: string;
|
||||
name: string;
|
||||
narration: string;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
/** Natural side of an account — assets and expenses sit on the debit side. */
|
||||
function isDebitSide(account: GlAccount): boolean {
|
||||
return account.type === 'ASSET' || account.type === 'EXPENSE' || account.type === 'CONTINGENT';
|
||||
}
|
||||
|
||||
function matchesBranch(p: GlPosting, branchCode: string): boolean {
|
||||
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`. */
|
||||
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>();
|
||||
for (const p of glPostings()) {
|
||||
if (p.date > 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;
|
||||
out.set(p.accountCode, (out.get(p.accountCode) ?? 0) + delta);
|
||||
}
|
||||
balanceCache.set(key, out);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Debit and credit turnover strictly inside the range. */
|
||||
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> }>();
|
||||
for (const p of glPostings()) {
|
||||
if (p.date < range.from || p.date > range.to || !matchesBranch(p, branchCode)) {
|
||||
continue;
|
||||
}
|
||||
let entry = out.get(p.accountCode);
|
||||
if (!entry) {
|
||||
entry = { debit: 0, credit: 0, vouchers: new Set<string>() };
|
||||
out.set(p.accountCode, entry);
|
||||
}
|
||||
entry.debit += p.debit;
|
||||
entry.credit += p.credit;
|
||||
entry.vouchers.add(p.voucherNo);
|
||||
}
|
||||
turnoverCache.set(key, out);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The day before `date`, ISO formatted — used for opening balances. */
|
||||
function previousDay(date: string): string {
|
||||
const d = new Date(`${date}T00:00:00Z`);
|
||||
d.setUTCDate(d.getUTCDate() - 1);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
// ── Reports ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export function trialBalance(range: GlDateRange, branchCode: string): TrialBalanceRow[] {
|
||||
const opening = cumulativeBalances(previousDay(range.from), branchCode);
|
||||
const turnover = periodTurnover(range, branchCode);
|
||||
const rows: TrialBalanceRow[] = [];
|
||||
|
||||
for (const account of GL_ACCOUNTS) {
|
||||
// Contingent accounts are memoranda and never appear in the trial balance.
|
||||
if (account.type === 'CONTINGENT') {
|
||||
continue;
|
||||
}
|
||||
const open = opening.get(account.code) ?? 0;
|
||||
const move = turnover.get(account.code);
|
||||
const periodDebit = move?.debit ?? 0;
|
||||
const periodCredit = move?.credit ?? 0;
|
||||
if (!open && !periodDebit && !periodCredit) {
|
||||
continue;
|
||||
}
|
||||
const debitSide = isDebitSide(account);
|
||||
const closingNatural = open + (debitSide ? periodDebit - periodCredit : periodCredit - periodDebit);
|
||||
const closingSigned = debitSide ? closingNatural : -closingNatural;
|
||||
|
||||
rows.push({
|
||||
code: account.code,
|
||||
name: account.name,
|
||||
group: account.group,
|
||||
openingDebit: debitSide && open > 0 ? open : !debitSide && open < 0 ? -open : 0,
|
||||
openingCredit: !debitSide && open > 0 ? open : debitSide && open < 0 ? -open : 0,
|
||||
periodDebit,
|
||||
periodCredit,
|
||||
closingDebit: closingSigned > 0 ? closingSigned : 0,
|
||||
closingCredit: closingSigned < 0 ? -closingSigned : 0
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function profitAndLoss(range: GlDateRange, branchCode: string): { income: PnlRow[]; expense: PnlRow[] } {
|
||||
const turnover = periodTurnover(range, branchCode);
|
||||
const income: PnlRow[] = [];
|
||||
const expense: PnlRow[] = [];
|
||||
|
||||
for (const account of GL_ACCOUNTS) {
|
||||
if (account.type !== 'INCOME' && account.type !== 'EXPENSE') {
|
||||
continue;
|
||||
}
|
||||
const move = turnover.get(account.code);
|
||||
if (!move) {
|
||||
continue;
|
||||
}
|
||||
const amount = account.type === 'INCOME' ? move.credit - move.debit : move.debit - move.credit;
|
||||
if (!amount) {
|
||||
continue;
|
||||
}
|
||||
const row: PnlRow = { code: account.code, name: account.name, group: account.group, amount };
|
||||
(account.type === 'INCOME' ? income : expense).push(row);
|
||||
}
|
||||
return { income, expense };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function balanceSheet(
|
||||
range: GlDateRange,
|
||||
branchCode: string
|
||||
): {
|
||||
assets: BalanceSheetRow[];
|
||||
liabilities: BalanceSheetRow[];
|
||||
surplus: number;
|
||||
} {
|
||||
const closing = cumulativeBalances(range.to, branchCode);
|
||||
const assets: BalanceSheetRow[] = [];
|
||||
const liabilities: BalanceSheetRow[] = [];
|
||||
let income = 0;
|
||||
let expense = 0;
|
||||
|
||||
for (const account of GL_ACCOUNTS) {
|
||||
const bal = closing.get(account.code) ?? 0;
|
||||
if (account.type === 'INCOME') {
|
||||
income += bal;
|
||||
continue;
|
||||
}
|
||||
if (account.type === 'EXPENSE') {
|
||||
expense += bal;
|
||||
continue;
|
||||
}
|
||||
if (account.type === 'CONTINGENT' || !bal) {
|
||||
continue;
|
||||
}
|
||||
const row: BalanceSheetRow = { code: account.code, name: account.name, group: account.group, amount: bal };
|
||||
(account.type === 'ASSET' ? assets : liabilities).push(row);
|
||||
}
|
||||
return { assets, liabilities, surplus: income - expense };
|
||||
}
|
||||
|
||||
/** Current period against the equivalent span immediately before it. */
|
||||
export function comparativeBalanceSheet(range: GlDateRange, branchCode: string): ComparativeRow[] {
|
||||
const priorTo = previousDay(range.from);
|
||||
const current = cumulativeBalances(range.to, branchCode);
|
||||
const previous = cumulativeBalances(priorTo, branchCode);
|
||||
const rows: ComparativeRow[] = [];
|
||||
|
||||
for (const account of GL_ACCOUNTS) {
|
||||
if (account.type !== 'ASSET' && account.type !== 'LIABILITY') {
|
||||
continue;
|
||||
}
|
||||
const cur = current.get(account.code) ?? 0;
|
||||
const prev = previous.get(account.code) ?? 0;
|
||||
if (!cur && !prev) {
|
||||
continue;
|
||||
}
|
||||
const variance = cur - prev;
|
||||
rows.push({
|
||||
code: account.code,
|
||||
name: account.name,
|
||||
group: account.group,
|
||||
side: account.type,
|
||||
current: cur,
|
||||
previous: prev,
|
||||
variance,
|
||||
variancePct: prev ? (variance / Math.abs(prev)) * 100 : null
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function contingentLiabilities(range: GlDateRange, branchCode: string): BalanceSheetRow[] {
|
||||
const closing = cumulativeBalances(range.to, branchCode);
|
||||
return GL_ACCOUNTS.filter((a) => a.type === 'CONTINGENT')
|
||||
.map((a) => ({ code: a.code, name: a.name, group: a.group, amount: closing.get(a.code) ?? 0 }))
|
||||
.filter((r) => r.amount !== 0);
|
||||
}
|
||||
|
||||
export function glWiseDrCr(range: GlDateRange, branchCode: string): GlWiseRow[] {
|
||||
const turnover = periodTurnover(range, branchCode);
|
||||
const rows: GlWiseRow[] = [];
|
||||
for (const account of GL_ACCOUNTS) {
|
||||
const move = turnover.get(account.code);
|
||||
if (!move || (!move.debit && !move.credit)) {
|
||||
continue;
|
||||
}
|
||||
rows.push({
|
||||
code: account.code,
|
||||
name: account.name,
|
||||
group: account.group,
|
||||
type: account.type,
|
||||
debit: move.debit,
|
||||
credit: move.credit,
|
||||
net: move.debit - move.credit,
|
||||
vouchers: move.vouchers.size
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function miscExpenseLedger(range: GlDateRange, branchCode: string): MiscExpenseRow[] {
|
||||
const miscCodes = new Set(GL_ACCOUNTS.filter((a) => a.miscExpense).map((a) => a.code));
|
||||
const rows: MiscExpenseRow[] = [];
|
||||
for (const p of glPostings()) {
|
||||
if (p.date < range.from || p.date > range.to || !matchesBranch(p, branchCode)) {
|
||||
continue;
|
||||
}
|
||||
if (!miscCodes.has(p.accountCode) || !p.debit) {
|
||||
continue;
|
||||
}
|
||||
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
|
||||
});
|
||||
}
|
||||
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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface GlReportSet {
|
||||
trialBalance: TrialBalanceRow[];
|
||||
pnl: { income: PnlRow[]; expense: PnlRow[] };
|
||||
balanceSheet: { assets: BalanceSheetRow[]; liabilities: BalanceSheetRow[]; surplus: number };
|
||||
comparative: ComparativeRow[];
|
||||
contingent: BalanceSheetRow[];
|
||||
glWise: GlWiseRow[];
|
||||
miscExpense: MiscExpenseRow[];
|
||||
}
|
||||
|
||||
let cacheKey = '';
|
||||
let cached: GlReportSet | null = null;
|
||||
|
||||
/** All seven reports for one filter selection, computed once per selection. */
|
||||
export function buildReports(range: GlDateRange, branchCode: string): GlReportSet {
|
||||
const key = `${range.from}|${range.to}|${branchCode}`;
|
||||
if (cached && cacheKey === key) {
|
||||
return cached;
|
||||
}
|
||||
cached = {
|
||||
trialBalance: trialBalance(range, branchCode),
|
||||
pnl: profitAndLoss(range, branchCode),
|
||||
balanceSheet: balanceSheet(range, branchCode),
|
||||
comparative: comparativeBalanceSheet(range, branchCode),
|
||||
contingent: contingentLiabilities(range, branchCode),
|
||||
glWise: glWiseDrCr(range, branchCode),
|
||||
miscExpense: miscExpenseLedger(range, branchCode)
|
||||
};
|
||||
cacheKey = key;
|
||||
return cached;
|
||||
}
|
||||
|
||||
/** Clamp a range to the generated ledger so filters cannot query empty space. */
|
||||
export function clampToLedger(range: GlDateRange): GlDateRange {
|
||||
return {
|
||||
from: range.from < GL_PERIOD.fyStart ? GL_PERIOD.fyStart : range.from,
|
||||
to: range.to > GL_PERIOD.fyEnd ? GL_PERIOD.fyEnd : range.to
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
return rows.reduce((acc, r) => acc + pick(r), 0);
|
||||
}
|
||||
|
||||
/** Groups rows by their `group` field, preserving chart-of-accounts order. */
|
||||
export function groupBy<T extends { group: string }>(rows: T[]): { group: string; rows: T[]; total: number }[] {
|
||||
const order: string[] = [];
|
||||
const map = new Map<string, T[]>();
|
||||
for (const r of rows) {
|
||||
if (!map.has(r.group)) {
|
||||
map.set(r.group, []);
|
||||
order.push(r.group);
|
||||
}
|
||||
map.get(r.group)!.push(r);
|
||||
}
|
||||
return order.map((g) => ({ group: g, rows: map.get(g)!, total: 0 }));
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* 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 { GlReportsRoutingModule } from './gl-reports-routing.module';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
SharedModule,
|
||||
GlReportsRoutingModule
|
||||
]
|
||||
})
|
||||
export class GlReportsModule {}
|
||||
+58
@@ -397,6 +397,64 @@
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<!-- ════════════ CONTINGENT LIABILITIES ════════════ -->
|
||||
<ng-container *ngIf="activeReport === 'CONTINGENT-LIAB'">
|
||||
<div class="acr-stats">
|
||||
<div class="acr-stat-card alert">
|
||||
<div class="acr-stat-l">Total Contingent Liabilities — {{ bank.fiscalYear }}</div>
|
||||
<div class="acr-stat-v">₹ {{ contingentLiab.current | number: '1.2-2' }} Lakh</div>
|
||||
<div class="acr-stat-meta">vs {{ contingentLiab.previous | number: '1.2-2' }} Lakh (PY)</div>
|
||||
</div>
|
||||
<div class="acr-stat-card" [ngClass]="clVariation <= 0 ? 'good' : 'alert'">
|
||||
<div class="acr-stat-l">Variation over Previous Year</div>
|
||||
<div class="acr-stat-v">{{ clVariation >= 0 ? '+' : '' }}{{ clVariation | number: '1.2-2' }} Lakh</div>
|
||||
<div class="acr-stat-meta">{{ clVariationPct >= 0 ? '+' : '' }}{{ clVariationPct.toFixed(2) }}%</div>
|
||||
</div>
|
||||
<div class="acr-stat-card">
|
||||
<div class="acr-stat-l">As On Date</div>
|
||||
<div class="acr-stat-v" style="font-size: 16px">{{ bank.asOnDate }}</div>
|
||||
<div class="acr-stat-meta">Schedule 12</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="acr-section">
|
||||
<div class="acr-sec-h">
|
||||
<h3 class="acr-sec-t">Contingent Liabilities — As on {{ bank.asOnDate }} (₹ in Lakhs)</h3>
|
||||
<button mat-stroked-button class="acr-btn-xls" (click)="exportContingentLiabCsv()">
|
||||
<fa-icon icon="file-csv"></fa-icon> Export CSV
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="acr-tbl-wrap">
|
||||
<table class="acr-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th colspan="2" class="acr-sub-h">Items Covered Under This Schedule</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let item of contingentLiabItems">
|
||||
<td class="mono">{{ item.code }}</td>
|
||||
<td>{{ item.label }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="row-total expense">
|
||||
<td colspan="2">
|
||||
<strong>Total Contingent Liabilities: ₹ {{ contingentLiab.current | number: '1.2-2' }} Lakh</strong>
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
<p class="acr-note">
|
||||
<fa-icon icon="info-circle"></fa-icon> Individual item-wise figures for this schedule are not broken
|
||||
out here — only the audited total is shown, to avoid presenting a sub-item split that could not be
|
||||
independently verified against the source statement.
|
||||
</p>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<!-- ════════════ GL DR/CR ════════════ -->
|
||||
<ng-container *ngIf="activeReport === 'GL-DR-CR'">
|
||||
<div class="acr-stats">
|
||||
|
||||
+10
@@ -318,6 +318,16 @@
|
||||
border-bottom: 1px dashed var(--acr-g200);
|
||||
}
|
||||
|
||||
.acr-note {
|
||||
margin: 12px 0 0;
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
color: var(--acr-g500);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.acr-btn-xls {
|
||||
font-size: 12px !important;
|
||||
height: 34px !important;
|
||||
|
||||
+157
-218
@@ -14,7 +14,7 @@ import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
|
||||
export type AcctReportCode = 'PNL' | 'BALANCE-SHEET' | 'COMPARATIVE-BS' | 'GL-DR-CR' | 'MISC-EXP';
|
||||
export type AcctReportCode = 'PNL' | 'BALANCE-SHEET' | 'COMPARATIVE-BS' | 'CONTINGENT-LIAB' | 'GL-DR-CR' | 'MISC-EXP';
|
||||
|
||||
export interface AcctReportsDialogData {
|
||||
initialReport?: AcctReportCode;
|
||||
@@ -47,6 +47,11 @@ interface GlLine {
|
||||
closing: number;
|
||||
}
|
||||
|
||||
interface ContingentLiabItem {
|
||||
code: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface MiscExpRow {
|
||||
txnDate: string;
|
||||
branch: string;
|
||||
@@ -81,7 +86,7 @@ export class AccountingReportsDialogComponent {
|
||||
activeReport: AcctReportCode;
|
||||
|
||||
bank = {
|
||||
name: 'Bharat Urban Bank Ltd',
|
||||
name: 'CBSNexora Bank Ltd',
|
||||
branch: 'Head Office',
|
||||
asOnDate: '31-03-2026',
|
||||
period: '01-04-2025 to 31-03-2026',
|
||||
@@ -94,7 +99,7 @@ export class AccountingReportsDialogComponent {
|
||||
poDesignation: 'Chief Finance Officer',
|
||||
auditor: 'M/s Sharma & Associates, Chartered Accountants',
|
||||
reportRefNo: '157/2',
|
||||
userCode: 'BUBL01',
|
||||
userCode: 'CBSN01',
|
||||
branchCount: 15
|
||||
};
|
||||
|
||||
@@ -120,6 +125,13 @@ export class AccountingReportsDialogComponent {
|
||||
description: 'Side-by-side comparison of current vs previous fiscal year.',
|
||||
group: 'Financial Statement'
|
||||
},
|
||||
{
|
||||
code: 'CONTINGENT-LIAB' as AcctReportCode,
|
||||
name: 'Contingent Liabilities',
|
||||
icon: 'exclamation-triangle',
|
||||
description: 'Off-balance-sheet claims, guarantees & contingent exposures (Schedule 12).',
|
||||
group: 'Financial Statement'
|
||||
},
|
||||
{
|
||||
code: 'GL-DR-CR' as AcctReportCode,
|
||||
name: 'GL Wise DR/CR',
|
||||
@@ -136,309 +148,192 @@ export class AccountingReportsDialogComponent {
|
||||
}
|
||||
];
|
||||
|
||||
// -------------------- P&L Data (₹ in Lakhs) --------------------
|
||||
// -------------------- P&L Data (₹ in Lakhs) — FY 2025-26 audited figures --------------------
|
||||
pnl: PnlLine[] = [
|
||||
{ code: 'I-01', group: 'INCOME', subgroup: 'Interest Earned', label: 'Interest on Advances', amount: 38420 },
|
||||
{ code: 'I-02', group: 'INCOME', subgroup: 'Interest Earned', label: 'Interest on Investments', amount: 14860 },
|
||||
{ code: 'I-01', group: 'INCOME', subgroup: 'Interest Earned', label: 'Interest on Advances & Bills', amount: 102641.73 },
|
||||
{ code: 'I-02', group: 'INCOME', subgroup: 'Interest Earned', label: 'Income on Investments', amount: 41936.75 },
|
||||
{
|
||||
code: 'I-03',
|
||||
group: 'INCOME',
|
||||
subgroup: 'Interest Earned',
|
||||
label: 'Interest on Balances with RBI / Other Banks',
|
||||
amount: 1240
|
||||
},
|
||||
{
|
||||
code: 'I-04',
|
||||
group: 'INCOME',
|
||||
subgroup: 'Other Income',
|
||||
label: 'Commission, Exchange & Brokerage',
|
||||
amount: 1820
|
||||
amount: 144.2
|
||||
},
|
||||
{ code: 'I-04', group: 'INCOME', subgroup: 'Other Income', label: 'Dividend Income', amount: 4.11 },
|
||||
{
|
||||
code: 'I-05',
|
||||
group: 'INCOME',
|
||||
subgroup: 'Other Income',
|
||||
label: 'Net Income on Sale of Fixed Assets',
|
||||
amount: 0.3
|
||||
},
|
||||
{ code: 'I-06', group: 'INCOME', subgroup: 'Other Income', label: 'Other Income (ATM/POS/UPI/Rental/Misc)', amount: 280 },
|
||||
{
|
||||
code: 'E-01',
|
||||
group: 'EXPENDITURE',
|
||||
subgroup: 'Interest Expended',
|
||||
label: 'Interest Paid on Deposits & Other Borrowings',
|
||||
amount: 62861.8
|
||||
},
|
||||
{ code: 'I-05', group: 'INCOME', subgroup: 'Other Income', label: 'Profit on Sale of Investments', amount: 480 },
|
||||
{ code: 'I-06', group: 'INCOME', subgroup: 'Other Income', label: 'Dividend Income', amount: 142 },
|
||||
{ code: 'I-07', group: 'INCOME', subgroup: 'Other Income', label: 'Miscellaneous Income', amount: 318 },
|
||||
{ code: 'E-01', group: 'EXPENDITURE', subgroup: 'Interest Expended', label: 'Interest on Deposits', amount: 28640 },
|
||||
{
|
||||
code: 'E-02',
|
||||
group: 'EXPENDITURE',
|
||||
subgroup: 'Interest Expended',
|
||||
label: 'Interest on RBI / Inter-Bank Borrowings',
|
||||
amount: 980
|
||||
label: 'Interest Paid on NABARD Borrowings',
|
||||
amount: 47083.71
|
||||
},
|
||||
{
|
||||
code: 'E-03',
|
||||
group: 'EXPENDITURE',
|
||||
subgroup: 'Operating Expenses',
|
||||
label: 'Payments to & Provisions for Employees',
|
||||
amount: 8420
|
||||
label: 'Operating Expenses (Staff, Admin, Establishment)',
|
||||
amount: 14503.79
|
||||
},
|
||||
{
|
||||
code: 'E-04',
|
||||
group: 'EXPENDITURE',
|
||||
subgroup: 'Operating Expenses',
|
||||
label: 'Rent, Taxes & Lighting',
|
||||
amount: 1240
|
||||
subgroup: 'Provisions',
|
||||
label: 'Provision for Standard Assets',
|
||||
amount: 730
|
||||
},
|
||||
{
|
||||
code: 'E-05',
|
||||
group: 'EXPENDITURE',
|
||||
subgroup: 'Provisions',
|
||||
label: 'Provision for Doubtful & Bad Debts (Loans & Advances)',
|
||||
amount: 2068.2
|
||||
},
|
||||
{ code: 'E-05', group: 'EXPENDITURE', subgroup: 'Operating Expenses', label: 'Printing & Stationery', amount: 184 },
|
||||
{
|
||||
code: 'E-06',
|
||||
group: 'EXPENDITURE',
|
||||
subgroup: 'Operating Expenses',
|
||||
label: 'Depreciation on Bank Property',
|
||||
amount: 642
|
||||
subgroup: 'Provisions',
|
||||
label: 'Bad Debts Written Off',
|
||||
amount: 906.44
|
||||
},
|
||||
{
|
||||
code: 'E-07',
|
||||
group: 'EXPENDITURE',
|
||||
subgroup: 'Operating Expenses',
|
||||
label: 'Auditors Fees & Expenses',
|
||||
amount: 124
|
||||
},
|
||||
{
|
||||
code: 'E-08',
|
||||
group: 'EXPENDITURE',
|
||||
subgroup: 'Operating Expenses',
|
||||
label: 'Repairs, Maintenance & Insurance',
|
||||
amount: 386
|
||||
},
|
||||
{
|
||||
code: 'E-09',
|
||||
group: 'EXPENDITURE',
|
||||
subgroup: 'Operating Expenses',
|
||||
label: 'Other Operating Expenses',
|
||||
amount: 824
|
||||
},
|
||||
{
|
||||
code: 'E-10',
|
||||
group: 'EXPENDITURE',
|
||||
subgroup: 'Provisions',
|
||||
label: 'Provisions for NPA / Standard Assets',
|
||||
amount: 4280
|
||||
label: 'Provision for Staff Training Reserve',
|
||||
amount: 245.06
|
||||
},
|
||||
{ code: 'E-11', group: 'EXPENDITURE', subgroup: 'Provisions', label: 'Provisions for Tax', amount: 2120 }
|
||||
{ code: 'E-08', group: 'EXPENDITURE', subgroup: 'Provisions', label: 'Provision for Income Tax', amount: 4600 }
|
||||
];
|
||||
|
||||
// -------------------- Balance Sheet (₹ in Lakhs) --------------------
|
||||
// -------------------- Balance Sheet (₹ in Lakhs) — as on 31-03-2026, audited --------------------
|
||||
bs: BsLine[] = [
|
||||
{
|
||||
code: 'L-01',
|
||||
side: 'CAPITAL-LIAB',
|
||||
group: 'Capital & Reserves',
|
||||
label: 'Capital (Paid-up Share Capital)',
|
||||
amount: 22000,
|
||||
prevAmount: 20800
|
||||
amount: 92837.18,
|
||||
prevAmount: 88842.87
|
||||
},
|
||||
{
|
||||
code: 'L-02',
|
||||
side: 'CAPITAL-LIAB',
|
||||
group: 'Capital & Reserves',
|
||||
label: 'Reserves & Surplus',
|
||||
amount: 32400,
|
||||
prevAmount: 28640
|
||||
amount: 155847.97,
|
||||
prevAmount: 145524.37
|
||||
},
|
||||
{
|
||||
code: 'L-03',
|
||||
side: 'CAPITAL-LIAB',
|
||||
group: 'Deposits',
|
||||
label: 'Demand Deposits — Current Accounts',
|
||||
amount: 84200,
|
||||
prevAmount: 78420
|
||||
label: 'Deposits',
|
||||
amount: 1154054.96,
|
||||
prevAmount: 997239.51
|
||||
},
|
||||
{
|
||||
code: 'L-04',
|
||||
side: 'CAPITAL-LIAB',
|
||||
group: 'Deposits',
|
||||
label: 'Savings Bank Deposits',
|
||||
amount: 218600,
|
||||
prevAmount: 198400
|
||||
group: 'Borrowings',
|
||||
label: 'Borrowings (NABARD / Other Banks)',
|
||||
amount: 1113954.48,
|
||||
prevAmount: 993301.8
|
||||
},
|
||||
{
|
||||
code: 'L-05',
|
||||
side: 'CAPITAL-LIAB',
|
||||
group: 'Deposits',
|
||||
label: 'Term Deposits',
|
||||
amount: 462800,
|
||||
prevAmount: 412400
|
||||
},
|
||||
{
|
||||
code: 'L-06',
|
||||
side: 'CAPITAL-LIAB',
|
||||
group: 'Borrowings',
|
||||
label: 'Borrowings from RBI / NABARD',
|
||||
amount: 14180,
|
||||
prevAmount: 16240
|
||||
},
|
||||
{
|
||||
code: 'L-07',
|
||||
side: 'CAPITAL-LIAB',
|
||||
group: 'Borrowings',
|
||||
label: 'Other Borrowings',
|
||||
amount: 10000,
|
||||
prevAmount: 9420
|
||||
},
|
||||
{
|
||||
code: 'L-08',
|
||||
side: 'CAPITAL-LIAB',
|
||||
group: 'Other Liabilities',
|
||||
label: 'Bills Payable',
|
||||
amount: 4420,
|
||||
prevAmount: 3980
|
||||
},
|
||||
{
|
||||
code: 'L-09',
|
||||
side: 'CAPITAL-LIAB',
|
||||
group: 'Other Liabilities',
|
||||
label: 'Inter-Office Adjustments (Net)',
|
||||
amount: 1820,
|
||||
prevAmount: 1640
|
||||
},
|
||||
{
|
||||
code: 'L-10',
|
||||
side: 'CAPITAL-LIAB',
|
||||
group: 'Other Liabilities',
|
||||
label: 'Interest Accrued',
|
||||
amount: 6240,
|
||||
prevAmount: 5840
|
||||
},
|
||||
{
|
||||
code: 'L-11',
|
||||
side: 'CAPITAL-LIAB',
|
||||
group: 'Other Liabilities',
|
||||
label: 'Other Liabilities & Provisions',
|
||||
amount: 5940,
|
||||
prevAmount: 5420
|
||||
amount: 121037.61,
|
||||
prevAmount: 124706.3
|
||||
},
|
||||
{
|
||||
code: 'A-01',
|
||||
side: 'ASSETS',
|
||||
group: 'Cash & Bank Balances',
|
||||
label: 'Cash on Hand',
|
||||
amount: 8420,
|
||||
prevAmount: 7820
|
||||
label: 'Cash & Balances with RBI',
|
||||
amount: 32627.16,
|
||||
prevAmount: 33361.93
|
||||
},
|
||||
{
|
||||
code: 'A-02',
|
||||
side: 'ASSETS',
|
||||
group: 'Cash & Bank Balances',
|
||||
label: 'Balances with RBI',
|
||||
amount: 38400,
|
||||
prevAmount: 34200
|
||||
group: 'Money at Call',
|
||||
label: 'Balances with Other Banks & Money at Call/Short Notice',
|
||||
amount: 604619.45,
|
||||
prevAmount: 549599.47
|
||||
},
|
||||
{
|
||||
code: 'A-03',
|
||||
side: 'ASSETS',
|
||||
group: 'Cash & Bank Balances',
|
||||
label: 'Balances with Other Banks — Current',
|
||||
amount: 14800,
|
||||
prevAmount: 12400
|
||||
group: 'Investments',
|
||||
label: 'Investments',
|
||||
amount: 316953.61,
|
||||
prevAmount: 248274.61
|
||||
},
|
||||
{
|
||||
code: 'A-04',
|
||||
side: 'ASSETS',
|
||||
group: 'Cash & Bank Balances',
|
||||
label: 'Balances with Other Banks — Term Deposits',
|
||||
amount: 10000,
|
||||
prevAmount: 9200
|
||||
group: 'Advances',
|
||||
label: 'Loans & Advances',
|
||||
amount: 1627711.4,
|
||||
prevAmount: 1466401.79
|
||||
},
|
||||
{
|
||||
code: 'A-05',
|
||||
side: 'ASSETS',
|
||||
group: 'Money at Call',
|
||||
label: 'Money at Call & Short Notice',
|
||||
amount: 12400,
|
||||
prevAmount: 10800
|
||||
group: 'Fixed Assets',
|
||||
label: 'Fixed Assets (Property, Plant & Equipment)',
|
||||
amount: 33791.75,
|
||||
prevAmount: 33727.66
|
||||
},
|
||||
{
|
||||
code: 'A-06',
|
||||
side: 'ASSETS',
|
||||
group: 'Investments',
|
||||
label: 'Government Securities (SLR)',
|
||||
amount: 142400,
|
||||
prevAmount: 128600
|
||||
},
|
||||
{
|
||||
code: 'A-07',
|
||||
side: 'ASSETS',
|
||||
group: 'Investments',
|
||||
label: 'Other Approved Securities',
|
||||
amount: 28200,
|
||||
prevAmount: 24800
|
||||
},
|
||||
{
|
||||
code: 'A-08',
|
||||
side: 'ASSETS',
|
||||
group: 'Investments',
|
||||
label: 'Shares / Debentures / Bonds',
|
||||
amount: 14000,
|
||||
prevAmount: 12200
|
||||
},
|
||||
{
|
||||
code: 'A-09',
|
||||
side: 'ASSETS',
|
||||
group: 'Advances',
|
||||
label: 'Cash Credit, Overdraft & Demand Loans',
|
||||
amount: 168400,
|
||||
prevAmount: 152800
|
||||
},
|
||||
{
|
||||
code: 'A-10',
|
||||
side: 'ASSETS',
|
||||
group: 'Advances',
|
||||
label: 'Term Loans (Secured)',
|
||||
amount: 217865,
|
||||
prevAmount: 198400
|
||||
},
|
||||
{
|
||||
code: 'A-11',
|
||||
side: 'ASSETS',
|
||||
group: 'Advances',
|
||||
label: 'Term Loans (Unsecured)',
|
||||
amount: 30000,
|
||||
prevAmount: 27200
|
||||
},
|
||||
{
|
||||
code: 'A-12',
|
||||
side: 'ASSETS',
|
||||
group: 'Fixed Assets',
|
||||
label: 'Premises (Net of Depreciation)',
|
||||
amount: 9420,
|
||||
prevAmount: 8820
|
||||
},
|
||||
{
|
||||
code: 'A-13',
|
||||
side: 'ASSETS',
|
||||
group: 'Fixed Assets',
|
||||
label: 'Other Fixed Assets',
|
||||
amount: 4780,
|
||||
prevAmount: 4620
|
||||
},
|
||||
{
|
||||
code: 'A-14',
|
||||
side: 'ASSETS',
|
||||
group: 'Other Assets',
|
||||
label: 'Inter-Office Adjustments (Net)',
|
||||
amount: 0,
|
||||
prevAmount: 0
|
||||
},
|
||||
{
|
||||
code: 'A-15',
|
||||
side: 'ASSETS',
|
||||
group: 'Other Assets',
|
||||
label: 'Tax Paid in Advance & TDS',
|
||||
amount: 5420,
|
||||
prevAmount: 4820
|
||||
},
|
||||
{ code: 'A-16', side: 'ASSETS', group: 'Other Assets', label: 'Stationery & Stamps', amount: 184, prevAmount: 142 },
|
||||
{
|
||||
code: 'A-17',
|
||||
side: 'ASSETS',
|
||||
group: 'Other Assets',
|
||||
label: 'Sundry Debtors & Other Assets',
|
||||
amount: 2876,
|
||||
prevAmount: 2540
|
||||
label: 'Other Assets',
|
||||
amount: 22028.83,
|
||||
prevAmount: 18249.4
|
||||
}
|
||||
];
|
||||
|
||||
// -------------------- Contingent Liabilities (Schedule 12, ₹ in Lakhs) --------------------
|
||||
// Only the audited TOTAL is reported here — the schedule's individual line items (claims not
|
||||
// acknowledged as debts, letter of guarantees, DEAF transfers, gratuity/RBI-penalty/GST disputes)
|
||||
// could not be re-verified against the source figures with confidence, so they are omitted
|
||||
// rather than risk showing an unverified breakdown.
|
||||
contingentLiabItems: ContingentLiabItem[] = [
|
||||
{ code: 'I', label: 'Claims against the bank not acknowledged as debts' },
|
||||
{ code: 'II', label: 'Letter of Guarantees' },
|
||||
{ code: 'III', label: 'Amounts transferred to Depositor Education and Awareness Fund (DEAF)' },
|
||||
{ code: 'IV', label: 'Other items for which the bank is contingently liable (Gratuity / RBI Penalty / GST Demand)' }
|
||||
];
|
||||
contingentLiab = {
|
||||
current: 1633.3,
|
||||
previous: 1522.13
|
||||
};
|
||||
get clVariation(): number {
|
||||
return this.variation(this.contingentLiab.current, this.contingentLiab.previous);
|
||||
}
|
||||
get clVariationPct(): number {
|
||||
return this.variationPct(this.contingentLiab.current, this.contingentLiab.previous);
|
||||
}
|
||||
|
||||
// -------------------- GL Wise DR/CR Data --------------------
|
||||
glReport: GlLine[] = [
|
||||
{
|
||||
@@ -1008,6 +903,7 @@ export class AccountingReportsDialogComponent {
|
||||
if (this.activeReport === 'PNL') return this.exportPnlCsv();
|
||||
if (this.activeReport === 'BALANCE-SHEET') return this.exportBsCsv(false);
|
||||
if (this.activeReport === 'COMPARATIVE-BS') return this.exportBsCsv(true);
|
||||
if (this.activeReport === 'CONTINGENT-LIAB') return this.exportContingentLiabCsv();
|
||||
if (this.activeReport === 'GL-DR-CR') return this.exportGlCsv();
|
||||
if (this.activeReport === 'MISC-EXP') return this.exportMiscCsv();
|
||||
}
|
||||
@@ -1155,6 +1051,49 @@ export class AccountingReportsDialogComponent {
|
||||
this.saveCsv(rows, `${comparative ? 'Comparative-' : ''}Balance-Sheet-${this.dateTag()}.csv`);
|
||||
}
|
||||
|
||||
exportContingentLiabCsv(): void {
|
||||
const rows: string[][] = [];
|
||||
rows.push([
|
||||
this.bank.name,
|
||||
this.bank.branch
|
||||
]);
|
||||
rows.push([
|
||||
'CONTINGENT LIABILITIES (SCHEDULE 12)',
|
||||
`As on ${this.bank.asOnDate}`
|
||||
]);
|
||||
rows.push([]);
|
||||
rows.push(['Items covered:']);
|
||||
for (const item of this.contingentLiabItems) {
|
||||
rows.push([
|
||||
item.code,
|
||||
item.label
|
||||
]);
|
||||
}
|
||||
rows.push([]);
|
||||
rows.push([
|
||||
'',
|
||||
'TOTAL CONTINGENT LIABILITIES',
|
||||
`FY ${this.bank.fiscalYear} (₹ Lakh)`,
|
||||
`FY ${this.bank.prevFiscalYear} (₹ Lakh)`,
|
||||
'Variation',
|
||||
'Variation %'
|
||||
]);
|
||||
rows.push([
|
||||
'',
|
||||
'',
|
||||
this.contingentLiab.current.toFixed(2),
|
||||
this.contingentLiab.previous.toFixed(2),
|
||||
this.clVariation.toFixed(2),
|
||||
this.clVariationPct.toFixed(2) + '%'
|
||||
]);
|
||||
rows.push([]);
|
||||
rows.push([
|
||||
'Certified by',
|
||||
this.bank.principalOfficer + ' — ' + this.bank.poDesignation
|
||||
]);
|
||||
this.saveCsv(rows, `Contingent-Liabilities-${this.dateTag()}.csv`);
|
||||
}
|
||||
|
||||
exportGlCsv(): void {
|
||||
const rows: string[][] = [];
|
||||
rows.push([
|
||||
|
||||
@@ -54,7 +54,7 @@ export class AlmReportDialogComponent {
|
||||
activeTab: 'dashboard' | 'ladder' | 'monthly' | 'returns' = 'dashboard';
|
||||
|
||||
bank = {
|
||||
name: 'Bharat Urban Bank Ltd',
|
||||
name: 'CBSNexora Bank Ltd',
|
||||
branch: 'Head Office',
|
||||
asOnDate: '31-03-2026',
|
||||
quarter: 'Q4 FY 2025-26',
|
||||
@@ -63,7 +63,7 @@ export class AlmReportDialogComponent {
|
||||
principalOfficer: 'Karthik Kamath',
|
||||
formCode: 'Form ALM-1',
|
||||
reportRefNo: '1036',
|
||||
userCode: 'BUBL01',
|
||||
userCode: 'CBSN01',
|
||||
/** Net Demand & Time Liabilities — denominator for ratios */
|
||||
ndtl: 740000, // ₹7,400 Cr in Lakhs
|
||||
/** RBI tolerance: cumulative negative gap up to 14 days ≤ 5%, 28 days ≤ 10% */
|
||||
|
||||
@@ -14,7 +14,7 @@ import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
|
||||
export type CimsFormCode = 'OSS-2' | 'PSA' | 'RAL' | 'RCP-UCB';
|
||||
export type CimsFormCode = 'OSS-2' | 'PSA' | 'RAL' | 'RCP-UCB' | 'OSS-4' | 'OSS-5' | 'OSS-6' | 'OSS-7';
|
||||
|
||||
export interface CimsXbrlDialogData {
|
||||
initialForm?: CimsFormCode;
|
||||
@@ -64,20 +64,20 @@ export class CimsXbrlDialogComponent {
|
||||
activeTab: 'filing' | 'data' | 'preview' = 'filing';
|
||||
|
||||
bank = {
|
||||
name: 'Bharat Urban Bank Ltd',
|
||||
name: 'CBSNexora Bank Ltd',
|
||||
branch: 'Head Office',
|
||||
bsrCode: 'BUBL0000123',
|
||||
ifsc: 'BUBL0000123',
|
||||
legalEntityId: 'BUBL-LEI-2025',
|
||||
bsrCode: 'CBSN0000123',
|
||||
ifsc: 'CBSN0000123',
|
||||
legalEntityId: 'CBSN-LEI-2025',
|
||||
pan: 'AAACB1234L',
|
||||
asOnDate: '31-03-2026',
|
||||
quarter: 'Q4 FY 2025-26',
|
||||
fiscalYear: '2025-26',
|
||||
principalOfficer: 'Karthik Kamath',
|
||||
poDesignation: 'Chief Compliance Officer',
|
||||
poEmail: 'compliance@bharaturbanbank.in',
|
||||
poEmail: 'compliance@cbsnexorabank.in',
|
||||
poMobile: '+91-9988002211',
|
||||
cimsUserId: 'BUBL_CIMS_001',
|
||||
cimsUserId: 'CBSN_CIMS_001',
|
||||
cimsPortal: 'https://cims.rbi.org.in/'
|
||||
};
|
||||
|
||||
@@ -97,7 +97,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'oss2:CapAdq.CRAR',
|
||||
section: 'Capital Adequacy',
|
||||
label: 'CRAR (Total)',
|
||||
value: 14.82,
|
||||
value: 21.82,
|
||||
unit: 'Percent',
|
||||
type: 'percent'
|
||||
},
|
||||
@@ -105,7 +105,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'oss2:CapAdq.Tier1',
|
||||
section: 'Capital Adequacy',
|
||||
label: 'Tier-I CRAR',
|
||||
value: 12.18,
|
||||
value: 20.51,
|
||||
unit: 'Percent',
|
||||
type: 'percent'
|
||||
},
|
||||
@@ -113,7 +113,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'oss2:CapAdq.Tier2',
|
||||
section: 'Capital Adequacy',
|
||||
label: 'Tier-II CRAR',
|
||||
value: 2.64,
|
||||
value: 1.31,
|
||||
unit: 'Percent',
|
||||
type: 'percent'
|
||||
},
|
||||
@@ -121,7 +121,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'oss2:Asset.Total',
|
||||
section: 'Asset Classification',
|
||||
label: 'Total Advances (₹ Lakh)',
|
||||
value: 416265,
|
||||
value: 4112.04,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
@@ -129,7 +129,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'oss2:Asset.Standard',
|
||||
section: 'Asset Classification',
|
||||
label: 'Standard Assets (₹ Lakh)',
|
||||
value: 401820,
|
||||
value: 3845.88,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
@@ -137,7 +137,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'oss2:Asset.SubStd',
|
||||
section: 'Asset Classification',
|
||||
label: 'Sub-Standard (₹ Lakh)',
|
||||
value: 6220,
|
||||
value: 102.41,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
@@ -145,7 +145,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'oss2:Asset.Doubtful',
|
||||
section: 'Asset Classification',
|
||||
label: 'Doubtful (₹ Lakh)',
|
||||
value: 5840,
|
||||
value: 163.75,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
@@ -153,7 +153,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'oss2:Asset.Loss',
|
||||
section: 'Asset Classification',
|
||||
label: 'Loss (₹ Lakh)',
|
||||
value: 2385,
|
||||
value: 0,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
@@ -161,7 +161,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'oss2:Asset.GNPA',
|
||||
section: 'NPA Position',
|
||||
label: 'Gross NPA (₹ Lakh)',
|
||||
value: 14445,
|
||||
value: 266.16,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
@@ -169,7 +169,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'oss2:Asset.NNPA',
|
||||
section: 'NPA Position',
|
||||
label: 'Net NPA (₹ Lakh)',
|
||||
value: 5212,
|
||||
value: -96.33,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
@@ -177,7 +177,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'oss2:Asset.GNPAPct',
|
||||
section: 'NPA Position',
|
||||
label: 'GNPA %',
|
||||
value: 3.47,
|
||||
value: 6.47,
|
||||
unit: 'Percent',
|
||||
type: 'percent'
|
||||
},
|
||||
@@ -185,7 +185,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'oss2:Asset.NNPAPct',
|
||||
section: 'NPA Position',
|
||||
label: 'NNPA %',
|
||||
value: 1.27,
|
||||
value: -2.6,
|
||||
unit: 'Percent',
|
||||
type: 'percent'
|
||||
},
|
||||
@@ -193,7 +193,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'oss2:Asset.PCR',
|
||||
section: 'NPA Position',
|
||||
label: 'Provision Coverage Ratio',
|
||||
value: 64.13,
|
||||
value: 136.24,
|
||||
unit: 'Percent',
|
||||
type: 'percent'
|
||||
},
|
||||
@@ -201,7 +201,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'oss2:Profit.NIM',
|
||||
section: 'Profitability',
|
||||
label: 'Net Interest Margin',
|
||||
value: 3.21,
|
||||
value: 0.51,
|
||||
unit: 'Percent',
|
||||
type: 'percent'
|
||||
},
|
||||
@@ -209,7 +209,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'oss2:Profit.ROA',
|
||||
section: 'Profitability',
|
||||
label: 'Return on Assets',
|
||||
value: 0.92,
|
||||
value: 0.67,
|
||||
unit: 'Percent',
|
||||
type: 'percent'
|
||||
},
|
||||
@@ -217,7 +217,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'oss2:Profit.ROE',
|
||||
section: 'Profitability',
|
||||
label: 'Return on Equity',
|
||||
value: 11.48,
|
||||
value: 5.38,
|
||||
unit: 'Percent',
|
||||
type: 'percent'
|
||||
},
|
||||
@@ -254,7 +254,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'psa:Reference.ANBC',
|
||||
section: 'Reference',
|
||||
label: 'Adjusted Net Bank Credit (₹ Lakh)',
|
||||
value: 600000,
|
||||
value: 3274.83,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
@@ -262,7 +262,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'psa:Reference.OBE',
|
||||
section: 'Reference',
|
||||
label: 'Credit Equivalent of OBE (₹ Lakh)',
|
||||
value: 25000,
|
||||
value: 0,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
@@ -270,7 +270,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'psa:PSL.Total',
|
||||
section: 'PSL Achievement',
|
||||
label: 'Total PSL Outstanding (₹ Lakh)',
|
||||
value: 241700,
|
||||
value: 2485.67,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
@@ -278,7 +278,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'psa:PSL.TotalPct',
|
||||
section: 'PSL Achievement',
|
||||
label: 'Total PSL % of ANBC',
|
||||
value: 40.28,
|
||||
value: 75.9,
|
||||
unit: 'Percent',
|
||||
type: 'percent'
|
||||
},
|
||||
@@ -286,7 +286,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'psa:PSL.Agri',
|
||||
section: 'Sub-targets',
|
||||
label: 'Agriculture (₹ Lakh)',
|
||||
value: 124000,
|
||||
value: 39.0,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
@@ -294,7 +294,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'psa:PSL.AgriPct',
|
||||
section: 'Sub-targets',
|
||||
label: 'Agriculture % of ANBC',
|
||||
value: 20.67,
|
||||
value: 1.19,
|
||||
unit: 'Percent',
|
||||
type: 'percent'
|
||||
},
|
||||
@@ -302,7 +302,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'psa:PSL.SMF',
|
||||
section: 'Sub-targets',
|
||||
label: 'Small & Marginal Farmers (₹ Lakh)',
|
||||
value: 72000,
|
||||
value: 0,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
@@ -310,23 +310,23 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'psa:PSL.SMFPct',
|
||||
section: 'Sub-targets',
|
||||
label: 'SMF % of ANBC',
|
||||
value: 12.0,
|
||||
value: 0,
|
||||
unit: 'Percent',
|
||||
type: 'percent'
|
||||
},
|
||||
{
|
||||
ref: 'psa:PSL.MicroEnt',
|
||||
section: 'Sub-targets',
|
||||
label: 'Micro Enterprises (₹ Lakh)',
|
||||
value: 51000,
|
||||
label: 'MSME Enterprises (₹ Lakh)',
|
||||
value: 2313.83,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
{
|
||||
ref: 'psa:PSL.MicroPct',
|
||||
section: 'Sub-targets',
|
||||
label: 'Micro Enterprises % of ANBC',
|
||||
value: 8.5,
|
||||
label: 'MSME Enterprises % of ANBC',
|
||||
value: 70.65,
|
||||
unit: 'Percent',
|
||||
type: 'percent'
|
||||
},
|
||||
@@ -334,7 +334,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'psa:PSL.Weaker',
|
||||
section: 'Sub-targets',
|
||||
label: 'Weaker Sections (₹ Lakh)',
|
||||
value: 85500,
|
||||
value: 990.28,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
@@ -342,7 +342,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'psa:PSL.WeakerPct',
|
||||
section: 'Sub-targets',
|
||||
label: 'Weaker Sections % of ANBC',
|
||||
value: 14.25,
|
||||
value: 30.22,
|
||||
unit: 'Percent',
|
||||
type: 'percent'
|
||||
},
|
||||
@@ -350,7 +350,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'psa:PSL.Housing',
|
||||
section: 'Other Categories',
|
||||
label: 'Housing (₹ Lakh)',
|
||||
value: 9200,
|
||||
value: 115.12,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
@@ -358,7 +358,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'psa:PSL.Education',
|
||||
section: 'Other Categories',
|
||||
label: 'Education (₹ Lakh)',
|
||||
value: 4200,
|
||||
value: 17.32,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
@@ -366,7 +366,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'psa:PSL.Export',
|
||||
section: 'Other Categories',
|
||||
label: 'Export Credit (₹ Lakh)',
|
||||
value: 3800,
|
||||
value: 0,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
@@ -374,7 +374,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'psa:PSL.RenEnergy',
|
||||
section: 'Other Categories',
|
||||
label: 'Renewable Energy (₹ Lakh)',
|
||||
value: 2400,
|
||||
value: 0,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
}
|
||||
@@ -395,15 +395,15 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'ral:Liab.Capital',
|
||||
section: 'Liabilities',
|
||||
label: 'Capital (₹ Lakh)',
|
||||
value: 22000,
|
||||
value: 272.78,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
{
|
||||
ref: 'ral:Liab.Reserves',
|
||||
section: 'Liabilities',
|
||||
label: 'Reserves & Surplus (₹ Lakh)',
|
||||
value: 32400,
|
||||
label: 'Reserves, Surplus & Profit (₹ Lakh)',
|
||||
value: 712.6,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
@@ -411,7 +411,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'ral:Liab.DepDemand',
|
||||
section: 'Liabilities',
|
||||
label: 'Demand Deposits (₹ Lakh)',
|
||||
value: 84200,
|
||||
value: 195.92,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
@@ -419,7 +419,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'ral:Liab.DepSavings',
|
||||
section: 'Liabilities',
|
||||
label: 'Savings Deposits (₹ Lakh)',
|
||||
value: 218600,
|
||||
value: 2081.16,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
@@ -427,7 +427,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'ral:Liab.DepTerm',
|
||||
section: 'Liabilities',
|
||||
label: 'Term Deposits (₹ Lakh)',
|
||||
value: 462800,
|
||||
value: 4066.01,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
@@ -435,7 +435,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'ral:Liab.Borrow',
|
||||
section: 'Liabilities',
|
||||
label: 'Borrowings (₹ Lakh)',
|
||||
value: 24180,
|
||||
value: 0,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
@@ -443,7 +443,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'ral:Liab.Other',
|
||||
section: 'Liabilities',
|
||||
label: 'Other Liabilities & Provisions (₹ Lakh)',
|
||||
value: 18420,
|
||||
value: 780.73,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
@@ -451,7 +451,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'ral:Asset.Cash',
|
||||
section: 'Assets',
|
||||
label: 'Cash on Hand (₹ Lakh)',
|
||||
value: 8420,
|
||||
value: 99.96,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
@@ -459,15 +459,15 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'ral:Asset.RBI',
|
||||
section: 'Assets',
|
||||
label: 'Balances with RBI (₹ Lakh)',
|
||||
value: 38400,
|
||||
value: 0,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
{
|
||||
ref: 'ral:Asset.Banks',
|
||||
section: 'Assets',
|
||||
label: 'Balances with Banks (₹ Lakh)',
|
||||
value: 24800,
|
||||
label: 'Balances with Banks & Other Institutions (₹ Lakh)',
|
||||
value: 1349.53,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
@@ -475,7 +475,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'ral:Asset.Money',
|
||||
section: 'Assets',
|
||||
label: 'Money at Call & Short Notice (₹ Lakh)',
|
||||
value: 12400,
|
||||
value: 0,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
@@ -483,7 +483,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'ral:Asset.Invest',
|
||||
section: 'Assets',
|
||||
label: 'Investments (₹ Lakh)',
|
||||
value: 184600,
|
||||
value: 2342.97,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
@@ -491,7 +491,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'ral:Asset.Adv',
|
||||
section: 'Assets',
|
||||
label: 'Advances (₹ Lakh)',
|
||||
value: 416265,
|
||||
value: 3912.88,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
@@ -499,23 +499,23 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'ral:Asset.Fixed',
|
||||
section: 'Assets',
|
||||
label: 'Fixed Assets (₹ Lakh)',
|
||||
value: 14200,
|
||||
value: 49.48,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
{
|
||||
ref: 'ral:Asset.Other',
|
||||
section: 'Assets',
|
||||
label: 'Other Assets (₹ Lakh)',
|
||||
value: 12480,
|
||||
label: 'Other Assets & Interest Receivable (₹ Lakh)',
|
||||
value: 354.38,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
{
|
||||
ref: 'ral:OBE.Guar',
|
||||
section: 'Off-Balance Sheet',
|
||||
label: 'Bank Guarantees (₹ Lakh)',
|
||||
value: 18420,
|
||||
label: 'Contingent Liabilities (₹ Lakh)',
|
||||
value: 26.38,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
@@ -523,7 +523,7 @@ export class CimsXbrlDialogComponent {
|
||||
ref: 'ral:OBE.LC',
|
||||
section: 'Off-Balance Sheet',
|
||||
label: 'Letters of Credit (₹ Lakh)',
|
||||
value: 6580,
|
||||
value: 0,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
}
|
||||
@@ -653,6 +653,293 @@ export class CimsXbrlDialogComponent {
|
||||
type: 'monetary'
|
||||
}
|
||||
]
|
||||
},
|
||||
'OSS-4': {
|
||||
code: 'OSS-4',
|
||||
name: 'OSS-4',
|
||||
fullName: 'Connected Lending & Large Exposure Statement',
|
||||
description:
|
||||
'Quarterly statement of loans to directors/relatives, senior officers, and exposures exceeding prudential thresholds. Illustrative figures — no source filing was provided for this return; values are modelled on this bank\'s own capital/advances scale for consistency.',
|
||||
cimsCode: 'CIMS_OSS_004',
|
||||
taxonomy: 'rbi-cims-oss4-2024-04-01',
|
||||
periodicity: 'Quarterly',
|
||||
lastFiled: '15-01-2026',
|
||||
nextDue: '15-04-2026',
|
||||
fields: [
|
||||
{
|
||||
ref: 'oss4:Connected.Directors',
|
||||
section: 'Connected Lending',
|
||||
label: 'Loans to Directors & Relatives (₹ Lakh)',
|
||||
value: 42.5,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
{
|
||||
ref: 'oss4:Connected.SrOfficers',
|
||||
section: 'Connected Lending',
|
||||
label: 'Loans to Senior Officers (₹ Lakh)',
|
||||
value: 18.75,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
{
|
||||
ref: 'oss4:Connected.Accounts',
|
||||
section: 'Connected Lending',
|
||||
label: 'Number of Connected Lending Accounts',
|
||||
value: 12,
|
||||
unit: 'Number',
|
||||
type: 'numeric'
|
||||
},
|
||||
{
|
||||
ref: 'oss4:LargeExp.Amount',
|
||||
section: 'Large Exposures',
|
||||
label: 'Exposures > 10% of Capital Funds (₹ Lakh)',
|
||||
value: 285.0,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
{
|
||||
ref: 'oss4:LargeExp.Pct',
|
||||
section: 'Large Exposures',
|
||||
label: 'Large Exposures as % of Capital Funds',
|
||||
value: 30.58,
|
||||
unit: 'Percent',
|
||||
type: 'percent'
|
||||
},
|
||||
{
|
||||
ref: 'oss4:LargeExp.SingleBreach',
|
||||
section: 'Large Exposures',
|
||||
label: 'Single Borrower Limit Breaches',
|
||||
value: 0,
|
||||
unit: 'Number',
|
||||
type: 'numeric'
|
||||
},
|
||||
{
|
||||
ref: 'oss4:LargeExp.GroupBreach',
|
||||
section: 'Large Exposures',
|
||||
label: 'Group Borrower Limit Breaches',
|
||||
value: 0,
|
||||
unit: 'Number',
|
||||
type: 'numeric'
|
||||
}
|
||||
]
|
||||
},
|
||||
'OSS-5': {
|
||||
code: 'OSS-5',
|
||||
name: 'OSS-5',
|
||||
fullName: 'Institutional & Membership Profile',
|
||||
description:
|
||||
'Annual statement of branch network, staff strength, membership and depositor/borrower base. Illustrative figures — no source filing was provided for this return; membership share capital is aligned to this bank\'s real paid-up capital figure.',
|
||||
cimsCode: 'CIMS_OSS_005',
|
||||
taxonomy: 'rbi-cims-oss5-2024-04-01',
|
||||
periodicity: 'Annual',
|
||||
lastFiled: '30-04-2025',
|
||||
nextDue: '30-04-2026',
|
||||
fields: [
|
||||
{
|
||||
ref: 'oss5:Profile.Branches',
|
||||
section: 'Network',
|
||||
label: 'Number of Branches',
|
||||
value: 8,
|
||||
unit: 'Number',
|
||||
type: 'numeric'
|
||||
},
|
||||
{
|
||||
ref: 'oss5:Profile.Employees',
|
||||
section: 'Network',
|
||||
label: 'Number of Employees',
|
||||
value: 64,
|
||||
unit: 'Number',
|
||||
type: 'numeric'
|
||||
},
|
||||
{
|
||||
ref: 'oss5:Profile.AreaOfOperation',
|
||||
section: 'Network',
|
||||
label: 'Area of Operation',
|
||||
value: 'Single District',
|
||||
type: 'string'
|
||||
},
|
||||
{
|
||||
ref: 'oss5:Profile.Members',
|
||||
section: 'Membership',
|
||||
label: 'Number of Regular Members',
|
||||
value: 18450,
|
||||
unit: 'Number',
|
||||
type: 'numeric'
|
||||
},
|
||||
{
|
||||
ref: 'oss5:Profile.ShareCapital',
|
||||
section: 'Membership',
|
||||
label: 'Membership Share Capital (₹ Lakh)',
|
||||
value: 272.78,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
{
|
||||
ref: 'oss5:Profile.Depositors',
|
||||
section: 'Customer Base',
|
||||
label: 'Number of Depositors',
|
||||
value: 42180,
|
||||
unit: 'Number',
|
||||
type: 'numeric'
|
||||
},
|
||||
{
|
||||
ref: 'oss5:Profile.Borrowers',
|
||||
section: 'Customer Base',
|
||||
label: 'Number of Borrowers',
|
||||
value: 3860,
|
||||
unit: 'Number',
|
||||
type: 'numeric'
|
||||
}
|
||||
]
|
||||
},
|
||||
'OSS-6': {
|
||||
code: 'OSS-6',
|
||||
name: 'OSS-6',
|
||||
fullName: 'Structural Liquidity & Interest Rate Sensitivity Statement',
|
||||
description:
|
||||
'Fortnightly maturity-bucket gap analysis of rate-sensitive assets and liabilities. Illustrative figures — no source filing was provided for this return.',
|
||||
cimsCode: 'CIMS_OSS_006',
|
||||
taxonomy: 'rbi-cims-oss6-2024-04-01',
|
||||
periodicity: 'Monthly',
|
||||
lastFiled: '14-04-2026',
|
||||
nextDue: '28-04-2026',
|
||||
fields: [
|
||||
{
|
||||
ref: 'oss6:Gap.1to14Days',
|
||||
section: 'Maturity Gap (₹ Lakh)',
|
||||
label: '1-14 Days',
|
||||
value: -45.2,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
{
|
||||
ref: 'oss6:Gap.15to28Days',
|
||||
section: 'Maturity Gap (₹ Lakh)',
|
||||
label: '15-28 Days',
|
||||
value: -12.8,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
{
|
||||
ref: 'oss6:Gap.29DaysTo3M',
|
||||
section: 'Maturity Gap (₹ Lakh)',
|
||||
label: '29 Days to 3 Months',
|
||||
value: 88.4,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
{
|
||||
ref: 'oss6:Gap.Over3To6M',
|
||||
section: 'Maturity Gap (₹ Lakh)',
|
||||
label: 'Over 3 Months to 6 Months',
|
||||
value: 62.15,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
{
|
||||
ref: 'oss6:Gap.Over6MTo1Y',
|
||||
section: 'Maturity Gap (₹ Lakh)',
|
||||
label: 'Over 6 Months to 1 Year',
|
||||
value: -28.9,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
{
|
||||
ref: 'oss6:Gap.Over1To3Y',
|
||||
section: 'Maturity Gap (₹ Lakh)',
|
||||
label: 'Over 1 Year to 3 Years',
|
||||
value: 210.6,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
{
|
||||
ref: 'oss6:Gap.Over3To5Y',
|
||||
section: 'Maturity Gap (₹ Lakh)',
|
||||
label: 'Over 3 Years to 5 Years',
|
||||
value: 95.3,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
{
|
||||
ref: 'oss6:Gap.Over5Y',
|
||||
section: 'Maturity Gap (₹ Lakh)',
|
||||
label: 'Over 5 Years',
|
||||
value: -18.75,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
{
|
||||
ref: 'oss6:Liq.CumNegGapPct',
|
||||
section: 'Liquidity Ratios',
|
||||
label: 'Cumulative Negative Gap (14-Day) as % of Cumulative Outflows',
|
||||
value: 4.85,
|
||||
unit: 'Percent',
|
||||
type: 'percent'
|
||||
}
|
||||
]
|
||||
},
|
||||
'OSS-7': {
|
||||
code: 'OSS-7',
|
||||
name: 'OSS-7',
|
||||
fullName: 'Statement of Frauds & Customer Complaints',
|
||||
description:
|
||||
'Quarterly statement of frauds reported and customer-complaint disposal. Illustrative figures — no source filing was provided for this return.',
|
||||
cimsCode: 'CIMS_OSS_007',
|
||||
taxonomy: 'rbi-cims-oss7-2024-04-01',
|
||||
periodicity: 'Quarterly',
|
||||
lastFiled: '15-01-2026',
|
||||
nextDue: '15-04-2026',
|
||||
fields: [
|
||||
{
|
||||
ref: 'oss7:Fraud.Count',
|
||||
section: 'Frauds',
|
||||
label: 'Number of Frauds Reported',
|
||||
value: 1,
|
||||
unit: 'Number',
|
||||
type: 'numeric'
|
||||
},
|
||||
{
|
||||
ref: 'oss7:Fraud.Amount',
|
||||
section: 'Frauds',
|
||||
label: 'Amount Involved in Frauds (₹ Lakh)',
|
||||
value: 2.4,
|
||||
unit: 'INR-Lakh',
|
||||
type: 'monetary'
|
||||
},
|
||||
{
|
||||
ref: 'oss7:Complaints.Received',
|
||||
section: 'Customer Complaints',
|
||||
label: 'Complaints Received (Quarter)',
|
||||
value: 14,
|
||||
unit: 'Number',
|
||||
type: 'numeric'
|
||||
},
|
||||
{
|
||||
ref: 'oss7:Complaints.Resolved',
|
||||
section: 'Customer Complaints',
|
||||
label: 'Complaints Resolved',
|
||||
value: 12,
|
||||
unit: 'Number',
|
||||
type: 'numeric'
|
||||
},
|
||||
{
|
||||
ref: 'oss7:Complaints.Pending',
|
||||
section: 'Customer Complaints',
|
||||
label: 'Complaints Pending',
|
||||
value: 2,
|
||||
unit: 'Number',
|
||||
type: 'numeric'
|
||||
},
|
||||
{
|
||||
ref: 'oss7:Complaints.PendingOver30',
|
||||
section: 'Customer Complaints',
|
||||
label: 'Complaints Pending > 30 Days',
|
||||
value: 0,
|
||||
unit: 'Number',
|
||||
type: 'numeric'
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
@@ -660,7 +947,11 @@ export class CimsXbrlDialogComponent {
|
||||
'OSS-2',
|
||||
'PSA',
|
||||
'RAL',
|
||||
'RCP-UCB'
|
||||
'RCP-UCB',
|
||||
'OSS-4',
|
||||
'OSS-5',
|
||||
'OSS-6',
|
||||
'OSS-7'
|
||||
];
|
||||
|
||||
data = inject<CimsXbrlDialogData | null>(MAT_DIALOG_DATA, { optional: true });
|
||||
|
||||
@@ -76,7 +76,7 @@ export class CkycReportsDialogComponent {
|
||||
activeReport: CkycReportCode;
|
||||
|
||||
bank = {
|
||||
name: 'Bharat Urban Bank Ltd',
|
||||
name: 'CBSNexora Bank Ltd',
|
||||
branch: 'Head Office',
|
||||
asOnDate: '29-01-2026',
|
||||
fiscalYear: '2025-26',
|
||||
@@ -84,7 +84,7 @@ export class CkycReportsDialogComponent {
|
||||
principalOfficer: 'Karthik Kamath',
|
||||
poDesignation: 'Principal Compliance Officer',
|
||||
reportRefNo: '2075/1',
|
||||
userCode: 'BUBL01',
|
||||
userCode: 'CBSN01',
|
||||
fyStart: '01-04-2025',
|
||||
fyEnd: '31-03-2026'
|
||||
};
|
||||
@@ -121,7 +121,7 @@ export class CkycReportsDialogComponent {
|
||||
|
||||
rekyc: RekycRow[] = [
|
||||
{
|
||||
ucode: 'BUBL000201',
|
||||
ucode: 'CBSN000201',
|
||||
name: 'Ravi Kumar Sharma',
|
||||
customerType: 'INDIVIDUAL',
|
||||
risk: 'LOW',
|
||||
@@ -132,7 +132,7 @@ export class CkycReportsDialogComponent {
|
||||
segment: 'BELOW-25-LACS'
|
||||
},
|
||||
{
|
||||
ucode: 'BUBL000418',
|
||||
ucode: 'CBSN000418',
|
||||
name: 'Priya Patel',
|
||||
customerType: 'INDIVIDUAL',
|
||||
risk: 'MEDIUM',
|
||||
@@ -143,7 +143,7 @@ export class CkycReportsDialogComponent {
|
||||
segment: 'ABOVE-25-LACS'
|
||||
},
|
||||
{
|
||||
ucode: 'BUBL000522',
|
||||
ucode: 'CBSN000522',
|
||||
name: 'Arjun Deshmukh',
|
||||
customerType: 'INDIVIDUAL',
|
||||
risk: 'LOW',
|
||||
@@ -154,7 +154,7 @@ export class CkycReportsDialogComponent {
|
||||
segment: 'BELOW-25-LACS'
|
||||
},
|
||||
{
|
||||
ucode: 'BUBL000711',
|
||||
ucode: 'CBSN000711',
|
||||
name: 'Vikram Reddy',
|
||||
customerType: 'INDIVIDUAL',
|
||||
risk: 'HIGH',
|
||||
@@ -165,7 +165,7 @@ export class CkycReportsDialogComponent {
|
||||
segment: 'ABOVE-25-LACS'
|
||||
},
|
||||
{
|
||||
ucode: 'BUBL000814',
|
||||
ucode: 'CBSN000814',
|
||||
name: 'Lakshmi Iyer',
|
||||
customerType: 'INDIVIDUAL',
|
||||
risk: 'LOW',
|
||||
@@ -176,7 +176,7 @@ export class CkycReportsDialogComponent {
|
||||
segment: 'BELOW-25-LACS'
|
||||
},
|
||||
{
|
||||
ucode: 'BUBL001012',
|
||||
ucode: 'CBSN001012',
|
||||
name: 'Nirav Shah',
|
||||
customerType: 'INDIVIDUAL',
|
||||
risk: 'MEDIUM',
|
||||
@@ -187,7 +187,7 @@ export class CkycReportsDialogComponent {
|
||||
segment: 'BELOW-25-LACS'
|
||||
},
|
||||
{
|
||||
ucode: 'BUBL001145',
|
||||
ucode: 'CBSN001145',
|
||||
name: 'Kavita Verma',
|
||||
customerType: 'INDIVIDUAL',
|
||||
risk: 'HIGH',
|
||||
@@ -198,7 +198,7 @@ export class CkycReportsDialogComponent {
|
||||
segment: 'ABOVE-25-LACS'
|
||||
},
|
||||
{
|
||||
ucode: 'BUBL001458',
|
||||
ucode: 'CBSN001458',
|
||||
name: 'Sameer Kapoor',
|
||||
customerType: 'INDIVIDUAL',
|
||||
risk: 'MEDIUM',
|
||||
@@ -209,7 +209,7 @@ export class CkycReportsDialogComponent {
|
||||
segment: 'ABOVE-25-LACS'
|
||||
},
|
||||
{
|
||||
ucode: 'BUBL001721',
|
||||
ucode: 'CBSN001721',
|
||||
name: 'Karthik Subramanian',
|
||||
customerType: 'INDIVIDUAL',
|
||||
risk: 'LOW',
|
||||
@@ -220,7 +220,7 @@ export class CkycReportsDialogComponent {
|
||||
segment: 'BELOW-25-LACS'
|
||||
},
|
||||
{
|
||||
ucode: 'BUBL002012',
|
||||
ucode: 'CBSN002012',
|
||||
name: 'Abhishek Banerjee',
|
||||
customerType: 'INDIVIDUAL',
|
||||
risk: 'MEDIUM',
|
||||
@@ -231,7 +231,7 @@ export class CkycReportsDialogComponent {
|
||||
segment: 'ABOVE-25-LACS'
|
||||
},
|
||||
{
|
||||
ucode: 'BUBL002314',
|
||||
ucode: 'CBSN002314',
|
||||
name: 'Gaurav Thakur',
|
||||
customerType: 'INDIVIDUAL',
|
||||
risk: 'LOW',
|
||||
@@ -242,7 +242,7 @@ export class CkycReportsDialogComponent {
|
||||
segment: 'BELOW-25-LACS'
|
||||
},
|
||||
{
|
||||
ucode: 'BUBL002615',
|
||||
ucode: 'CBSN002615',
|
||||
name: 'Manmohan Singh Gill',
|
||||
customerType: 'INDIVIDUAL',
|
||||
risk: 'HIGH',
|
||||
@@ -253,7 +253,7 @@ export class CkycReportsDialogComponent {
|
||||
segment: 'BELOW-25-LACS'
|
||||
},
|
||||
{
|
||||
ucode: 'BUBL002918',
|
||||
ucode: 'CBSN002918',
|
||||
name: 'Sunita Devi',
|
||||
customerType: 'INDIVIDUAL',
|
||||
risk: 'LOW',
|
||||
@@ -264,7 +264,7 @@ export class CkycReportsDialogComponent {
|
||||
segment: 'BELOW-25-LACS'
|
||||
},
|
||||
{
|
||||
ucode: 'BUBL003221',
|
||||
ucode: 'CBSN003221',
|
||||
name: 'Deepak Tiwari',
|
||||
customerType: 'INDIVIDUAL',
|
||||
risk: 'MEDIUM',
|
||||
@@ -275,7 +275,7 @@ export class CkycReportsDialogComponent {
|
||||
segment: 'ABOVE-25-LACS'
|
||||
},
|
||||
{
|
||||
ucode: 'BUBL003524',
|
||||
ucode: 'CBSN003524',
|
||||
name: 'Harpreet Kaur',
|
||||
customerType: 'INDIVIDUAL',
|
||||
risk: 'MEDIUM',
|
||||
@@ -286,7 +286,7 @@ export class CkycReportsDialogComponent {
|
||||
segment: 'ABOVE-25-LACS'
|
||||
},
|
||||
{
|
||||
ucode: 'BUBL003812',
|
||||
ucode: 'CBSN003812',
|
||||
name: 'Saraswati Mahila Co-op Society',
|
||||
customerType: 'SOCIETY',
|
||||
risk: 'MEDIUM',
|
||||
@@ -297,7 +297,7 @@ export class CkycReportsDialogComponent {
|
||||
segment: 'ABOVE-25-LACS'
|
||||
},
|
||||
{
|
||||
ucode: 'BUBL004115',
|
||||
ucode: 'CBSN004115',
|
||||
name: 'Aditya Enterprises',
|
||||
customerType: 'FIRM',
|
||||
risk: 'HIGH',
|
||||
@@ -308,7 +308,7 @@ export class CkycReportsDialogComponent {
|
||||
segment: 'ABOVE-25-LACS'
|
||||
},
|
||||
{
|
||||
ucode: 'BUBL004318',
|
||||
ucode: 'CBSN004318',
|
||||
name: 'Vasudha Trading HUF',
|
||||
customerType: 'HUF',
|
||||
risk: 'MEDIUM',
|
||||
@@ -332,7 +332,7 @@ export class CkycReportsDialogComponent {
|
||||
branchCode: '01',
|
||||
branch: 'Mumbai Main',
|
||||
acType: 'SB',
|
||||
clientId: 'BUBL000201',
|
||||
clientId: 'CBSN000201',
|
||||
customerName: 'Ravi Kumar Sharma',
|
||||
oldRisk: 'LOW',
|
||||
newRisk: 'LOW',
|
||||
@@ -345,7 +345,7 @@ export class CkycReportsDialogComponent {
|
||||
branchCode: '02',
|
||||
branch: 'Pune FC Road',
|
||||
acType: 'CA',
|
||||
clientId: 'BUBL000418',
|
||||
clientId: 'CBSN000418',
|
||||
customerName: 'Priya Patel',
|
||||
oldRisk: 'LOW',
|
||||
newRisk: 'MEDIUM',
|
||||
@@ -358,7 +358,7 @@ export class CkycReportsDialogComponent {
|
||||
branchCode: '03',
|
||||
branch: 'Nagpur Dharampeth',
|
||||
acType: 'TL',
|
||||
clientId: 'BUBL000522',
|
||||
clientId: 'CBSN000522',
|
||||
customerName: 'Arjun Deshmukh',
|
||||
oldRisk: 'MEDIUM',
|
||||
newRisk: 'LOW',
|
||||
@@ -371,7 +371,7 @@ export class CkycReportsDialogComponent {
|
||||
branchCode: '04',
|
||||
branch: 'Bengaluru Jayanagar',
|
||||
acType: 'OD',
|
||||
clientId: 'BUBL000711',
|
||||
clientId: 'CBSN000711',
|
||||
customerName: 'Vikram Reddy',
|
||||
oldRisk: 'MEDIUM',
|
||||
newRisk: 'HIGH',
|
||||
@@ -384,7 +384,7 @@ export class CkycReportsDialogComponent {
|
||||
branchCode: '05',
|
||||
branch: 'Bengaluru Indiranagar',
|
||||
acType: 'SB',
|
||||
clientId: 'BUBL000814',
|
||||
clientId: 'CBSN000814',
|
||||
customerName: 'Lakshmi Iyer',
|
||||
oldRisk: 'LOW',
|
||||
newRisk: 'LOW',
|
||||
@@ -397,7 +397,7 @@ export class CkycReportsDialogComponent {
|
||||
branchCode: '06',
|
||||
branch: 'Ahmedabad Satellite',
|
||||
acType: 'CC',
|
||||
clientId: 'BUBL001012',
|
||||
clientId: 'CBSN001012',
|
||||
customerName: 'Nirav Shah',
|
||||
oldRisk: 'LOW',
|
||||
newRisk: 'MEDIUM',
|
||||
@@ -410,7 +410,7 @@ export class CkycReportsDialogComponent {
|
||||
branchCode: '07',
|
||||
branch: 'Lucknow Gomti Nagar',
|
||||
acType: 'CA',
|
||||
clientId: 'BUBL001145',
|
||||
clientId: 'CBSN001145',
|
||||
customerName: 'Kavita Verma',
|
||||
oldRisk: 'MEDIUM',
|
||||
newRisk: 'HIGH',
|
||||
@@ -423,7 +423,7 @@ export class CkycReportsDialogComponent {
|
||||
branchCode: '08',
|
||||
branch: 'Delhi Greater Kailash',
|
||||
acType: 'OD',
|
||||
clientId: 'BUBL001458',
|
||||
clientId: 'CBSN001458',
|
||||
customerName: 'Sameer Kapoor',
|
||||
oldRisk: 'MEDIUM',
|
||||
newRisk: 'MEDIUM',
|
||||
@@ -436,7 +436,7 @@ export class CkycReportsDialogComponent {
|
||||
branchCode: '09',
|
||||
branch: 'Chennai Valasaravakkam',
|
||||
acType: 'SB',
|
||||
clientId: 'BUBL001721',
|
||||
clientId: 'CBSN001721',
|
||||
customerName: 'Karthik Subramanian',
|
||||
oldRisk: 'LOW',
|
||||
newRisk: 'LOW',
|
||||
@@ -449,7 +449,7 @@ export class CkycReportsDialogComponent {
|
||||
branchCode: '10',
|
||||
branch: 'Kolkata Salt Lake',
|
||||
acType: 'TL',
|
||||
clientId: 'BUBL002012',
|
||||
clientId: 'CBSN002012',
|
||||
customerName: 'Abhishek Banerjee',
|
||||
oldRisk: 'LOW',
|
||||
newRisk: 'MEDIUM',
|
||||
@@ -462,7 +462,7 @@ export class CkycReportsDialogComponent {
|
||||
branchCode: '11',
|
||||
branch: 'Shimla Sector 4',
|
||||
acType: 'SB',
|
||||
clientId: 'BUBL002314',
|
||||
clientId: 'CBSN002314',
|
||||
customerName: 'Gaurav Thakur',
|
||||
oldRisk: 'LOW',
|
||||
newRisk: 'LOW',
|
||||
@@ -475,7 +475,7 @@ export class CkycReportsDialogComponent {
|
||||
branchCode: '12',
|
||||
branch: 'Amritsar Model Town',
|
||||
acType: 'CC',
|
||||
clientId: 'BUBL002615',
|
||||
clientId: 'CBSN002615',
|
||||
customerName: 'Manmohan Singh Gill',
|
||||
oldRisk: 'HIGH',
|
||||
newRisk: 'HIGH',
|
||||
@@ -488,7 +488,7 @@ export class CkycReportsDialogComponent {
|
||||
branchCode: '13',
|
||||
branch: 'Bhopal Arera Colony',
|
||||
acType: 'SB',
|
||||
clientId: 'BUBL002918',
|
||||
clientId: 'CBSN002918',
|
||||
customerName: 'Sunita Devi',
|
||||
oldRisk: 'LOW',
|
||||
newRisk: 'LOW',
|
||||
@@ -501,7 +501,7 @@ export class CkycReportsDialogComponent {
|
||||
branchCode: '14',
|
||||
branch: 'Indore Vijay Nagar',
|
||||
acType: 'OD',
|
||||
clientId: 'BUBL003221',
|
||||
clientId: 'CBSN003221',
|
||||
customerName: 'Deepak Tiwari',
|
||||
oldRisk: 'MEDIUM',
|
||||
newRisk: 'HIGH',
|
||||
@@ -514,7 +514,7 @@ export class CkycReportsDialogComponent {
|
||||
branchCode: '15',
|
||||
branch: 'Ludhiana Sarabha Nagar',
|
||||
acType: 'TL',
|
||||
clientId: 'BUBL003524',
|
||||
clientId: 'CBSN003524',
|
||||
customerName: 'Harpreet Kaur',
|
||||
oldRisk: 'MEDIUM',
|
||||
newRisk: 'MEDIUM',
|
||||
|
||||
@@ -165,12 +165,12 @@
|
||||
<div class="da-compare">
|
||||
<div class="da-item">
|
||||
<div class="da-label">Total Deposits</div>
|
||||
<div class="da-value deposit">₹ 3,264.42 Cr</div>
|
||||
<div class="da-bar"><div class="da-fill deposit-fill" style="width: 78%"></div></div>
|
||||
<div class="da-value deposit">₹ 11,540.55 Cr</div>
|
||||
<div class="da-bar"><div class="da-fill deposit-fill" style="width: 71%"></div></div>
|
||||
</div>
|
||||
<div class="da-item">
|
||||
<div class="da-label">Total Advances</div>
|
||||
<div class="da-value advance">₹ 4,162.65 Cr</div>
|
||||
<div class="da-value advance">₹ 16,277.11 Cr</div>
|
||||
<div class="da-bar"><div class="da-fill advance-fill" style="width: 100%"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
<!--
|
||||
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="fdrd-dlg">
|
||||
<!-- HEADER -->
|
||||
<div class="fdrd-head">
|
||||
<div class="fdrd-head-left">
|
||||
<div class="fdrd-head-ic"><fa-icon icon="piggy-bank"></fa-icon></div>
|
||||
<div>
|
||||
<h2 class="fdrd-title">FD / RD Entry</h2>
|
||||
<p class="fdrd-sub">Open a new Fixed Deposit or Recurring Deposit account.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fdrd-head-actions">
|
||||
<span class="fdrd-meta"> <fa-icon icon="university"></fa-icon> {{ bank.name }} </span>
|
||||
<span class="fdrd-meta"> <fa-icon icon="map-marker-alt"></fa-icon> {{ bank.branch }} </span>
|
||||
<button class="fdrd-close" (click)="close()" matTooltip="Close" aria-label="Close">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fdrd-body">
|
||||
<!-- Success banner -->
|
||||
<div class="fdrd-success" *ngIf="saved">
|
||||
<fa-icon icon="check-circle"></fa-icon>
|
||||
<div>
|
||||
<strong>{{ lastAccountNo }}</strong> created for <strong>{{ lastClientName }}</strong>
|
||||
<div class="fdrd-success-sub">You can enter another deposit below, or close this dialog.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FORM -->
|
||||
<div class="fdrd-section">
|
||||
<!-- Row 1: Deposit Info | Client & Amount -->
|
||||
<div class="ws-group-row">
|
||||
<div class="ws-group">
|
||||
<span class="ws-caption">Deposit Info</span>
|
||||
<div class="ws-group__fields">
|
||||
<div class="ws-f" style="width: 200px">
|
||||
<label class="ws-f__lbl">Deposit Type</label>
|
||||
<select class="ws-f__ctl" [(ngModel)]="depositType" (ngModelChange)="onDepositTypeChange()">
|
||||
<option *ngFor="let dt of depositTypes" [value]="dt.code">{{ dt.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="ws-f" style="width: 150px">
|
||||
<label class="ws-f__lbl">Pay Mode</label>
|
||||
<select class="ws-f__ctl" [(ngModel)]="payMode">
|
||||
<option *ngFor="let m of payModes" [value]="m">{{ m }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ws-vdivider"></div>
|
||||
|
||||
<div class="ws-group ws-group--flex">
|
||||
<span class="ws-caption">Client & Amount</span>
|
||||
<div class="ws-group__fields">
|
||||
<div class="ws-f" style="flex: 1.5; min-width: 180px">
|
||||
<label class="ws-f__lbl">Client</label>
|
||||
<select class="ws-f__ctl" [(ngModel)]="clientId">
|
||||
<option value="" disabled>-- Select Client --</option>
|
||||
<option *ngFor="let c of clients" [value]="c.id">{{ c.name }} ({{ c.id }})</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="ws-f" style="flex: 1; min-width: 120px">
|
||||
<label class="ws-f__lbl">{{ isRecurring ? 'Monthly Instalment (₹)' : 'Deposit Amount (₹)' }}</label>
|
||||
<input
|
||||
class="ws-f__ctl ws-f__ctl--mono"
|
||||
type="number"
|
||||
min="0"
|
||||
[(ngModel)]="principal"
|
||||
placeholder="e.g. 100000"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 2: Term & Rate | Instructions -->
|
||||
<div class="ws-group-row ws-group-row--mt">
|
||||
<div class="ws-group">
|
||||
<span class="ws-caption">Interest & Tenure</span>
|
||||
<div class="ws-group__fields">
|
||||
<div class="ws-f" style="width: 110px">
|
||||
<label class="ws-f__lbl">Int. Rate (% p.a.)</label>
|
||||
<input class="ws-f__ctl" type="number" min="0" step="0.05" [(ngModel)]="interestRate" />
|
||||
</div>
|
||||
<div class="ws-f" style="width: 240px">
|
||||
<label class="ws-f__lbl">Tenure</label>
|
||||
<div class="fdrd-tenure-row">
|
||||
<input class="ws-f__ctl" type="number" min="0" [(ngModel)]="tenureYears" />
|
||||
<span>Yrs</span>
|
||||
<input class="ws-f__ctl" type="number" min="0" max="11" [(ngModel)]="tenureMonths" />
|
||||
<span>Mo</span>
|
||||
<input class="ws-f__ctl" type="number" min="0" max="30" [(ngModel)]="tenureDays" />
|
||||
<span>Days</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ws-vdivider"></div>
|
||||
|
||||
<div class="ws-group ws-group--flex">
|
||||
<span class="ws-caption">Nominee & Maturity Details</span>
|
||||
<div class="ws-group__fields">
|
||||
<div class="ws-f" style="flex: 1; min-width: 120px">
|
||||
<label class="ws-f__lbl">Interest Payout</label>
|
||||
<select class="ws-f__ctl" [(ngModel)]="interestPayout">
|
||||
<option *ngFor="let o of interestPayoutOptions" [value]="o">{{ o }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="ws-f" style="flex: 1; min-width: 130px">
|
||||
<label class="ws-f__lbl">Nominee Name (optional)</label>
|
||||
<input class="ws-f__ctl" type="text" [(ngModel)]="nomineeName" placeholder="Spouse / Child name" />
|
||||
</div>
|
||||
<div class="ws-f" style="flex: 1.2; min-width: 160px">
|
||||
<label class="ws-f__lbl">Maturity Instruction</label>
|
||||
<select class="ws-f__ctl" [(ngModel)]="maturityInstruction">
|
||||
<option *ngFor="let m of maturityInstructions" [value]="m">{{ m }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SUMMARY -->
|
||||
<div class="fdrd-summary">
|
||||
<div class="fdrd-stat">
|
||||
<div class="fdrd-stat-l">Principal</div>
|
||||
<div class="fdrd-stat-v">₹ {{ principal || 0 | number: '1.0-2' }}</div>
|
||||
</div>
|
||||
<div class="fdrd-stat">
|
||||
<div class="fdrd-stat-l">Tenure</div>
|
||||
<div class="fdrd-stat-v">{{ tenureYears }}y {{ tenureMonths }}m {{ tenureDays }}d</div>
|
||||
</div>
|
||||
<div class="fdrd-stat">
|
||||
<div class="fdrd-stat-l">Maturity Date</div>
|
||||
<div class="fdrd-stat-v">{{ maturityDate | date: 'dd-MM-yyyy' }}</div>
|
||||
</div>
|
||||
<div class="fdrd-stat fdrd-stat-good">
|
||||
<div class="fdrd-stat-l">Maturity Amount</div>
|
||||
<div class="fdrd-stat-v">₹ {{ maturityAmount | number: '1.2-2' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FOOTER -->
|
||||
<div class="fdrd-foot">
|
||||
<div class="fdrd-foot-l">
|
||||
<fa-icon icon="info-circle"></fa-icon> Demo entry — quarterly-compounded interest, no real account is
|
||||
created.
|
||||
</div>
|
||||
<div class="fdrd-foot-actions">
|
||||
<button mat-stroked-button class="fdrd-btn-ghost" (click)="resetForm()">
|
||||
<fa-icon icon="undo"></fa-icon> Reset
|
||||
</button>
|
||||
<button mat-stroked-button class="fdrd-btn-save" [disabled]="!isValid" (click)="save()">
|
||||
<fa-icon icon="save"></fa-icon> Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,395 @@
|
||||
/**
|
||||
* 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 {
|
||||
display: block;
|
||||
font-family: 'Hanken Grotesk', Inter, sans-serif;
|
||||
color: #1a2a3e;
|
||||
}
|
||||
|
||||
.fdrd-dlg {
|
||||
background: #fff;
|
||||
border-radius: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* HEADER - Matches .ws-header in clerk dashboard */
|
||||
.fdrd-head {
|
||||
padding: 0 12px;
|
||||
height: 32px;
|
||||
background: linear-gradient(90deg, #0d1b2e 0%, #162542 100%);
|
||||
border-bottom: 2px solid #e87722;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fdrd-head-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.fdrd-head-ic {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 6px;
|
||||
background: #e87722;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fdrd-title {
|
||||
margin: 0;
|
||||
font-family: Newsreader, Georgia, serif;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #e8f0fb;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.fdrd-sub {
|
||||
display: none; /* Hide subtitle in header to match clean style */
|
||||
}
|
||||
|
||||
.fdrd-head-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.fdrd-meta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 11px;
|
||||
color: #7a95b0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
|
||||
fa-icon {
|
||||
color: #5a7589;
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.fdrd-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #7a95b0;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: rgb(255 255 255 / 10%);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
/* BODY - Styled like canvas + card grid */
|
||||
.fdrd-body {
|
||||
padding: 16px 20px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
background: #f0f4f9;
|
||||
}
|
||||
|
||||
.fdrd-success {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 10px;
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
margin-bottom: 14px;
|
||||
font-size: 13px;
|
||||
|
||||
fa-icon {
|
||||
font-size: 16px;
|
||||
margin-top: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.fdrd-success-sub {
|
||||
margin-top: 2px;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* Card container for form */
|
||||
.fdrd-section {
|
||||
background: #fff;
|
||||
border: 1px solid #dce4ef;
|
||||
border-radius: 10px;
|
||||
padding: 16px 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* Transaction Entry Style Layout mappings */
|
||||
.ws-group-row {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
|
||||
&--mt {
|
||||
margin-top: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.ws-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
|
||||
&--flex {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__fields {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
flex-wrap: nowrap;
|
||||
height: 100%;
|
||||
align-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.ws-caption {
|
||||
font-family: Newsreader, Georgia, serif;
|
||||
font-style: italic;
|
||||
font-size: 11px;
|
||||
color: #7a95b0;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.ws-vdivider {
|
||||
width: 1px;
|
||||
background: #dce4ef;
|
||||
margin: 12px 10px 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ws-f {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
flex-shrink: 0;
|
||||
|
||||
&__lbl {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #7a95b0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__ctl {
|
||||
height: 26px;
|
||||
padding: 0 7px;
|
||||
border: 1px solid #c8d8eb;
|
||||
border-radius: 6px;
|
||||
font-family: 'Hanken Grotesk', Inter, sans-serif;
|
||||
font-size: 11.5px;
|
||||
color: #1a2a3e;
|
||||
background: #f4f7fc;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
box-shadow 0.15s;
|
||||
|
||||
&:hover {
|
||||
border-color: #9ab5d0;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 0 0 3px rgb(37 99 235 / 12%);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
&--mono {
|
||||
font-family: 'Roboto Mono', monospace;
|
||||
font-size: 11.5px;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fdrd-tenure-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
|
||||
.ws-f__ctl {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #7a95b0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
/* SUMMARY - Styled like info summary block */
|
||||
.fdrd-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: #f4f7fc;
|
||||
border: 1px solid #c8d8eb;
|
||||
border-radius: 10px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.fdrd-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.fdrd-stat-l {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #7a95b0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.fdrd-stat-v {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #1a2a3e;
|
||||
}
|
||||
|
||||
.fdrd-stat-good .fdrd-stat-v {
|
||||
color: #1a7a4a;
|
||||
}
|
||||
|
||||
/* FOOTER - Styled like action panel */
|
||||
.fdrd-foot {
|
||||
padding: 10px 16px;
|
||||
border-top: 1px solid #dce4ef;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
background: #f0f4f9;
|
||||
}
|
||||
|
||||
.fdrd-foot-l {
|
||||
font-size: 11px;
|
||||
color: #7a95b0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.fdrd-foot-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.fdrd-btn-ghost {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 28px;
|
||||
padding: 0 12px !important;
|
||||
border-radius: 7px;
|
||||
border: 1px solid #dce4ef !important;
|
||||
background: #fff !important;
|
||||
color: #1a2a3e !important;
|
||||
font-family: 'Hanken Grotesk', sans-serif;
|
||||
font-size: 11.5px !important;
|
||||
font-weight: 600 !important;
|
||||
cursor: pointer;
|
||||
box-shadow: none !important;
|
||||
|
||||
&:hover {
|
||||
background: #f0f4f9 !important;
|
||||
border-color: #a8bdd4 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.fdrd-btn-save {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 28px;
|
||||
padding: 0 16px !important;
|
||||
border-radius: 7px;
|
||||
border: none !important;
|
||||
background: #e87722 !important;
|
||||
color: #fff !important;
|
||||
font-family: 'Hanken Grotesk', sans-serif;
|
||||
font-size: 11.5px !important;
|
||||
font-weight: 700 !important;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 8px rgb(232 119 34 / 30%) !important;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
background: #c8d8eb !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.ws-group-row {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.ws-vdivider {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ws-group__fields {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.fdrd-summary {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* 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, DecimalPipe } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatDialogRef, MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
|
||||
type DepositType = 'FD-SINGLE' | 'FD-JOINT' | 'RD-MONTHLY' | 'RD-QUARTERLY';
|
||||
|
||||
interface DemoClient {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-fd-rd-entry-dialog',
|
||||
standalone: true,
|
||||
templateUrl: './fd-rd-entry-dialog.component.html',
|
||||
styleUrls: ['./fd-rd-entry-dialog.component.scss'],
|
||||
imports: [CommonModule, DecimalPipe, FormsModule, MatDialogModule, MatButtonModule, MatTooltipModule, FaIconComponent]
|
||||
})
|
||||
export class FdRdEntryDialogComponent {
|
||||
private dialogRef = inject(MatDialogRef<FdRdEntryDialogComponent>);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
|
||||
bank = {
|
||||
name: 'CBSNexora Bank Ltd',
|
||||
branch: 'Head Office'
|
||||
};
|
||||
|
||||
depositTypes: { code: DepositType; label: string; defaultRate: number }[] = [
|
||||
{ code: 'FD-SINGLE', label: 'Fixed Deposit — Single A/c', defaultRate: 7.25 },
|
||||
{ code: 'FD-JOINT', label: 'Fixed Deposit — Joint A/c', defaultRate: 7.25 },
|
||||
{ code: 'RD-MONTHLY', label: 'Recurring Deposit — Monthly', defaultRate: 7.0 },
|
||||
{ code: 'RD-QUARTERLY', label: 'Recurring Deposit — Quarterly', defaultRate: 7.0 }
|
||||
];
|
||||
|
||||
clients: DemoClient[] = [
|
||||
{ id: 'C-10231', name: 'Ramesh Kumar Sharma' },
|
||||
{ id: 'C-10344', name: 'Priya Enterprises' },
|
||||
{ id: 'C-10412', name: 'Anjali Verma' },
|
||||
{ id: 'C-10567', name: 'Suresh & Sons HUF' },
|
||||
{ id: 'C-10689', name: 'Vikram Traders' }
|
||||
];
|
||||
|
||||
payModes = ['Cash', 'Cheque', 'Transfer from Savings A/c'];
|
||||
interestPayoutOptions = ['Cumulative (Paid on Maturity)', 'Monthly Payout', 'Quarterly Payout'];
|
||||
maturityInstructions = [
|
||||
'Renew Principal & Interest',
|
||||
'Renew Principal Only, Payout Interest',
|
||||
'Payout on Maturity (No Renewal)'
|
||||
];
|
||||
|
||||
depositType: DepositType = 'FD-SINGLE';
|
||||
clientId = '';
|
||||
principal: number | null = null;
|
||||
interestRate = 7.25;
|
||||
tenureYears = 1;
|
||||
tenureMonths = 0;
|
||||
tenureDays = 0;
|
||||
interestPayout = this.interestPayoutOptions[0];
|
||||
payMode = this.payModes[0];
|
||||
nomineeName = '';
|
||||
maturityInstruction = this.maturityInstructions[0];
|
||||
|
||||
saved = false;
|
||||
lastAccountNo = '';
|
||||
lastClientName = '';
|
||||
|
||||
get selectedClient(): DemoClient | undefined {
|
||||
return this.clients.find((c) => c.id === this.clientId);
|
||||
}
|
||||
|
||||
onDepositTypeChange(): void {
|
||||
const dt = this.depositTypes.find((d) => d.code === this.depositType);
|
||||
if (dt) this.interestRate = dt.defaultRate;
|
||||
}
|
||||
|
||||
get isRecurring(): boolean {
|
||||
return this.depositType.startsWith('RD');
|
||||
}
|
||||
|
||||
get tenureInYears(): number {
|
||||
return this.tenureYears + this.tenureMonths / 12 + this.tenureDays / 365;
|
||||
}
|
||||
|
||||
get maturityDate(): Date {
|
||||
const d = new Date();
|
||||
d.setFullYear(d.getFullYear() + this.tenureYears);
|
||||
d.setMonth(d.getMonth() + this.tenureMonths);
|
||||
d.setDate(d.getDate() + this.tenureDays);
|
||||
return d;
|
||||
}
|
||||
|
||||
/** Quarterly-compounded maturity value, the standard FD/RD convention */
|
||||
get maturityAmount(): number {
|
||||
const p = this.principal || 0;
|
||||
const t = this.tenureInYears;
|
||||
if (p <= 0 || t <= 0) return 0;
|
||||
return p * Math.pow(1 + this.interestRate / 100 / 4, 4 * t);
|
||||
}
|
||||
|
||||
get isValid(): boolean {
|
||||
return !!this.clientId && (this.principal ?? 0) > 0 && this.tenureInYears > 0;
|
||||
}
|
||||
|
||||
save(): void {
|
||||
if (!this.isValid) return;
|
||||
const prefix = this.isRecurring ? 'RD' : 'FD';
|
||||
this.lastAccountNo = `${prefix}${Math.floor(1000000 + Math.random() * 8999999)}`;
|
||||
this.lastClientName = this.selectedClient?.name ?? '';
|
||||
this.saved = true;
|
||||
this.snackBar.open(`${prefix} account ${this.lastAccountNo} created for ${this.lastClientName}`, 'Close', {
|
||||
duration: 4000
|
||||
});
|
||||
}
|
||||
|
||||
resetForm(): void {
|
||||
this.depositType = 'FD-SINGLE';
|
||||
this.clientId = '';
|
||||
this.principal = null;
|
||||
this.interestRate = 7.25;
|
||||
this.tenureYears = 1;
|
||||
this.tenureMonths = 0;
|
||||
this.tenureDays = 0;
|
||||
this.nomineeName = '';
|
||||
this.interestPayout = this.interestPayoutOptions[0];
|
||||
this.payMode = this.payModes[0];
|
||||
this.maturityInstruction = this.maturityInstructions[0];
|
||||
this.saved = false;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,7 @@ export class PslReportDialogComponent {
|
||||
activeTab: 'dashboard' | 'categories' | 'register' | 'returns' = 'dashboard';
|
||||
|
||||
bank = {
|
||||
name: 'Bharat Urban Bank Ltd',
|
||||
name: 'CBSNexora Bank Ltd',
|
||||
branch: 'Head Office',
|
||||
quarter: 'Q3 FY 2025-26',
|
||||
asOnDate: '29-01-2026',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
<!--
|
||||
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">TREASURY · INVESTMENT · FIXED DEPOSIT</div>
|
||||
<h1 class="pg-title">FD Investment</h1>
|
||||
<p class="pg-sub">
|
||||
Bank's own Fixed Deposit investments with other banks — Purchase, Interest, Sell/Premature, Write-off and
|
||||
Part-Payment entries.
|
||||
</p>
|
||||
</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>
|
||||
|
||||
<div class="cctr-tabs">
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeTab === 'holdings'"
|
||||
(click)="setTab('holdings')"
|
||||
>
|
||||
Holdings
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeTab === 'purchase'"
|
||||
(click)="setTab('purchase')"
|
||||
>
|
||||
01 Purchase Entry
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeTab === 'interest'"
|
||||
(click)="setTab('interest')"
|
||||
>
|
||||
02 Interest Entry
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeTab === 'sell'"
|
||||
(click)="setTab('sell')"
|
||||
>
|
||||
03 Sell / Premature Entry
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeTab === 'writeoff'"
|
||||
(click)="setTab('writeoff')"
|
||||
>
|
||||
04 Write Off Entry
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeTab === 'partpayment'"
|
||||
(click)="setTab('partpayment')"
|
||||
>
|
||||
05 FD Part Payment Entry
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- HOLDINGS -->
|
||||
@if (activeTab === 'holdings') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">FD Holdings Register</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Cert No</th>
|
||||
<th>Party</th>
|
||||
<th>Type</th>
|
||||
<th class="num">Face Value</th>
|
||||
<th class="num">Rate</th>
|
||||
<th>Purchase Date</th>
|
||||
<th>Maturity Date</th>
|
||||
<th class="num col-in">Accrued Interest</th>
|
||||
<th class="num col-net">Maturity Value</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (h of holdings; track h.certNo) {
|
||||
<tr>
|
||||
<td>
|
||||
<span class="pg-chip">{{ h.certNo }}</span>
|
||||
</td>
|
||||
<td>{{ h.party }}</td>
|
||||
<td>{{ h.interestFreq === 'CUMULATIVE' ? 'Cumulative' : 'Payout' }}</td>
|
||||
<td class="num">₹{{ h.faceValue | number: '1.2-2' }}</td>
|
||||
<td class="num">{{ h.interestRate }}%</td>
|
||||
<td>{{ h.purchaseDate }}</td>
|
||||
<td>{{ h.maturityDate }}</td>
|
||||
<td class="num col-in">₹{{ accrued(h) | number: '1.2-2' }}</td>
|
||||
<td class="num col-net">₹{{ maturity(h) | number: '1.2-2' }}</td>
|
||||
<td>
|
||||
<span [class]="statusClass(h)">{{ h.status }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- PURCHASE ENTRY -->
|
||||
@if (activeTab === 'purchase') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Investment - FD Purchase Entry</div>
|
||||
<div class="inv-form-grid">
|
||||
<label class="inv-form-field">
|
||||
<span>Tran. Type</span>
|
||||
<select [(ngModel)]="purchaseForm.tranType">
|
||||
<option value="FRESH">FRESH</option>
|
||||
<option value="RENEWAL">RENEWAL</option>
|
||||
</select>
|
||||
</label>
|
||||
@if (purchaseForm.tranType === 'RENEWAL') {
|
||||
<label class="inv-form-field"
|
||||
><span>OLD CertNo</span><input type="text" [(ngModel)]="purchaseForm.oldCertNo"
|
||||
/></label>
|
||||
}
|
||||
<label class="inv-form-field">
|
||||
<span>FD Type</span>
|
||||
<select [(ngModel)]="purchaseForm.fdType">
|
||||
@for (t of fdTypeOptions; track t) {
|
||||
<option [value]="t">{{ t }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>Mature Amt.calc.Type</span>
|
||||
<select [(ngModel)]="purchaseForm.matureAmtCalcType">
|
||||
<option value="GENERAL">GENERAL</option>
|
||||
<option value="DATEWISE">DATEWISE</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>Party *</span>
|
||||
<select [(ngModel)]="purchaseForm.party">
|
||||
<option value="">-- Select --</option>
|
||||
@for (p of partyOptions; track p.partyCode) {
|
||||
<option [value]="p.partyName">{{ p.partyName }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>Sub Party</span>
|
||||
<select [(ngModel)]="purchaseForm.subParty">
|
||||
<option value="">-- Select --</option>
|
||||
@for (p of partyOptions; track p.partyCode) {
|
||||
<option [value]="p.partyName">{{ p.partyName }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label class="inv-form-field"
|
||||
><span>GL ACNO</span><input type="text" [(ngModel)]="purchaseForm.glAcNo"
|
||||
/></label>
|
||||
<label class="inv-form-field"
|
||||
><span>GL A/c Name</span><input type="text" [(ngModel)]="purchaseForm.glAcName" readonly
|
||||
/></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Certificate No.</span><input type="text" [(ngModel)]="purchaseForm.certNo"
|
||||
/></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Certificate Date</span><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.certDate"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
<label class="inv-form-field">
|
||||
<span>Sub Type</span>
|
||||
<select [(ngModel)]="purchaseForm.subType">
|
||||
@for (s of subTypeOptions; track s.subTypeCode + s.subTypeName) {
|
||||
<option [value]="s.subTypeName">{{ s.subTypeCode }} - {{ s.subTypeName }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>Reciept/Advice</span>
|
||||
<select [(ngModel)]="purchaseForm.receiptAdvice">
|
||||
<option value="RECEIPT">RECEIPT</option>
|
||||
<option value="ADVICE">ADVICE</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="inv-form-field"
|
||||
><span>Transaction Date</span
|
||||
><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.transactionDate"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Deposit Date *</span
|
||||
><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.depositDate"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Deposit Amount *</span><input type="number" [(ngModel)]="purchaseForm.depositAmount" min="0"
|
||||
/></label>
|
||||
<label class="inv-form-field">
|
||||
<span>Interest[M/Q/H/Y]</span>
|
||||
<select [(ngModel)]="purchaseForm.interestFreq">
|
||||
@for (i of intTypeOptions; track i.value) {
|
||||
<option [value]="i.value">{{ i.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label class="inv-form-field"
|
||||
><span>Interest Date</span
|
||||
><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.interestDate"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Interest Rate *</span><input type="number" [(ngModel)]="purchaseForm.interestRate" min="0" step="0.01"
|
||||
/></label>
|
||||
@if (purchaseForm.matureAmtCalcType === 'GENERAL') {
|
||||
<label class="inv-form-field"
|
||||
><span>Period Month</span><input type="number" [(ngModel)]="purchaseForm.periodMonth" min="0" max="11"
|
||||
/></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Year</span><input type="number" [(ngModel)]="purchaseForm.periodYear" min="0"
|
||||
/></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Days</span><input type="number" [(ngModel)]="purchaseForm.periodDays" min="0"
|
||||
/></label>
|
||||
} @else {
|
||||
<label class="inv-form-field"
|
||||
><span>Maturity Date (manual) *</span
|
||||
><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.manualMaturityDate"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
}
|
||||
<label class="inv-form-field"
|
||||
><span>Resolution No.</span><input type="text" [(ngModel)]="purchaseForm.resolutionNo"
|
||||
/></label>
|
||||
<label class="inv-form-field">
|
||||
<span>CR. Branch Code</span>
|
||||
<select [(ngModel)]="purchaseForm.crBranchCode">
|
||||
@for (b of branchOptions; track b) {
|
||||
<option [value]="b">{{ b }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label class="inv-form-field"
|
||||
><span>CR. To A/c</span><input type="text" [(ngModel)]="purchaseForm.crToAc"
|
||||
/></label>
|
||||
<label class="inv-form-field"
|
||||
><span>CR. A/c Name</span><input type="text" [(ngModel)]="purchaseForm.crToAcName" readonly
|
||||
/></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Int.Receivable Code</span><input type="text" [(ngModel)]="purchaseForm.intReceivableCode"
|
||||
/></label>
|
||||
<label class="inv-form-field">
|
||||
<span>LIEN Y/N</span>
|
||||
<select [(ngModel)]="purchaseForm.lien">
|
||||
<option value="NO">NO</option>
|
||||
<option value="YES">YES</option>
|
||||
</select>
|
||||
</label>
|
||||
@if (purchaseForm.lien === 'YES') {
|
||||
<label class="inv-form-field"
|
||||
><span>LIEN AMT.</span><input type="number" [(ngModel)]="purchaseForm.lienAmt"
|
||||
/></label>
|
||||
<label class="inv-form-field">
|
||||
<span>LIEN Reason</span>
|
||||
<select [(ngModel)]="purchaseForm.lienReason">
|
||||
<option value="">-- Select --</option>
|
||||
@for (l of lienReasonOptions; track l.reasonCode) {
|
||||
<option [value]="l.reasonDescription">{{ l.reasonDescription }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
}
|
||||
<label class="inv-form-field">
|
||||
<span>Classification</span>
|
||||
<select [(ngModel)]="purchaseForm.classification">
|
||||
<option value="">-- Select --</option>
|
||||
<option value="STANDARD">STANDARD</option>
|
||||
<option value="SUB-STANDARD">SUB-STANDARD</option>
|
||||
<option value="DOUBTFUL">DOUBTFUL</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>Collable</span>
|
||||
<select [(ngModel)]="purchaseForm.collable">
|
||||
<option value="NO">NO</option>
|
||||
<option value="YES">YES</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="pos-section-label">Mature / Premature Details</div>
|
||||
<div class="inv-form-grid">
|
||||
<label class="inv-form-field"
|
||||
><span>* Maturity Date</span><input type="text" [value]="purchaseMaturityDate" readonly
|
||||
/></label>
|
||||
<label class="inv-form-field"
|
||||
><span>* Maturity Amt</span><input type="text" [value]="purchaseMaturityAmount | number: '1.0-0'" readonly
|
||||
/></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Search by Cert No</span><input type="text" [(ngModel)]="searchCertNo" placeholder="Enter Cert No"
|
||||
/></label>
|
||||
</div>
|
||||
<div class="inv-form-actions">
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="savePurchase()">Save</button>
|
||||
<button type="button" class="pg-btn pg-btn--ghost" (click)="cancelPurchase()">Cancel</button>
|
||||
<button type="button" class="pg-btn pg-btn--ghost" (click)="searchPurchase()">Search</button>
|
||||
<button type="button" class="pg-btn pg-btn--ghost" (click)="dropPurchase()">Drop</button>
|
||||
<button type="button" class="pg-btn pg-btn--ghost" (click)="exitPurchase()">Exit</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- INTEREST ENTRY -->
|
||||
@if (activeTab === 'interest') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Investment - FD Interest Entry / Calculation</div>
|
||||
<div class="inv-form-grid">
|
||||
<label class="inv-form-field">
|
||||
<span>Certificate No *</span>
|
||||
<select [(ngModel)]="interestCertNo">
|
||||
<option value="">-- Select --</option>
|
||||
@for (h of interestEligibleHoldings; track h.certNo) {
|
||||
<option [value]="h.certNo">{{ h.certNo }} — {{ h.party }}</option>
|
||||
}
|
||||
</select>
|
||||
</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) {
|
||||
<label class="inv-form-field"><span>Party</span><input type="text" [value]="h.party" readonly /></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Interest Rate</span><input type="text" [value]="h.interestRate + '%'" readonly
|
||||
/></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Frequency</span><input type="text" [value]="h.interestFreq" readonly
|
||||
/></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Interest for Period</span
|
||||
><input type="text" [value]="'₹' + (interestAccrued(h) | number: '1.2-2')" readonly
|
||||
/></label>
|
||||
}
|
||||
</div>
|
||||
<div class="inv-form-actions">
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="postInterest()">Process / Post Interest</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- SELL / PREMATURE ENTRY -->
|
||||
@if (activeTab === 'sell') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Investment - FD Sell / Premature Closure Entry</div>
|
||||
<div class="inv-form-grid">
|
||||
<label class="inv-form-field">
|
||||
<span>Certificate No *</span>
|
||||
<select [(ngModel)]="sellCertNo">
|
||||
<option value="">-- Select --</option>
|
||||
@for (h of activeHoldings; track h.certNo) {
|
||||
<option [value]="h.certNo">{{ h.certNo }} — {{ h.party }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
@if (sellHolding; as h) {
|
||||
<label class="inv-form-field"
|
||||
><span>Face Value</span><input type="text" [value]="'₹' + (h.faceValue | number: '1.2-2')" readonly
|
||||
/></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Accrued Interest till Sell Date</span
|
||||
><input type="text" [value]="'₹' + (accrued(h) | number: '1.2-2')" readonly
|
||||
/></label>
|
||||
}
|
||||
<label class="inv-form-field"
|
||||
><span>Sell / Premature Date *</span><mifosx-date-ddmmyyyy [(ngModel)]="sellDate"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
<label class="inv-form-field"><span>Remarks</span><input type="text" [(ngModel)]="sellRemark" /></label>
|
||||
</div>
|
||||
<div class="inv-form-actions">
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="saveSell()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- WRITE OFF ENTRY -->
|
||||
@if (activeTab === 'writeoff') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Investment - FD Write Off Entry</div>
|
||||
<div class="inv-form-grid">
|
||||
<label class="inv-form-field">
|
||||
<span>Certificate No *</span>
|
||||
<select [(ngModel)]="writeoffCertNo">
|
||||
<option value="">-- Select --</option>
|
||||
@for (h of activeHoldings; track h.certNo) {
|
||||
<option [value]="h.certNo">{{ h.certNo }} — {{ h.party }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
@if (writeoffHolding; as h) {
|
||||
<label class="inv-form-field"
|
||||
><span>Face Value to be Written Off</span
|
||||
><input type="text" [value]="'₹' + (h.faceValue | number: '1.2-2')" readonly
|
||||
/></label>
|
||||
}
|
||||
<label class="inv-form-field"><span>Reason *</span><input type="text" [(ngModel)]="writeoffReason" /></label>
|
||||
</div>
|
||||
<div class="inv-form-actions">
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="saveWriteoff()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- PART PAYMENT ENTRY -->
|
||||
@if (activeTab === 'partpayment') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Investment - FD Part Payment Entry</div>
|
||||
<div class="inv-form-grid">
|
||||
<label class="inv-form-field">
|
||||
<span>Certificate No *</span>
|
||||
<select [(ngModel)]="partPayCertNo">
|
||||
<option value="">-- Select --</option>
|
||||
@for (h of activeHoldings; track h.certNo) {
|
||||
<option [value]="h.certNo">{{ h.certNo }} — {{ h.party }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
@if (partPayHolding; as h) {
|
||||
<label class="inv-form-field"
|
||||
><span>Current Face Value</span><input type="text" [value]="'₹' + (h.faceValue | number: '1.2-2')" readonly
|
||||
/></label>
|
||||
}
|
||||
<label class="inv-form-field"
|
||||
><span>Part Payment Amount *</span><input type="number" [(ngModel)]="partPayAmount" min="0"
|
||||
/></label>
|
||||
</div>
|
||||
<div class="inv-form-actions">
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="savePartPayment()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (activeTab !== 'holdings') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">FD Transaction Log</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Voucher No</th>
|
||||
<th>Cert No</th>
|
||||
<th>Type</th>
|
||||
<th>Particulars</th>
|
||||
<th>GL Posted</th>
|
||||
<th class="num">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (t of txns; track t.voucherNo) {
|
||||
<tr>
|
||||
<td>{{ t.txnDate }}</td>
|
||||
<td>
|
||||
<span class="pg-chip">{{ t.voucherNo }}</span>
|
||||
</td>
|
||||
<td>{{ t.certNo }}</td>
|
||||
<td>{{ t.txnType }}</td>
|
||||
<td>{{ t.particulars }}</td>
|
||||
<td>{{ t.glCode ? t.glCode + ' — ' + t.glName : '—' }}</td>
|
||||
<td class="num">₹{{ t.amount | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="inv-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
|
||||
@@ -0,0 +1,562 @@
|
||||
/**
|
||||
* 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 } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { DateDdmmyyyyPickerComponent } from '../date-ddmmyyyy-picker/date-ddmmyyyy-picker.component';
|
||||
import {
|
||||
FD_HOLDINGS,
|
||||
ALL_TXNS,
|
||||
PARTY_MASTER,
|
||||
SUBTYPE_MASTER,
|
||||
FD_TYPE_OPTIONS,
|
||||
INT_TYPE_OPTIONS,
|
||||
LIEN_REASON_MASTER,
|
||||
INTEREST_INCOME_GL_MASTER,
|
||||
INTEREST_INCOME_GL_BY_TYPE,
|
||||
InvestmentHolding,
|
||||
InvestmentTxn,
|
||||
InterestFreq,
|
||||
accruedInterest,
|
||||
accruedInterestBetween,
|
||||
canAccrueInterest,
|
||||
daysToMaturity,
|
||||
maturityAmount,
|
||||
addPeriod,
|
||||
todayDDMMYYYY
|
||||
} from '../investment-data';
|
||||
import { exportWorkbook } from '../investment-export';
|
||||
|
||||
type FdTab = 'holdings' | 'purchase' | 'interest' | 'sell' | 'writeoff' | 'partpayment';
|
||||
|
||||
interface FdPurchaseForm {
|
||||
tranType: 'FRESH' | 'RENEWAL';
|
||||
oldCertNo: string;
|
||||
fdType: string;
|
||||
/** GENERAL uses the period (Y/M/D) fields; DATEWISE lets the user type the maturity date directly. */
|
||||
matureAmtCalcType: 'GENERAL' | 'DATEWISE';
|
||||
certNo: string;
|
||||
certDate: string;
|
||||
party: string;
|
||||
subParty: string;
|
||||
glAcNo: string;
|
||||
glAcName: string;
|
||||
subType: string;
|
||||
receiptAdvice: 'RECEIPT' | 'ADVICE';
|
||||
transactionDate: string;
|
||||
depositDate: string;
|
||||
depositAmount: number | null;
|
||||
interestFreq: InterestFreq;
|
||||
interestDate: string;
|
||||
interestRate: number | null;
|
||||
periodYear: number | null;
|
||||
periodMonth: number | null;
|
||||
periodDays: number | null;
|
||||
/** Only used when matureAmtCalcType === 'DATEWISE'. */
|
||||
manualMaturityDate: string;
|
||||
resolutionNo: string;
|
||||
crBranchCode: string;
|
||||
crToAc: string;
|
||||
crToAcName: string;
|
||||
intReceivableCode: string;
|
||||
lien: 'YES' | 'NO';
|
||||
lienAmt: number | null;
|
||||
lienReason: string;
|
||||
classification: string;
|
||||
collable: 'YES' | 'NO';
|
||||
}
|
||||
|
||||
/**
|
||||
* FD (Fixed Deposit) treasury investment screen — Holdings register + Purchase / Interest /
|
||||
* Sell-Premature / Write-off / Part-Payment entry tabs, modelled on the legacy "Investment - FD
|
||||
* Purchase Entry" form. Dummy in-memory data only; Save appends to the local holdings/txns
|
||||
* arrays so the demo feels interactive without any backend.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-fd-investment',
|
||||
templateUrl: './fd-investment.component.html',
|
||||
styleUrls: [
|
||||
'../../cashier/cashier-shared.scss',
|
||||
'../investment-shared.scss'
|
||||
],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
DateDdmmyyyyPickerComponent
|
||||
]
|
||||
})
|
||||
export class FdInvestmentComponent {
|
||||
activeTab: FdTab = 'holdings';
|
||||
asOnDate = todayDDMMYYYY();
|
||||
|
||||
holdings: InvestmentHolding[] = [...FD_HOLDINGS];
|
||||
txns: InvestmentTxn[] = ALL_TXNS.filter((t) => t.instrumentType === 'FD');
|
||||
|
||||
readonly partyOptions = PARTY_MASTER;
|
||||
readonly subTypeOptions = SUBTYPE_MASTER;
|
||||
readonly fdTypeOptions = FD_TYPE_OPTIONS;
|
||||
readonly intTypeOptions = INT_TYPE_OPTIONS;
|
||||
readonly lienReasonOptions = LIEN_REASON_MASTER;
|
||||
readonly interestIncomeGlOptions = INTEREST_INCOME_GL_MASTER;
|
||||
readonly branchOptions = [
|
||||
'HEAD OFFICE',
|
||||
'BRANCH 1',
|
||||
'BRANCH 2'
|
||||
];
|
||||
|
||||
generating = false;
|
||||
toastMsg = '';
|
||||
toastVisible = false;
|
||||
private toastTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
purchaseForm: FdPurchaseForm = this.blankPurchaseForm();
|
||||
/** Certificate No typed into the Purchase Entry's Search box (legacy "Search" button). */
|
||||
searchCertNo = '';
|
||||
|
||||
interestCertNo = '';
|
||||
interestFromDate = addPeriod(this.asOnDate, 0, -3);
|
||||
interestToDate = this.asOnDate;
|
||||
interestGlCode = INTEREST_INCOME_GL_BY_TYPE.FD;
|
||||
sellCertNo = '';
|
||||
sellDate = this.asOnDate;
|
||||
sellRemark = '';
|
||||
writeoffCertNo = '';
|
||||
writeoffReason = '';
|
||||
partPayCertNo = '';
|
||||
partPayAmount: number | null = null;
|
||||
|
||||
setTab(tab: FdTab): void {
|
||||
this.activeTab = tab;
|
||||
}
|
||||
|
||||
get activeHoldings(): InvestmentHolding[] {
|
||||
return this.holdings.filter((h) => h.status === 'ACTIVE');
|
||||
}
|
||||
|
||||
private nextCertNo(): string {
|
||||
const nums = this.holdings.map((h) => parseInt(h.certNo, 10)).filter((n) => !isNaN(n));
|
||||
return String((nums.length ? Math.max(...nums) : 15619) + 1);
|
||||
}
|
||||
|
||||
private blankPurchaseForm(): FdPurchaseForm {
|
||||
return {
|
||||
tranType: 'FRESH',
|
||||
oldCertNo: '',
|
||||
fdType: 'CUMMULATIVE',
|
||||
matureAmtCalcType: 'GENERAL',
|
||||
certNo: this.nextCertNo(),
|
||||
certDate: this.asOnDate,
|
||||
party: '',
|
||||
subParty: '',
|
||||
glAcNo: '320020',
|
||||
glAcName: 'FIXED DEPO. CO-OPER. BANK MAIN',
|
||||
subType: 'NON SLR',
|
||||
receiptAdvice: 'RECEIPT',
|
||||
transactionDate: this.asOnDate,
|
||||
depositDate: this.asOnDate,
|
||||
depositAmount: null,
|
||||
interestFreq: 'CUMULATIVE',
|
||||
interestDate: this.asOnDate,
|
||||
interestRate: null,
|
||||
periodYear: 2,
|
||||
periodMonth: 0,
|
||||
periodDays: 0,
|
||||
manualMaturityDate: '',
|
||||
resolutionNo: '',
|
||||
crBranchCode: 'HEAD OFFICE',
|
||||
crToAc: '310070',
|
||||
crToAcName: 'CURRENT A/C CO-OP. BANK MAIN BR.41',
|
||||
intReceivableCode: '320020',
|
||||
lien: 'NO',
|
||||
lienAmt: null,
|
||||
lienReason: '',
|
||||
classification: '',
|
||||
collable: 'NO'
|
||||
};
|
||||
}
|
||||
|
||||
get purchaseMaturityDate(): string {
|
||||
const f = this.purchaseForm;
|
||||
if (f.matureAmtCalcType === 'DATEWISE') {
|
||||
return f.manualMaturityDate;
|
||||
}
|
||||
if (!f.depositDate) {
|
||||
return '';
|
||||
}
|
||||
const base = addPeriod(f.depositDate, f.periodYear || 0, f.periodMonth || 0);
|
||||
return f.periodDays ? addPeriod(base, 0, 0, f.periodDays) : base;
|
||||
}
|
||||
|
||||
get purchaseMaturityAmount(): number {
|
||||
const f = this.purchaseForm;
|
||||
if (!f.depositAmount || !f.interestRate || !this.purchaseMaturityDate) {
|
||||
return 0;
|
||||
}
|
||||
return maturityAmount({
|
||||
certNo: f.certNo,
|
||||
instrumentType: 'FD',
|
||||
scriptCode: '',
|
||||
scriptName: '',
|
||||
party: f.party,
|
||||
subType: f.subType,
|
||||
faceValue: f.depositAmount,
|
||||
purchaseRate: 100,
|
||||
units: 1,
|
||||
purchaseDate: f.depositDate,
|
||||
maturityDate: this.purchaseMaturityDate,
|
||||
interestRate: f.interestRate,
|
||||
interestFreq: f.interestFreq,
|
||||
glCode: f.glAcNo,
|
||||
glName: '',
|
||||
crToAc: f.crToAc,
|
||||
brokerCode: '',
|
||||
status: 'ACTIVE'
|
||||
});
|
||||
}
|
||||
|
||||
savePurchase(): void {
|
||||
const f = this.purchaseForm;
|
||||
if (!f.party || !f.depositAmount || !f.interestRate || !this.purchaseMaturityDate) {
|
||||
this.toast('Please fill Party, Deposit Amount, Interest Rate and Period.');
|
||||
return;
|
||||
}
|
||||
const holding: InvestmentHolding = {
|
||||
certNo: f.certNo,
|
||||
instrumentType: 'FD',
|
||||
scriptCode: f.interestFreq === 'CUMULATIVE' ? 'FD-CUM' : 'FD-MIP',
|
||||
scriptName: f.fdType ? `${f.fdType} Fixed Deposit` : 'Fixed Deposit',
|
||||
party: f.party,
|
||||
subType: f.subType,
|
||||
faceValue: f.depositAmount,
|
||||
purchaseRate: 100,
|
||||
units: 1,
|
||||
purchaseDate: f.depositDate,
|
||||
maturityDate: this.purchaseMaturityDate,
|
||||
interestRate: f.interestRate,
|
||||
interestFreq: f.interestFreq,
|
||||
glCode: f.glAcNo,
|
||||
glName: f.glAcName,
|
||||
crToAc: f.crToAc,
|
||||
brokerCode: '',
|
||||
status: 'ACTIVE'
|
||||
};
|
||||
this.holdings = [
|
||||
holding,
|
||||
...this.holdings
|
||||
];
|
||||
this.txns = [
|
||||
{
|
||||
certNo: holding.certNo,
|
||||
instrumentType: 'FD',
|
||||
txnType: 'PURCHASE',
|
||||
voucherNo: `INV/PUR/FD-${holding.certNo}`,
|
||||
txnDate: f.transactionDate,
|
||||
amount: holding.faceValue,
|
||||
particulars: `${f.tranType === 'RENEWAL' ? 'Renewal' : 'Fresh'} FD Purchase - ${holding.party}`
|
||||
},
|
||||
...this.txns
|
||||
];
|
||||
this.toast(`FD Certificate No. ${holding.certNo} saved successfully.`);
|
||||
this.purchaseForm = this.blankPurchaseForm();
|
||||
this.activeTab = 'holdings';
|
||||
}
|
||||
|
||||
cancelPurchase(): void {
|
||||
this.purchaseForm = this.blankPurchaseForm();
|
||||
this.toast('Entry cancelled — form reset.');
|
||||
}
|
||||
|
||||
/** Legacy "Search" button — loads an existing certificate's values back into the form. */
|
||||
searchPurchase(): void {
|
||||
const h = this.holdings.find((x) => x.certNo === this.searchCertNo.trim());
|
||||
if (!h) {
|
||||
this.toast(`Certificate No. ${this.searchCertNo} not found.`);
|
||||
return;
|
||||
}
|
||||
this.purchaseForm = {
|
||||
...this.blankPurchaseForm(),
|
||||
tranType: 'FRESH',
|
||||
certNo: h.certNo,
|
||||
certDate: h.purchaseDate,
|
||||
party: h.party,
|
||||
subType: h.subType,
|
||||
glAcNo: h.glCode,
|
||||
glAcName: h.glName,
|
||||
transactionDate: h.purchaseDate,
|
||||
depositDate: h.purchaseDate,
|
||||
depositAmount: h.faceValue,
|
||||
interestFreq: h.interestFreq,
|
||||
interestDate: h.purchaseDate,
|
||||
interestRate: h.interestRate,
|
||||
matureAmtCalcType: 'DATEWISE',
|
||||
manualMaturityDate: h.maturityDate,
|
||||
crToAc: h.crToAc
|
||||
};
|
||||
this.toast(`Certificate ${h.certNo} loaded.`);
|
||||
}
|
||||
|
||||
/** Legacy "Drop" button — removes the loaded certificate from the register. */
|
||||
dropPurchase(): void {
|
||||
const certNo = this.purchaseForm.certNo;
|
||||
const exists = this.holdings.some((h) => h.certNo === certNo);
|
||||
if (!exists) {
|
||||
this.toast('Nothing to drop — this certificate is not yet saved.');
|
||||
return;
|
||||
}
|
||||
this.holdings = this.holdings.filter((h) => h.certNo !== certNo);
|
||||
this.txns = this.txns.filter((t) => t.certNo !== certNo);
|
||||
this.purchaseForm = this.blankPurchaseForm();
|
||||
this.toast(`Certificate ${certNo} dropped.`);
|
||||
}
|
||||
|
||||
/** Legacy "Exit" button — closes the entry form and returns to the register. */
|
||||
exitPurchase(): void {
|
||||
this.purchaseForm = this.blankPurchaseForm();
|
||||
this.activeTab = 'holdings';
|
||||
}
|
||||
|
||||
// -------------------- 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 {
|
||||
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 {
|
||||
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 {
|
||||
const h = this.interestHolding;
|
||||
if (!h) {
|
||||
this.toast('Select a certificate first.');
|
||||
return;
|
||||
}
|
||||
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 = [
|
||||
{
|
||||
certNo: h.certNo,
|
||||
instrumentType: 'FD',
|
||||
txnType: 'INTEREST',
|
||||
voucherNo: `INV/INT/FD-${h.certNo}-${this.txns.length + 1}`,
|
||||
txnDate: toDate,
|
||||
amount: amt,
|
||||
particulars: `Interest posted (accrual) for period ${fromDate} to ${toDate} - ${h.party} (${h.interestFreq})`,
|
||||
glCode,
|
||||
glName
|
||||
},
|
||||
...this.txns
|
||||
];
|
||||
this.toast(
|
||||
`Interest of ₹${amt.toLocaleString('en-IN')} posted to ${glCode} - ${glName} for Certificate ${h.certNo}.`
|
||||
);
|
||||
this.interestCertNo = '';
|
||||
}
|
||||
|
||||
// -------------------- Sell / Premature --------------------
|
||||
get sellHolding(): InvestmentHolding | undefined {
|
||||
return this.holdings.find((h) => h.certNo === this.sellCertNo);
|
||||
}
|
||||
|
||||
saveSell(): void {
|
||||
const h = this.sellHolding;
|
||||
if (!h || !this.sellDate) {
|
||||
this.toast('Select a certificate and sell/premature date.');
|
||||
return;
|
||||
}
|
||||
const payout = h.faceValue + accruedInterest(h, this.sellDate);
|
||||
h.status = 'SOLD';
|
||||
this.txns = [
|
||||
{
|
||||
certNo: h.certNo,
|
||||
instrumentType: 'FD',
|
||||
txnType: 'SELL',
|
||||
voucherNo: `INV/SEL/FD-${h.certNo}`,
|
||||
txnDate: this.sellDate,
|
||||
amount: payout,
|
||||
particulars: `Premature closure - ${h.party}${this.sellRemark ? ' - ' + this.sellRemark : ''}`
|
||||
},
|
||||
...this.txns
|
||||
];
|
||||
this.toast(`Certificate ${h.certNo} closed. Payout ₹${payout.toLocaleString('en-IN')}.`);
|
||||
this.sellCertNo = '';
|
||||
this.sellRemark = '';
|
||||
this.activeTab = 'holdings';
|
||||
}
|
||||
|
||||
// -------------------- Write-off --------------------
|
||||
get writeoffHolding(): InvestmentHolding | undefined {
|
||||
return this.holdings.find((h) => h.certNo === this.writeoffCertNo);
|
||||
}
|
||||
|
||||
saveWriteoff(): void {
|
||||
const h = this.writeoffHolding;
|
||||
if (!h || !this.writeoffReason) {
|
||||
this.toast('Select a certificate and enter a write-off reason.');
|
||||
return;
|
||||
}
|
||||
h.status = 'WRITTEN_OFF';
|
||||
this.txns = [
|
||||
{
|
||||
certNo: h.certNo,
|
||||
instrumentType: 'FD',
|
||||
txnType: 'WRITE_OFF',
|
||||
voucherNo: `INV/WOF/FD-${h.certNo}`,
|
||||
txnDate: this.asOnDate,
|
||||
amount: h.faceValue,
|
||||
particulars: `Written off - ${this.writeoffReason}`
|
||||
},
|
||||
...this.txns
|
||||
];
|
||||
this.toast(`Certificate ${h.certNo} written off.`);
|
||||
this.writeoffCertNo = '';
|
||||
this.writeoffReason = '';
|
||||
this.activeTab = 'holdings';
|
||||
}
|
||||
|
||||
// -------------------- Part Payment --------------------
|
||||
get partPayHolding(): InvestmentHolding | undefined {
|
||||
return this.holdings.find((h) => h.certNo === this.partPayCertNo);
|
||||
}
|
||||
|
||||
savePartPayment(): void {
|
||||
const h = this.partPayHolding;
|
||||
if (!h || !this.partPayAmount || this.partPayAmount <= 0 || this.partPayAmount >= h.faceValue) {
|
||||
this.toast('Select a certificate and enter a valid part-payment amount (less than face value).');
|
||||
return;
|
||||
}
|
||||
h.faceValue -= this.partPayAmount;
|
||||
this.txns = [
|
||||
{
|
||||
certNo: h.certNo,
|
||||
instrumentType: 'FD',
|
||||
txnType: 'PART_PAYMENT',
|
||||
voucherNo: `INV/PP/FD-${h.certNo}-${this.txns.length + 1}`,
|
||||
txnDate: this.asOnDate,
|
||||
amount: this.partPayAmount,
|
||||
particulars: `Part payment withdrawn - ${h.party}, balance ₹${h.faceValue.toLocaleString('en-IN')}`
|
||||
},
|
||||
...this.txns
|
||||
];
|
||||
this.toast(`Part payment of ₹${this.partPayAmount.toLocaleString('en-IN')} recorded for Certificate ${h.certNo}.`);
|
||||
this.partPayCertNo = '';
|
||||
this.partPayAmount = null;
|
||||
this.activeTab = 'holdings';
|
||||
}
|
||||
|
||||
// -------------------- Helpers --------------------
|
||||
accrued(h: InvestmentHolding): number {
|
||||
return accruedInterest(h, this.asOnDate);
|
||||
}
|
||||
|
||||
maturity(h: InvestmentHolding): number {
|
||||
return maturityAmount(h);
|
||||
}
|
||||
|
||||
daysLeft(h: InvestmentHolding): number {
|
||||
return daysToMaturity(h, this.asOnDate);
|
||||
}
|
||||
|
||||
statusClass(h: InvestmentHolding): string {
|
||||
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 {
|
||||
this.toastMsg = msg;
|
||||
this.toastVisible = true;
|
||||
if (this.toastTimer) {
|
||||
clearTimeout(this.toastTimer);
|
||||
}
|
||||
this.toastTimer = setTimeout(() => (this.toastVisible = false), 3200);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
<!--
|
||||
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">TREASURY · INVESTMENT</div>
|
||||
<h1 class="pg-title">Investment Portfolio Dashboard</h1>
|
||||
<p class="pg-sub">
|
||||
Consolidated position across FD, Govt Security, Non-SLR, Bonds, Mutual Fund and Share holdings as on
|
||||
{{ asOnDate }}.
|
||||
</p>
|
||||
</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>
|
||||
|
||||
<div class="inv-kpis">
|
||||
<div class="inv-kpi">
|
||||
<span class="inv-kpi__label">Total Holdings</span>
|
||||
<span class="inv-kpi__value">{{ totalHoldings }}</span>
|
||||
<span class="inv-kpi__sub">{{ activeCount }} active · {{ maturedCount }} matured</span>
|
||||
</div>
|
||||
<div class="inv-kpi">
|
||||
<span class="inv-kpi__label">Total Invested (Face Value)</span>
|
||||
<span class="inv-kpi__value">₹{{ totalFaceValue | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="inv-kpi">
|
||||
<span class="inv-kpi__label">Accrued Interest / Income</span>
|
||||
<span class="inv-kpi__value col-in">₹{{ totalAccruedInterest | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="inv-kpi">
|
||||
<span class="inv-kpi__label">Projected Value at Maturity</span>
|
||||
<span class="inv-kpi__value">₹{{ totalMaturityValue | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Instrument-wise Break-up</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Instrument Type</th>
|
||||
<th class="num">Holdings</th>
|
||||
<th class="num">Face Value</th>
|
||||
<th class="num col-in">Accrued Interest</th>
|
||||
<th class="num col-net">Maturity Value</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (s of summaries; track s.type) {
|
||||
<tr>
|
||||
<td>{{ s.label }}</td>
|
||||
<td class="num">{{ s.count }}</td>
|
||||
<td class="num">₹{{ s.faceValue | number: '1.2-2' }}</td>
|
||||
<td class="num col-in">₹{{ s.accruedInterest | number: '1.2-2' }}</td>
|
||||
<td class="num col-net">₹{{ s.maturityValue | number: '1.2-2' }}</td>
|
||||
<td>
|
||||
<a class="pg-btn pg-btn--ghost" [routerLink]="s.route">Open</a>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="cctr-total-row">
|
||||
<td>Total</td>
|
||||
<td class="num">{{ totalHoldings }}</td>
|
||||
<td class="num">₹{{ totalFaceValue | number: '1.2-2' }}</td>
|
||||
<td class="num">₹{{ totalAccruedInterest | number: '1.2-2' }}</td>
|
||||
<td class="num">₹{{ totalMaturityValue | number: '1.2-2' }}</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</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="pos-section-label">Due-Date Diary — Maturing Within 90 Days</div>
|
||||
@if (maturingSoon.length === 0) {
|
||||
<div class="pg-empty">No holdings maturing in the next 90 days.</div>
|
||||
} @else {
|
||||
<div>
|
||||
@for (h of maturingSoon; track h.certNo) {
|
||||
<div class="inv-maturity-row">
|
||||
<span>
|
||||
<span class="pg-chip">{{ h.certNo }}</span>
|
||||
{{ h.scriptName }} — {{ h.party }} ({{ instrumentLabel(h.instrumentType) }})
|
||||
</span>
|
||||
<span>
|
||||
Matures {{ h.maturityDate }} ·
|
||||
<span
|
||||
class="inv-maturity-days"
|
||||
[class.inv-maturity-days--soon]="daysLeft(h) <= 30"
|
||||
[class.inv-maturity-days--ok]="daysLeft(h) > 30"
|
||||
>
|
||||
{{ daysLeft(h) }} days
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Quick Entry</div>
|
||||
<div class="inv-nav-grid" style="padding: 16px">
|
||||
<a class="inv-nav-card" routerLink="/investment/fd">
|
||||
<span class="inv-nav-card__label">Fixed Deposit</span>
|
||||
<span class="inv-nav-card__count">{{ summaries[0].count }}</span>
|
||||
<span class="inv-nav-card__value">Purchase / Interest / Sell / Write-off</span>
|
||||
</a>
|
||||
<a class="inv-nav-card" routerLink="/investment/bonds">
|
||||
<span class="inv-nav-card__label">Govt Security / Non-SLR / Bonds / MF</span>
|
||||
<span class="inv-nav-card__count">{{
|
||||
summaries[1].count + summaries[2].count + summaries[3].count + summaries[4].count
|
||||
}}</span>
|
||||
<span class="inv-nav-card__value">Purchase / Interest / Sell / Amortize</span>
|
||||
</a>
|
||||
<a class="inv-nav-card" routerLink="/investment/share">
|
||||
<span class="inv-nav-card__label">Share Entry</span>
|
||||
<span class="inv-nav-card__count">{{ summaries[5].count }}</span>
|
||||
<span class="inv-nav-card__value">Purchase / Dividend / Sell / Write-off</span>
|
||||
</a>
|
||||
<a class="inv-nav-card" routerLink="/investment/interest">
|
||||
<span class="inv-nav-card__label">Interest</span>
|
||||
<span class="inv-nav-card__count">5</span>
|
||||
<span class="inv-nav-card__value">FD / Bond Calculation & Registers</span>
|
||||
</a>
|
||||
<a class="inv-nav-card" routerLink="/investment/masters">
|
||||
<span class="inv-nav-card__label">Masters</span>
|
||||
<span class="inv-nav-card__count">10</span>
|
||||
<span class="inv-nav-card__value">Script / Broker / Party / Control Params</span>
|
||||
</a>
|
||||
<a class="inv-nav-card" routerLink="/investment/reports">
|
||||
<span class="inv-nav-card__label">Reports</span>
|
||||
<span class="inv-nav-card__count">20+</span>
|
||||
<span class="inv-nav-card__value">Balance / Ledger / Maturity / Portfolio</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="inv-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* 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 } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import {
|
||||
ALL_HOLDINGS,
|
||||
ALLOCATION_TARGET_IS_RBI_LINKED,
|
||||
INSTRUMENT_LABELS,
|
||||
InstrumentType,
|
||||
InvestmentHolding,
|
||||
TARGET_ALLOCATION_PCT,
|
||||
accruedInterest,
|
||||
daysToMaturity,
|
||||
maturityAmount
|
||||
} from '../investment-data';
|
||||
import { exportWorkbook } from '../investment-export';
|
||||
|
||||
interface InstrumentSummary {
|
||||
type: InstrumentType;
|
||||
label: string;
|
||||
route: string;
|
||||
count: number;
|
||||
faceValue: number;
|
||||
accruedInterest: 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
|
||||
* hand-duplicated, so it can never drift from the per-instrument screens or Reports. */
|
||||
@Component({
|
||||
selector: 'mifosx-investment-dashboard',
|
||||
templateUrl: './investment-dashboard.component.html',
|
||||
styleUrls: [
|
||||
'../../cashier/cashier-shared.scss',
|
||||
'../investment-shared.scss'
|
||||
],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterLink
|
||||
]
|
||||
})
|
||||
export class InvestmentDashboardComponent {
|
||||
asOnDate = '';
|
||||
generating = false;
|
||||
toastMsg = '';
|
||||
toastVisible = false;
|
||||
private toastTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
private readonly types: Array<{ type: InstrumentType; route: string }> = [
|
||||
{ type: 'FD', route: '/investment/fd' },
|
||||
{ type: 'GOVT_SECURITY', route: '/investment/bonds' },
|
||||
{ type: 'NON_SLR', route: '/investment/bonds' },
|
||||
{ type: 'BOND', route: '/investment/bonds' },
|
||||
{ type: 'MUTUAL_FUND', route: '/investment/bonds' },
|
||||
{ type: 'SHARE', route: '/investment/share' }
|
||||
];
|
||||
|
||||
constructor() {
|
||||
const d = new Date();
|
||||
this.asOnDate = `${String(d.getDate()).padStart(2, '0')}-${String(d.getMonth() + 1).padStart(2, '0')}-${d.getFullYear()}`;
|
||||
}
|
||||
|
||||
private holdingsOf(type: InstrumentType): InvestmentHolding[] {
|
||||
return ALL_HOLDINGS.filter((h) => h.instrumentType === type);
|
||||
}
|
||||
|
||||
get summaries(): InstrumentSummary[] {
|
||||
return this.types.map(({ type, route }) => {
|
||||
const holdings = this.holdingsOf(type);
|
||||
return {
|
||||
type,
|
||||
label: INSTRUMENT_LABELS[type],
|
||||
route,
|
||||
count: holdings.length,
|
||||
faceValue: holdings.reduce((s, h) => s + h.faceValue, 0),
|
||||
accruedInterest: holdings.reduce((s, h) => s + accruedInterest(h, this.asOnDate), 0),
|
||||
maturityValue: holdings.reduce((s, h) => s + maturityAmount(h), 0)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
return ALL_HOLDINGS.length;
|
||||
}
|
||||
|
||||
get totalFaceValue(): number {
|
||||
return ALL_HOLDINGS.reduce((s, h) => s + h.faceValue, 0);
|
||||
}
|
||||
|
||||
get totalAccruedInterest(): number {
|
||||
return ALL_HOLDINGS.reduce((s, h) => s + accruedInterest(h, this.asOnDate), 0);
|
||||
}
|
||||
|
||||
get totalMaturityValue(): number {
|
||||
return ALL_HOLDINGS.reduce((s, h) => s + maturityAmount(h), 0);
|
||||
}
|
||||
|
||||
get activeCount(): number {
|
||||
return ALL_HOLDINGS.filter((h) => h.status === 'ACTIVE').length;
|
||||
}
|
||||
|
||||
get maturedCount(): number {
|
||||
return ALL_HOLDINGS.filter((h) => h.status === 'MATURED').length;
|
||||
}
|
||||
|
||||
/** Holdings maturing within the next 90 days — a "due-date diary" style widget. */
|
||||
get maturingSoon(): InvestmentHolding[] {
|
||||
return ALL_HOLDINGS.filter((h) => h.status === 'ACTIVE' && daysToMaturity(h, this.asOnDate) <= 90).sort(
|
||||
(a, b) => daysToMaturity(a, this.asOnDate) - daysToMaturity(b, this.asOnDate)
|
||||
);
|
||||
}
|
||||
|
||||
daysLeft(h: InvestmentHolding): number {
|
||||
return daysToMaturity(h, this.asOnDate);
|
||||
}
|
||||
|
||||
instrumentLabel(type: InstrumentType): string {
|
||||
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);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,609 @@
|
||||
<!--
|
||||
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">TREASURY · INVESTMENT · INTEREST</div>
|
||||
<h1 class="pg-title">Investment Interest</h1>
|
||||
<p class="pg-sub">
|
||||
Interest Receivable, FD and Govt./Bond interest calculation-and-post batch runs, Interest Receive, plus the
|
||||
Government Bond Interest, FD Interest and FD registers.
|
||||
</p>
|
||||
</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>
|
||||
|
||||
<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
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeTab === 'fdCalc'"
|
||||
(click)="setTab('fdCalc')"
|
||||
>
|
||||
01 FD Interest Calculation & Post
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeTab === 'bondCalc'"
|
||||
(click)="setTab('bondCalc')"
|
||||
>
|
||||
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
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeTab === 'bondIntRegister'"
|
||||
(click)="setTab('bondIntRegister')"
|
||||
>
|
||||
03 Government Bond Int Register
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeTab === 'fdIntRegister'"
|
||||
(click)="setTab('fdIntRegister')"
|
||||
>
|
||||
04 FD Interest Register
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeTab === 'fdRegister'"
|
||||
(click)="setTab('fdRegister')"
|
||||
>
|
||||
05 FD Register
|
||||
</button>
|
||||
</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 -->
|
||||
@if (activeTab === 'fdCalc') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Investment - FD Interest Calculation & Post</div>
|
||||
<div class="inv-form-grid">
|
||||
<label class="inv-form-field"
|
||||
><span>From Date</span><mifosx-date-ddmmyyyy [(ngModel)]="fdCalcFromDate"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
<label class="inv-form-field"
|
||||
><span>To Date</span><mifosx-date-ddmmyyyy [(ngModel)]="fdCalcToDate"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
<label class="inv-form-field">
|
||||
<span>Certificate No</span>
|
||||
<select [(ngModel)]="fdCalcCertNo">
|
||||
<option value="">-- All Certificates --</option>
|
||||
@for (h of fdHoldings; track h.certNo) {
|
||||
<option [value]="h.certNo">{{ h.certNo }} — {{ h.party }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label class="inv-form-field"
|
||||
><span>ALL</span><input type="checkbox" [(ngModel)]="fdCalcAll" style="height: 18px; width: 18px"
|
||||
/></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"
|
||||
><span>Total Interest to Post</span
|
||||
><input type="text" [value]="'₹' + (fdCalcTotal | number: '1.2-2')" readonly
|
||||
/></label>
|
||||
</div>
|
||||
<div class="inv-form-actions">
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="processFdInterest()">Calculate & Post</button>
|
||||
</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Cert No</th>
|
||||
<th>Party</th>
|
||||
<th class="num">Face Value</th>
|
||||
<th class="num">Rate</th>
|
||||
<th>Frequency</th>
|
||||
<th class="num col-in">Interest</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (h of fdCalcTargets; track h.certNo) {
|
||||
<tr>
|
||||
<td>
|
||||
<span class="pg-chip">{{ h.certNo }}</span>
|
||||
</td>
|
||||
<td>{{ h.party }}</td>
|
||||
<td class="num">₹{{ h.faceValue | number: '1.2-2' }}</td>
|
||||
<td class="num">{{ h.interestRate }}%</td>
|
||||
<td>{{ h.interestFreq }}</td>
|
||||
<td class="num col-in">₹{{ fdInterest(h) | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="6" class="pg-empty">No FD certificates match the filter.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="cctr-total-row">
|
||||
<td colspan="5">Total</td>
|
||||
<td class="num">₹{{ fdCalcTotal | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</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>
|
||||
}
|
||||
|
||||
<!-- 02 GOVT./BOND INT. CALCULATION -->
|
||||
@if (activeTab === 'bondCalc') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Investment - Govt./Bond Interest Calculation & Post</div>
|
||||
<div class="inv-form-grid">
|
||||
<label class="inv-form-field"
|
||||
><span>From Date</span><mifosx-date-ddmmyyyy [(ngModel)]="bondCalcFromDate"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
<label class="inv-form-field"
|
||||
><span>To Date</span><mifosx-date-ddmmyyyy [(ngModel)]="bondCalcToDate"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
<label class="inv-form-field">
|
||||
<span>Type</span>
|
||||
<select [(ngModel)]="bondCalcType" (ngModelChange)="onBondCalcTypeChange()">
|
||||
<option value="">-- All Types --</option>
|
||||
<option value="GOVT_SECURITY">GOVT SECURITY</option>
|
||||
<option value="NON_SLR">NON SLR</option>
|
||||
<option value="BOND">BOND</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="inv-form-field">
|
||||
<span>Script</span>
|
||||
<select [(ngModel)]="bondCalcScript">
|
||||
<option value="">-- All Scripts --</option>
|
||||
@for (s of scriptOptions; track s.scriptCode) {
|
||||
<option [value]="s.scriptCode">{{ s.scriptCode }} — {{ s.scriptName }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label class="inv-form-field"><span>Deal No</span><input type="text" [(ngModel)]="bondCalcDealNo" /></label>
|
||||
<label class="inv-form-field"
|
||||
><span>ALL</span><input type="checkbox" [(ngModel)]="bondCalcAll" style="height: 18px; width: 18px"
|
||||
/></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"
|
||||
><span>Total Interest to Post</span
|
||||
><input type="text" [value]="'₹' + (bondCalcTotal | number: '1.2-2')" readonly
|
||||
/></label>
|
||||
</div>
|
||||
<div class="inv-form-actions">
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="processBondInterest()">
|
||||
Calculate & Post
|
||||
</button>
|
||||
</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Cert No</th>
|
||||
<th>Script</th>
|
||||
<th>Type</th>
|
||||
<th class="num">Face Value</th>
|
||||
<th class="num">Rate</th>
|
||||
<th>Frequency</th>
|
||||
<th class="num col-in">Interest</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (h of bondCalcTargets; track h.certNo) {
|
||||
<tr>
|
||||
<td>
|
||||
<span class="pg-chip">{{ h.certNo }}</span>
|
||||
</td>
|
||||
<td>{{ h.scriptName }}</td>
|
||||
<td>{{ instrumentLabel(h.instrumentType) }}</td>
|
||||
<td class="num">₹{{ h.faceValue | number: '1.2-2' }}</td>
|
||||
<td class="num">{{ h.interestRate }}%</td>
|
||||
<td>{{ h.interestFreq }}</td>
|
||||
<td class="num col-in">₹{{ bondInterest(h) | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="7" class="pg-empty">No holdings match the filter.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="cctr-total-row">
|
||||
<td colspan="6">Total</td>
|
||||
<td class="num">₹{{ bondCalcTotal | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</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>
|
||||
}
|
||||
|
||||
<!-- 03 GOVERNMENT BOND INT REGISTER -->
|
||||
@if (activeTab === 'bondIntRegister') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Government Bond Interest Register</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Voucher No</th>
|
||||
<th>Cert No</th>
|
||||
<th>Instrument</th>
|
||||
<th>Particulars</th>
|
||||
<th>GL Posted</th>
|
||||
<th class="num col-in">Interest Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (t of bondIntRegister; track t.voucherNo) {
|
||||
<tr>
|
||||
<td>{{ t.txnDate }}</td>
|
||||
<td>
|
||||
<span class="pg-chip">{{ t.voucherNo }}</span>
|
||||
</td>
|
||||
<td>{{ t.certNo }}</td>
|
||||
<td>{{ instrumentLabel(t.instrumentType) }}</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="7" class="pg-empty">No bond interest entries recorded yet.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="cctr-total-row">
|
||||
<td colspan="6">Total</td>
|
||||
<td class="num">₹{{ bondIntRegisterTotal | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- 04 FD INTEREST REGISTER -->
|
||||
@if (activeTab === 'fdIntRegister') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">FD Interest Register</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Voucher No</th>
|
||||
<th>Cert No</th>
|
||||
<th>Particulars</th>
|
||||
<th>GL Posted</th>
|
||||
<th class="num col-in">Interest Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (t of fdIntRegister; 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 FD interest entries recorded yet.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="cctr-total-row">
|
||||
<td colspan="5">Total</td>
|
||||
<td class="num">₹{{ fdIntRegisterTotal | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- 05 FD REGISTER -->
|
||||
@if (activeTab === 'fdRegister') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">FD Register</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Cert No</th>
|
||||
<th>Party</th>
|
||||
<th>Sub Type</th>
|
||||
<th class="num">Face Value</th>
|
||||
<th class="num">Rate</th>
|
||||
<th>Frequency</th>
|
||||
<th>Deposit Date</th>
|
||||
<th>Maturity Date</th>
|
||||
<th class="num">Days Left</th>
|
||||
<th class="num col-in">Accrued Interest</th>
|
||||
<th class="num col-net">Maturity Value</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (h of fdRegister; track h.certNo) {
|
||||
<tr>
|
||||
<td>
|
||||
<span class="pg-chip">{{ h.certNo }}</span>
|
||||
</td>
|
||||
<td>{{ h.party }}</td>
|
||||
<td>{{ h.subType }}</td>
|
||||
<td class="num">₹{{ h.faceValue | number: '1.2-2' }}</td>
|
||||
<td class="num">{{ h.interestRate }}%</td>
|
||||
<td>{{ h.interestFreq }}</td>
|
||||
<td>{{ h.purchaseDate }}</td>
|
||||
<td>{{ h.maturityDate }}</td>
|
||||
<td class="num">{{ daysLeft(h) }}</td>
|
||||
<td class="num col-in">₹{{ accrued(h) | number: '1.2-2' }}</td>
|
||||
<td class="num col-net">₹{{ maturity(h) | number: '1.2-2' }}</td>
|
||||
<td>
|
||||
<span [class]="statusClass(h)">{{ h.status }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="cctr-total-row">
|
||||
<td colspan="3">Total</td>
|
||||
<td class="num">₹{{ fdRegisterFaceTotal | number: '1.2-2' }}</td>
|
||||
<td colspan="5"></td>
|
||||
<td class="num">₹{{ fdRegisterAccruedTotal | number: '1.2-2' }}</td>
|
||||
<td class="num">₹{{ fdRegisterMaturityTotal | number: '1.2-2' }}</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="inv-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
|
||||
@@ -0,0 +1,760 @@
|
||||
/**
|
||||
* 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 } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { DateDdmmyyyyPickerComponent } from '../date-ddmmyyyy-picker/date-ddmmyyyy-picker.component';
|
||||
import {
|
||||
FD_HOLDINGS,
|
||||
GOVT_SECURITY_HOLDINGS,
|
||||
NON_SLR_HOLDINGS,
|
||||
BOND_HOLDINGS,
|
||||
ALL_TXNS,
|
||||
SCRIPT_MASTER,
|
||||
INTEREST_INCOME_GL_MASTER,
|
||||
INTEREST_INCOME_GL_BY_TYPE,
|
||||
CASH_BANK_GL_MASTER,
|
||||
InvestmentHolding,
|
||||
InvestmentTxn,
|
||||
InstrumentType,
|
||||
InvestmentProvisionStatus,
|
||||
INSTRUMENT_LABELS,
|
||||
PROVISION_STATUS_LABELS,
|
||||
accruedInterest,
|
||||
accruedInterestBetween,
|
||||
addPeriod,
|
||||
daysToMaturity,
|
||||
maturityAmount,
|
||||
todayDDMMYYYY,
|
||||
isNpi,
|
||||
canAccrueInterest,
|
||||
interestReceivableBalance,
|
||||
suspenseInterest,
|
||||
provisionRequiredInv
|
||||
} from '../investment-data';
|
||||
import { exportWorkbook, ExportSheetSpec } from '../investment-export';
|
||||
|
||||
type InterestTab =
|
||||
| 'receivable'
|
||||
| 'fdCalc'
|
||||
| 'bondCalc'
|
||||
| 'receive'
|
||||
| 'bondIntRegister'
|
||||
| 'fdIntRegister'
|
||||
| 'fdRegister';
|
||||
|
||||
/**
|
||||
* The legacy "Interest" top-menu for Investment — plus the full accrual lifecycle: Interest
|
||||
* Receivable, 01 FD Interest Calculation, 02 Govt./Bond Int. Calculation, Interest Receive,
|
||||
* 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,
|
||||
* 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({
|
||||
selector: 'mifosx-investment-interest',
|
||||
templateUrl: './investment-interest.component.html',
|
||||
styleUrls: [
|
||||
'../../cashier/cashier-shared.scss',
|
||||
'../investment-shared.scss'
|
||||
],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
DateDdmmyyyyPickerComponent
|
||||
]
|
||||
})
|
||||
export class InvestmentInterestComponent {
|
||||
activeTab: InterestTab = 'receivable';
|
||||
asOnDate = todayDDMMYYYY();
|
||||
|
||||
fdHoldings: InvestmentHolding[] = [...FD_HOLDINGS];
|
||||
bondHoldings: InvestmentHolding[] = [
|
||||
...GOVT_SECURITY_HOLDINGS,
|
||||
...NON_SLR_HOLDINGS,
|
||||
...BOND_HOLDINGS
|
||||
];
|
||||
txns: InvestmentTxn[] = [...ALL_TXNS];
|
||||
|
||||
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 = '';
|
||||
toastVisible = false;
|
||||
private toastTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
// 01 FD Interest Calculation — posts interest accrued strictly within [fromDate, toDate]
|
||||
fdCalcFromDate = addPeriod(this.asOnDate, 0, -3);
|
||||
fdCalcToDate = this.asOnDate;
|
||||
fdCalcCertNo = '';
|
||||
fdCalcAll = false;
|
||||
fdCalcGlCode = INTEREST_INCOME_GL_BY_TYPE.FD;
|
||||
|
||||
// 02 Govt./Bond Int. Calculation — posts interest accrued strictly within [fromDate, toDate]
|
||||
bondCalcFromDate = addPeriod(this.asOnDate, 0, -3);
|
||||
bondCalcToDate = this.asOnDate;
|
||||
bondCalcType: InstrumentType | '' = '';
|
||||
bondCalcScript = '';
|
||||
bondCalcDealNo = '';
|
||||
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
|
||||
registerFromDate = '';
|
||||
registerToDate = '';
|
||||
|
||||
setTab(tab: InterestTab): void {
|
||||
this.activeTab = tab;
|
||||
}
|
||||
|
||||
instrumentLabel(type: InstrumentType): string {
|
||||
return INSTRUMENT_LABELS[type];
|
||||
}
|
||||
|
||||
statusClass(h: InvestmentHolding): string {
|
||||
return `inv-badge inv-badge--${h.status.toLowerCase()}`;
|
||||
}
|
||||
|
||||
maturity(h: InvestmentHolding): number {
|
||||
return maturityAmount(h);
|
||||
}
|
||||
|
||||
daysLeft(h: InvestmentHolding): number {
|
||||
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 --------------------
|
||||
get fdCalcTargets(): InvestmentHolding[] {
|
||||
const base = this.fdHoldings.filter((h) => h.status === 'ACTIVE' && canAccrueInterest(h));
|
||||
if (this.fdCalcAll) {
|
||||
return base;
|
||||
}
|
||||
return base.filter((h) => !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 {
|
||||
return accruedInterestBetween(h, this.fdCalcFromDate || h.purchaseDate, this.fdCalcToDate || this.asOnDate);
|
||||
}
|
||||
|
||||
get fdCalcTotal(): number {
|
||||
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 {
|
||||
const targets = this.fdCalcTargets.filter((h) => this.fdInterest(h) > 0);
|
||||
if (targets.length === 0) {
|
||||
this.toast('No FD holdings have interest due to be posted for this period.');
|
||||
return;
|
||||
}
|
||||
const toDate = this.fdCalcToDate || this.asOnDate;
|
||||
const fromDate = this.fdCalcFromDate || toDate;
|
||||
const glCode = this.fdCalcGlCode;
|
||||
const glName = this.glName(glCode);
|
||||
const newTxns: InvestmentTxn[] = targets.map((h, i) => {
|
||||
const amt = this.fdInterest(h);
|
||||
h.interestPosted = this.posted(h) + amt;
|
||||
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 = [
|
||||
...newTxns,
|
||||
...this.txns
|
||||
];
|
||||
this.toast(
|
||||
`FD interest posted for ${targets.length} certificate(s) to ${glCode} - ${glName}. Total ₹${total.toLocaleString('en-IN')}.`
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------- 02 Govt./Bond Int. Calculation --------------------
|
||||
get bondCalcTargets(): InvestmentHolding[] {
|
||||
const base = this.bondHoldings.filter((h) => h.status === 'ACTIVE' && canAccrueInterest(h));
|
||||
if (this.bondCalcAll) {
|
||||
return base;
|
||||
}
|
||||
return base.filter((h) => {
|
||||
if (this.bondCalcType && h.instrumentType !== this.bondCalcType) return false;
|
||||
if (this.bondCalcScript && h.scriptCode !== this.bondCalcScript) return false;
|
||||
if (this.bondCalcDealNo && h.certNo !== this.bondCalcDealNo.trim()) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
return accruedInterestBetween(h, this.bondCalcFromDate || h.purchaseDate, this.bondCalcToDate || this.asOnDate);
|
||||
}
|
||||
|
||||
get bondCalcTotal(): number {
|
||||
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 {
|
||||
const targets = this.bondCalcTargets.filter((h) => this.bondInterest(h) > 0);
|
||||
if (targets.length === 0) {
|
||||
this.toast('No Govt/Bond holdings have interest due to be posted for this period.');
|
||||
return;
|
||||
}
|
||||
const toDate = this.bondCalcToDate || this.asOnDate;
|
||||
const fromDate = this.bondCalcFromDate || toDate;
|
||||
const glCode = this.bondCalcGlCode;
|
||||
const glName = this.glName(glCode);
|
||||
const newTxns: InvestmentTxn[] = targets.map((h, i) => {
|
||||
const amt = this.bondInterest(h);
|
||||
h.interestPosted = this.posted(h) + amt;
|
||||
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 = [
|
||||
...newTxns,
|
||||
...this.txns
|
||||
];
|
||||
this.bondCalcFromDate = toDate;
|
||||
this.toast(
|
||||
`Interest posted for ${targets.length} holding(s) to ${glCode} - ${glName}. Total ₹${total.toLocaleString('en-IN')}.`
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------- Interest Receivable (all interest-bearing types) --------------------
|
||||
/** 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 --------------------
|
||||
get bondIntRegister(): InvestmentTxn[] {
|
||||
return this.txns.filter(
|
||||
(t) => t.txnType === 'INTEREST' && ([
|
||||
'GOVT_SECURITY',
|
||||
'NON_SLR',
|
||||
'BOND'
|
||||
] as InstrumentType[]).includes(t.instrumentType)
|
||||
);
|
||||
}
|
||||
|
||||
get bondIntRegisterTotal(): number {
|
||||
return this.bondIntRegister.reduce((s, t) => s + t.amount, 0);
|
||||
}
|
||||
|
||||
// -------------------- 04 FD Interest Register --------------------
|
||||
get fdIntRegister(): InvestmentTxn[] {
|
||||
return this.txns.filter((t) => t.txnType === 'INTEREST' && t.instrumentType === 'FD');
|
||||
}
|
||||
|
||||
get fdIntRegisterTotal(): number {
|
||||
return this.fdIntRegister.reduce((s, t) => s + t.amount, 0);
|
||||
}
|
||||
|
||||
// -------------------- 05 FD Register --------------------
|
||||
get fdRegister(): InvestmentHolding[] {
|
||||
return this.fdHoldings;
|
||||
}
|
||||
|
||||
get fdRegisterFaceTotal(): number {
|
||||
return this.fdRegister.reduce((s, h) => s + h.faceValue, 0);
|
||||
}
|
||||
|
||||
get fdRegisterMaturityTotal(): number {
|
||||
return this.fdRegister.reduce((s, h) => s + this.maturity(h), 0);
|
||||
}
|
||||
|
||||
accrued(h: InvestmentHolding): number {
|
||||
return accruedInterest(h, this.asOnDate);
|
||||
}
|
||||
|
||||
get fdRegisterAccruedTotal(): number {
|
||||
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 {
|
||||
this.toastMsg = msg;
|
||||
this.toastVisible = true;
|
||||
if (this.toastTimer) {
|
||||
clearTimeout(this.toastTimer);
|
||||
}
|
||||
this.toastTimer = setTimeout(() => (this.toastVisible = false), 3200);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
<!--
|
||||
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">TREASURY · INVESTMENT · MASTERS</div>
|
||||
<h1 class="pg-title">Investment Masters</h1>
|
||||
<p class="pg-sub">
|
||||
Bank/Party, Script, SubType, Broker, Control Parameter, LIEN Reason, Mutual Fund Product, SGL Bank and Script
|
||||
Market Detail masters.
|
||||
</p>
|
||||
</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>
|
||||
|
||||
<div class="cctr-tabs">
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeTab === 'party'"
|
||||
(click)="setTab('party')"
|
||||
>
|
||||
04 Bank / Party Master
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeTab === 'script'"
|
||||
(click)="setTab('script')"
|
||||
>
|
||||
05 Script Master
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeTab === 'subtype'"
|
||||
(click)="setTab('subtype')"
|
||||
>
|
||||
06 SubType Master
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeTab === 'broker'"
|
||||
(click)="setTab('broker')"
|
||||
>
|
||||
07 Broker Master
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeTab === 'control'"
|
||||
(click)="setTab('control')"
|
||||
>
|
||||
08 Control Parameter
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeTab === 'dailyscript'"
|
||||
(click)="setTab('dailyscript')"
|
||||
>
|
||||
09 Daily Script Master
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeTab === 'lien'"
|
||||
(click)="setTab('lien')"
|
||||
>
|
||||
10 LIEN Reason Master
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeTab === 'mfproduct'"
|
||||
(click)="setTab('mfproduct')"
|
||||
>
|
||||
11 Mutual Fund Product Master
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeTab === 'sglbank'"
|
||||
(click)="setTab('sglbank')"
|
||||
>
|
||||
12 SGL Bank Master
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeTab === 'marketdetail'"
|
||||
(click)="setTab('marketdetail')"
|
||||
>
|
||||
13 Script Market Detail
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- PARTY MASTER -->
|
||||
@if (activeTab === 'party') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Add Bank / Party</div>
|
||||
<div class="inv-form-grid">
|
||||
<label class="inv-form-field"
|
||||
><span>Party Name *</span><input type="text" [(ngModel)]="newParty.partyName"
|
||||
/></label>
|
||||
<label class="inv-form-field">
|
||||
<span>Party Type *</span>
|
||||
<select [(ngModel)]="newParty.partyType">
|
||||
<option [ngValue]="undefined">-- Select --</option>
|
||||
<option value="BANK">BANK</option>
|
||||
<option value="CORPORATE">CORPORATE</option>
|
||||
<option value="GOVT">GOVT</option>
|
||||
<option value="AMC">AMC</option>
|
||||
<option value="EXCHANGE">EXCHANGE</option>
|
||||
<option value="COOP_SOCIETY">COOP SOCIETY</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="inv-form-field"><span>PAN No</span><input type="text" [(ngModel)]="newParty.panNo" /></label>
|
||||
</div>
|
||||
<div class="inv-form-actions">
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="addParty()">Save</button>
|
||||
</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Party Code</th>
|
||||
<th>Party Name</th>
|
||||
<th>Type</th>
|
||||
<th>PAN No</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (p of parties; track p.partyCode) {
|
||||
<tr>
|
||||
<td>
|
||||
<span class="pg-chip">{{ p.partyCode }}</span>
|
||||
</td>
|
||||
<td>{{ p.partyName }}</td>
|
||||
<td>{{ p.partyType }}</td>
|
||||
<td>{{ p.panNo }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- SCRIPT MASTER -->
|
||||
@if (activeTab === 'script') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Add Script</div>
|
||||
<div class="inv-form-grid">
|
||||
<label class="inv-form-field"
|
||||
><span>Script Code</span><input type="text" [(ngModel)]="newScript.scriptCode"
|
||||
/></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Script Name *</span><input type="text" [(ngModel)]="newScript.scriptName"
|
||||
/></label>
|
||||
<label class="inv-form-field">
|
||||
<span>Instrument Type *</span>
|
||||
<select [(ngModel)]="newScript.instrumentType">
|
||||
<option [ngValue]="undefined">-- Select --</option>
|
||||
@for (t of instrumentTypes; track t) {
|
||||
<option [value]="t">{{ t }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label class="inv-form-field"><span>ISIN</span><input type="text" [(ngModel)]="newScript.isin" /></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Face Value / Unit</span><input type="number" [(ngModel)]="newScript.faceValuePerUnit"
|
||||
/></label>
|
||||
</div>
|
||||
<div class="inv-form-actions">
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="addScript()">Save</button>
|
||||
</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Script Code</th>
|
||||
<th>Script Name</th>
|
||||
<th>Instrument</th>
|
||||
<th>ISIN</th>
|
||||
<th class="num">Face Value/Unit</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (s of scripts; track s.scriptCode) {
|
||||
<tr>
|
||||
<td>
|
||||
<span class="pg-chip">{{ s.scriptCode }}</span>
|
||||
</td>
|
||||
<td>{{ s.scriptName }}</td>
|
||||
<td>{{ s.instrumentType }}</td>
|
||||
<td>{{ s.isin || '—' }}</td>
|
||||
<td class="num">{{ s.faceValuePerUnit }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- SUBTYPE MASTER -->
|
||||
@if (activeTab === 'subtype') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Add Sub Type</div>
|
||||
<div class="inv-form-grid">
|
||||
<label class="inv-form-field"
|
||||
><span>Sub Type Name *</span><input type="text" [(ngModel)]="newSubType.subTypeName"
|
||||
/></label>
|
||||
<label class="inv-form-field">
|
||||
<span>Instrument Type *</span>
|
||||
<select [(ngModel)]="newSubType.instrumentType">
|
||||
<option [ngValue]="undefined">-- Select --</option>
|
||||
@for (t of instrumentTypes; track t) {
|
||||
<option [value]="t">{{ t }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="inv-form-actions">
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="addSubType()">Save</button>
|
||||
</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Code</th>
|
||||
<th>Sub Type</th>
|
||||
<th>Instrument</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (s of subTypes; track s.subTypeCode + s.subTypeName) {
|
||||
<tr>
|
||||
<td>
|
||||
<span class="pg-chip">{{ s.subTypeCode }}</span>
|
||||
</td>
|
||||
<td>{{ s.subTypeName }}</td>
|
||||
<td>{{ s.instrumentType }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- BROKER MASTER -->
|
||||
@if (activeTab === 'broker') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Add Broker</div>
|
||||
<div class="inv-form-grid">
|
||||
<label class="inv-form-field"
|
||||
><span>Broker Name *</span><input type="text" [(ngModel)]="newBroker.brokerName"
|
||||
/></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Contact No</span><input type="text" [(ngModel)]="newBroker.contactNo"
|
||||
/></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Empanelled On</span><mifosx-date-ddmmyyyy [(ngModel)]="newBroker.empanelledOn"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
</div>
|
||||
<div class="inv-form-actions">
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="addBroker()">Save</button>
|
||||
</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Broker Code</th>
|
||||
<th>Broker Name</th>
|
||||
<th>Contact</th>
|
||||
<th>Empanelled On</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (b of brokers; track b.brokerCode) {
|
||||
<tr>
|
||||
<td>
|
||||
<span class="pg-chip">{{ b.brokerCode }}</span>
|
||||
</td>
|
||||
<td>{{ b.brokerName }}</td>
|
||||
<td>{{ b.contactNo }}</td>
|
||||
<td>{{ b.empanelledOn }}</td>
|
||||
<td>
|
||||
<span class="inv-badge inv-badge--active">{{ b.status }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- CONTROL PARAMETER -->
|
||||
@if (activeTab === 'control') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Add Control Parameter</div>
|
||||
<div class="inv-form-grid">
|
||||
<label class="inv-form-field"
|
||||
><span>Parameter Name *</span><input type="text" [(ngModel)]="newControlParam.paramName"
|
||||
/></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Value *</span><input type="text" [(ngModel)]="newControlParam.paramValue"
|
||||
/></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Description</span><input type="text" [(ngModel)]="newControlParam.description"
|
||||
/></label>
|
||||
</div>
|
||||
<div class="inv-form-actions">
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="addControlParam()">Save</button>
|
||||
</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Parameter</th>
|
||||
<th>Value</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (c of controlParams; track c.paramName) {
|
||||
<tr>
|
||||
<td>
|
||||
<span class="pg-chip">{{ c.paramName }}</span>
|
||||
</td>
|
||||
<td class="num">{{ c.paramValue }}</td>
|
||||
<td>{{ c.description }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- DAILY SCRIPT MASTER -->
|
||||
@if (activeTab === 'dailyscript') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Add Daily Script Rate</div>
|
||||
<div class="inv-form-grid">
|
||||
<label class="inv-form-field"
|
||||
><span>Rate Date *</span><mifosx-date-ddmmyyyy [(ngModel)]="newDailyRate.rateDate"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
<label class="inv-form-field">
|
||||
<span>Script Code *</span>
|
||||
<select [(ngModel)]="newDailyRate.scriptCode">
|
||||
<option [ngValue]="undefined">-- Select --</option>
|
||||
@for (s of scripts; track s.scriptCode) {
|
||||
<option [value]="s.scriptCode">{{ s.scriptCode }} — {{ s.scriptName }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label class="inv-form-field"
|
||||
><span>Open Rate</span><input type="number" [(ngModel)]="newDailyRate.openRate" step="0.01"
|
||||
/></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Close Rate *</span><input type="number" [(ngModel)]="newDailyRate.closeRate" step="0.01"
|
||||
/></label>
|
||||
<label class="inv-form-field"
|
||||
><span>High Rate</span><input type="number" [(ngModel)]="newDailyRate.highRate" step="0.01"
|
||||
/></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Low Rate</span><input type="number" [(ngModel)]="newDailyRate.lowRate" step="0.01"
|
||||
/></label>
|
||||
</div>
|
||||
<div class="inv-form-actions">
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="addDailyRate()">Save</button>
|
||||
</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Rate Date</th>
|
||||
<th>Script Code</th>
|
||||
<th>Script Name</th>
|
||||
<th class="num">Open</th>
|
||||
<th class="num">High</th>
|
||||
<th class="num">Low</th>
|
||||
<th class="num">Close</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of dailyScriptRates; track r.rateDate + r.scriptCode) {
|
||||
<tr>
|
||||
<td>{{ r.rateDate }}</td>
|
||||
<td>
|
||||
<span class="pg-chip">{{ r.scriptCode }}</span>
|
||||
</td>
|
||||
<td>{{ r.scriptName }}</td>
|
||||
<td class="num">{{ r.openRate | number: '1.2-2' }}</td>
|
||||
<td class="num">{{ r.highRate | number: '1.2-2' }}</td>
|
||||
<td class="num">{{ r.lowRate | number: '1.2-2' }}</td>
|
||||
<td class="num">{{ r.closeRate | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- LIEN REASON MASTER -->
|
||||
@if (activeTab === 'lien') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Add LIEN Reason</div>
|
||||
<div class="inv-form-grid">
|
||||
<label class="inv-form-field"
|
||||
><span>Reason Description *</span><input type="text" [(ngModel)]="newLienReason.reasonDescription"
|
||||
/></label>
|
||||
</div>
|
||||
<div class="inv-form-actions">
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="addLienReason()">Save</button>
|
||||
</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Reason Code</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (l of lienReasons; track l.reasonCode) {
|
||||
<tr>
|
||||
<td>
|
||||
<span class="pg-chip">{{ l.reasonCode }}</span>
|
||||
</td>
|
||||
<td>{{ l.reasonDescription }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- MUTUAL FUND PRODUCT MASTER -->
|
||||
@if (activeTab === 'mfproduct') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Add Mutual Fund Product</div>
|
||||
<div class="inv-form-grid">
|
||||
<label class="inv-form-field"
|
||||
><span>Product Name *</span><input type="text" [(ngModel)]="newMfProduct.productName"
|
||||
/></label>
|
||||
<label class="inv-form-field"
|
||||
><span>AMC Name *</span><input type="text" [(ngModel)]="newMfProduct.amcName"
|
||||
/></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Category</span><input type="text" [(ngModel)]="newMfProduct.category"
|
||||
/></label>
|
||||
<label class="inv-form-field">
|
||||
<span>Risk Level</span>
|
||||
<select [(ngModel)]="newMfProduct.riskLevel">
|
||||
<option value="LOW">LOW</option>
|
||||
<option value="MODERATE">MODERATE</option>
|
||||
<option value="HIGH">HIGH</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="inv-form-actions">
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="addMfProduct()">Save</button>
|
||||
</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Product Code</th>
|
||||
<th>Product Name</th>
|
||||
<th>AMC</th>
|
||||
<th>Category</th>
|
||||
<th>Risk</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (m of mfProducts; track m.productCode) {
|
||||
<tr>
|
||||
<td>
|
||||
<span class="pg-chip">{{ m.productCode }}</span>
|
||||
</td>
|
||||
<td>{{ m.productName }}</td>
|
||||
<td>{{ m.amcName }}</td>
|
||||
<td>{{ m.category }}</td>
|
||||
<td>{{ m.riskLevel }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- SGL BANK MASTER -->
|
||||
@if (activeTab === 'sglbank') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Add SGL Bank</div>
|
||||
<div class="inv-form-grid">
|
||||
<label class="inv-form-field"
|
||||
><span>Bank Name *</span><input type="text" [(ngModel)]="newSglBank.bankName"
|
||||
/></label>
|
||||
<label class="inv-form-field"><span>Branch</span><input type="text" [(ngModel)]="newSglBank.branch" /></label>
|
||||
</div>
|
||||
<div class="inv-form-actions">
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="addSglBank()">Save</button>
|
||||
</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>SGL Code</th>
|
||||
<th>Bank Name</th>
|
||||
<th>Branch</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (s of sglBanks; track s.sglCode) {
|
||||
<tr>
|
||||
<td>
|
||||
<span class="pg-chip">{{ s.sglCode }}</span>
|
||||
</td>
|
||||
<td>{{ s.bankName }}</td>
|
||||
<td>{{ s.branch }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- SCRIPT MARKET DETAIL (also covers "09 Daily Script Master") -->
|
||||
@if (activeTab === 'marketdetail') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Script Market Detail (Daily Script Rates)</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Script Code</th>
|
||||
<th class="num">Market Price</th>
|
||||
<th>As On</th>
|
||||
<th class="num">YTM (%)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (m of marketDetails; track m.scriptCode) {
|
||||
<tr>
|
||||
<td>
|
||||
<span class="pg-chip">{{ m.scriptCode }}</span>
|
||||
</td>
|
||||
<td class="num">₹{{ m.marketPrice | number: '1.2-2' }}</td>
|
||||
<td>{{ m.asOnDate }}</td>
|
||||
<td class="num">{{ m.ytm }}%</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="inv-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
|
||||
@@ -0,0 +1,517 @@
|
||||
/**
|
||||
* 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 } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { DateDdmmyyyyPickerComponent } from '../date-ddmmyyyy-picker/date-ddmmyyyy-picker.component';
|
||||
import {
|
||||
SCRIPT_MASTER,
|
||||
BROKER_MASTER,
|
||||
PARTY_MASTER,
|
||||
SUBTYPE_MASTER,
|
||||
CONTROL_PARAMETERS,
|
||||
MUTUAL_FUND_PRODUCTS,
|
||||
SGL_BANK_MASTER,
|
||||
SCRIPT_MARKET_DETAIL,
|
||||
DAILY_SCRIPT_RATES,
|
||||
LIEN_REASON_MASTER,
|
||||
ScriptMaster,
|
||||
BrokerMaster,
|
||||
PartyMaster,
|
||||
SubTypeMaster,
|
||||
ControlParameter,
|
||||
MutualFundProductMaster,
|
||||
SglBankMaster,
|
||||
ScriptMarketDetail,
|
||||
DailyScriptRate,
|
||||
LienReason,
|
||||
InstrumentType
|
||||
} from '../investment-data';
|
||||
import { exportWorkbook, ExportSheetSpec } from '../investment-export';
|
||||
|
||||
type MasterTab =
|
||||
| 'party'
|
||||
| 'script'
|
||||
| 'subtype'
|
||||
| 'broker'
|
||||
| 'control'
|
||||
| 'dailyscript'
|
||||
| 'lien'
|
||||
| 'mfproduct'
|
||||
| 'sglbank'
|
||||
| 'marketdetail';
|
||||
|
||||
/**
|
||||
* Investment master-data screens (legacy "04-13" Entry submenu masters) — 9 simple dummy
|
||||
* lists, each with an inline Add-row form so the demo stays interactive without a backend.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-investment-masters',
|
||||
templateUrl: './investment-masters.component.html',
|
||||
styleUrls: [
|
||||
'../../cashier/cashier-shared.scss',
|
||||
'../investment-shared.scss'
|
||||
],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
DateDdmmyyyyPickerComponent
|
||||
]
|
||||
})
|
||||
export class InvestmentMastersComponent {
|
||||
activeTab: MasterTab = 'party';
|
||||
|
||||
scripts: ScriptMaster[] = [...SCRIPT_MASTER];
|
||||
brokers: BrokerMaster[] = [...BROKER_MASTER];
|
||||
parties: PartyMaster[] = [...PARTY_MASTER];
|
||||
subTypes: SubTypeMaster[] = [...SUBTYPE_MASTER];
|
||||
controlParams: ControlParameter[] = [...CONTROL_PARAMETERS];
|
||||
mfProducts: MutualFundProductMaster[] = [...MUTUAL_FUND_PRODUCTS];
|
||||
sglBanks: SglBankMaster[] = [...SGL_BANK_MASTER];
|
||||
marketDetails: ScriptMarketDetail[] = [...SCRIPT_MARKET_DETAIL];
|
||||
dailyScriptRates: DailyScriptRate[] = [...DAILY_SCRIPT_RATES];
|
||||
lienReasons: LienReason[] = [...LIEN_REASON_MASTER];
|
||||
|
||||
readonly instrumentTypes: InstrumentType[] = [
|
||||
'FD',
|
||||
'GOVT_SECURITY',
|
||||
'NON_SLR',
|
||||
'BOND',
|
||||
'MUTUAL_FUND',
|
||||
'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 = '';
|
||||
toastVisible = false;
|
||||
private toastTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
newParty: Partial<PartyMaster> = {};
|
||||
newScript: Partial<ScriptMaster> = {};
|
||||
newSubType: Partial<SubTypeMaster> = {};
|
||||
newBroker: Partial<BrokerMaster> = {};
|
||||
newControlParam: Partial<ControlParameter> = {};
|
||||
newLienReason: Partial<LienReason> = {};
|
||||
newMfProduct: Partial<MutualFundProductMaster> = {};
|
||||
newSglBank: Partial<SglBankMaster> = {};
|
||||
newDailyRate: Partial<DailyScriptRate> = {};
|
||||
|
||||
setTab(tab: MasterTab): void {
|
||||
this.activeTab = tab;
|
||||
}
|
||||
|
||||
addParty(): void {
|
||||
if (!this.newParty.partyName || !this.newParty.partyType) {
|
||||
this.toast('Enter Party Name and Type.');
|
||||
return;
|
||||
}
|
||||
this.parties = [
|
||||
{
|
||||
partyCode: `PTY-${String(this.parties.length + 1).padStart(3, '0')}`,
|
||||
panNo: '',
|
||||
...this.newParty
|
||||
} as PartyMaster,
|
||||
...this.parties
|
||||
];
|
||||
this.newParty = {};
|
||||
this.toast('Party/Bank master added.');
|
||||
}
|
||||
|
||||
addScript(): void {
|
||||
if (!this.newScript.scriptName || !this.newScript.instrumentType) {
|
||||
this.toast('Enter Script Name and Instrument Type.');
|
||||
return;
|
||||
}
|
||||
this.scripts = [
|
||||
{
|
||||
scriptCode: this.newScript.scriptCode || `SCR-${this.scripts.length + 1}`,
|
||||
isin: '',
|
||||
faceValuePerUnit: 100,
|
||||
...this.newScript
|
||||
} as ScriptMaster,
|
||||
...this.scripts
|
||||
];
|
||||
this.newScript = {};
|
||||
this.toast('Script master added.');
|
||||
}
|
||||
|
||||
addSubType(): void {
|
||||
if (!this.newSubType.subTypeName || !this.newSubType.instrumentType) {
|
||||
this.toast('Enter Sub Type Name and Instrument Type.');
|
||||
return;
|
||||
}
|
||||
this.subTypes = [
|
||||
{ subTypeCode: String(this.subTypes.length + 1), ...this.newSubType } as SubTypeMaster,
|
||||
...this.subTypes
|
||||
];
|
||||
this.newSubType = {};
|
||||
this.toast('Sub Type master added.');
|
||||
}
|
||||
|
||||
addBroker(): void {
|
||||
if (!this.newBroker.brokerName) {
|
||||
this.toast('Enter Broker Name.');
|
||||
return;
|
||||
}
|
||||
this.brokers = [
|
||||
{
|
||||
brokerCode: `BRK-${String(this.brokers.length + 1).padStart(3, '0')}`,
|
||||
contactNo: '',
|
||||
empanelledOn: '',
|
||||
status: 'ACTIVE',
|
||||
...this.newBroker
|
||||
} as BrokerMaster,
|
||||
...this.brokers
|
||||
];
|
||||
this.newBroker = {};
|
||||
this.toast('Broker master added.');
|
||||
}
|
||||
|
||||
addControlParam(): void {
|
||||
if (!this.newControlParam.paramName || !this.newControlParam.paramValue) {
|
||||
this.toast('Enter Parameter Name and Value.');
|
||||
return;
|
||||
}
|
||||
this.controlParams = [
|
||||
{ description: '', ...this.newControlParam } as ControlParameter,
|
||||
...this.controlParams
|
||||
];
|
||||
this.newControlParam = {};
|
||||
this.toast('Control parameter added.');
|
||||
}
|
||||
|
||||
addLienReason(): void {
|
||||
if (!this.newLienReason.reasonDescription) {
|
||||
this.toast('Enter Reason Description.');
|
||||
return;
|
||||
}
|
||||
this.lienReasons = [
|
||||
{ reasonCode: `LR-${String(this.lienReasons.length + 1).padStart(2, '0')}`, ...this.newLienReason } as LienReason,
|
||||
...this.lienReasons
|
||||
];
|
||||
this.newLienReason = {};
|
||||
this.toast('LIEN reason added.');
|
||||
}
|
||||
|
||||
addMfProduct(): void {
|
||||
if (!this.newMfProduct.productName || !this.newMfProduct.amcName) {
|
||||
this.toast('Enter Product Name and AMC Name.');
|
||||
return;
|
||||
}
|
||||
this.mfProducts = [
|
||||
{
|
||||
productCode: `MFP-${String(this.mfProducts.length + 1).padStart(2, '0')}`,
|
||||
category: '',
|
||||
riskLevel: 'MODERATE',
|
||||
...this.newMfProduct
|
||||
} as MutualFundProductMaster,
|
||||
...this.mfProducts
|
||||
];
|
||||
this.newMfProduct = {};
|
||||
this.toast('Mutual Fund product added.');
|
||||
}
|
||||
|
||||
addSglBank(): void {
|
||||
if (!this.newSglBank.bankName) {
|
||||
this.toast('Enter Bank Name.');
|
||||
return;
|
||||
}
|
||||
this.sglBanks = [
|
||||
{
|
||||
sglCode: `SGL-${String(this.sglBanks.length + 1).padStart(2, '0')}`,
|
||||
branch: '',
|
||||
...this.newSglBank
|
||||
} as SglBankMaster,
|
||||
...this.sglBanks
|
||||
];
|
||||
this.newSglBank = {};
|
||||
this.toast('SGL Bank master added.');
|
||||
}
|
||||
|
||||
addDailyRate(): void {
|
||||
if (!this.newDailyRate.scriptCode || !this.newDailyRate.rateDate || this.newDailyRate.closeRate == null) {
|
||||
this.toast('Enter Rate Date, Script Code and Close Rate.');
|
||||
return;
|
||||
}
|
||||
const script = this.scripts.find((s) => s.scriptCode === this.newDailyRate.scriptCode);
|
||||
this.dailyScriptRates = [
|
||||
{
|
||||
scriptName: script?.scriptName || '',
|
||||
openRate: this.newDailyRate.closeRate,
|
||||
highRate: this.newDailyRate.closeRate,
|
||||
lowRate: this.newDailyRate.closeRate,
|
||||
...this.newDailyRate
|
||||
} as DailyScriptRate,
|
||||
...this.dailyScriptRates
|
||||
];
|
||||
this.newDailyRate = {};
|
||||
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 {
|
||||
this.toastMsg = msg;
|
||||
this.toastVisible = true;
|
||||
if (this.toastTimer) {
|
||||
clearTimeout(this.toastTimer);
|
||||
}
|
||||
this.toastTimer = setTimeout(() => (this.toastVisible = false), 3200);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,701 @@
|
||||
<!--
|
||||
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">TREASURY · INVESTMENT · REPORTS</div>
|
||||
<h1 class="pg-title">Investment Reports</h1>
|
||||
<p class="pg-sub">
|
||||
Balance reports, ledgers, registers, maturity lists and the consolidated portfolio — all derived live from the
|
||||
same holdings/transactions used by the Entry screens.
|
||||
</p>
|
||||
</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>
|
||||
|
||||
<div class="cctr-tabs">
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeGroup === 'balance'"
|
||||
(click)="setGroup('balance', 'govtSecBalance')"
|
||||
>
|
||||
Balance Reports
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeGroup === 'ledger'"
|
||||
(click)="setGroup('ledger', 'fdLedger')"
|
||||
>
|
||||
Ledgers & Registers
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeGroup === 'maturity'"
|
||||
(click)="setGroup('maturity', 'maturedFd')"
|
||||
>
|
||||
Maturity & Lists
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeGroup === 'portfolio'"
|
||||
(click)="setGroup('portfolio', 'portfolio')"
|
||||
>
|
||||
Portfolio
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ===================== BALANCE REPORTS ===================== -->
|
||||
@if (activeGroup === 'balance') {
|
||||
<div class="cctr-tabs">
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeReport === 'govtSecBalance'"
|
||||
(click)="setReport('govtSecBalance')"
|
||||
>
|
||||
Govt Security Balance
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeReport === 'shareBalance'"
|
||||
(click)="setReport('shareBalance')"
|
||||
>
|
||||
Share Balance
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeReport === 'fdrBalance'"
|
||||
(click)="setReport('fdrBalance')"
|
||||
>
|
||||
FDR Balance
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeReport === 'bankInvList'"
|
||||
(click)="setReport('bankInvList')"
|
||||
>
|
||||
Bank Investment List
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (activeReport === 'govtSecBalance' || activeReport === 'shareBalance' || activeReport === 'fdrBalance') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">
|
||||
{{
|
||||
activeReport === 'govtSecBalance'
|
||||
? 'Govt Security Balance Report'
|
||||
: activeReport === 'shareBalance'
|
||||
? 'Share Balance Report'
|
||||
: 'FDR Balance Report'
|
||||
}}
|
||||
</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Cert No</th>
|
||||
<th>Script / Party</th>
|
||||
<th class="num">Units</th>
|
||||
<th class="num">Face Value</th>
|
||||
<th class="num">Market/Purchase Rate</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (
|
||||
h of activeReport === 'govtSecBalance'
|
||||
? govtSecHoldings
|
||||
: activeReport === 'shareBalance'
|
||||
? shareHoldings
|
||||
: fdHoldings;
|
||||
track h.certNo
|
||||
) {
|
||||
<tr>
|
||||
<td>
|
||||
<span class="pg-chip">{{ h.certNo }}</span>
|
||||
</td>
|
||||
<td>{{ h.scriptName }} — {{ h.party }}</td>
|
||||
<td class="num">{{ h.units | number }}</td>
|
||||
<td class="num">₹{{ h.faceValue | number: '1.2-2' }}</td>
|
||||
<td class="num">{{ marketPrice(h.scriptCode) || h.purchaseRate }}</td>
|
||||
<td>
|
||||
<span [class]="statusClass(h)">{{ h.status }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
@if (activeReport === 'bankInvList') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Bank Investment List</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Party / Bank</th>
|
||||
<th class="num">No. of Holdings</th>
|
||||
<th class="num">Total Face Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of bankInvestmentList; track r.party) {
|
||||
<tr>
|
||||
<td>{{ r.party }}</td>
|
||||
<td class="num">{{ r.count }}</td>
|
||||
<td class="num">₹{{ r.totalFaceValue | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- ===================== LEDGERS & REGISTERS ===================== -->
|
||||
@if (activeGroup === 'ledger') {
|
||||
<div class="cctr-tabs">
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeReport === 'fdLedger'"
|
||||
(click)="setReport('fdLedger')"
|
||||
>
|
||||
FD Ledger
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeReport === 'bondLedger'"
|
||||
(click)="setReport('bondLedger')"
|
||||
>
|
||||
Govt/Bond Ledger
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeReport === 'shareLedger'"
|
||||
(click)="setReport('shareLedger')"
|
||||
>
|
||||
Share Ledger
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeReport === 'intDueRegister'"
|
||||
(click)="setReport('intDueRegister')"
|
||||
>
|
||||
Interest Due Register
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeReport === 'slrRegister'"
|
||||
(click)="setReport('slrRegister')"
|
||||
>
|
||||
SLR Bond Register
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeReport === 'pendingReceived'"
|
||||
(click)="setReport('pendingReceived')"
|
||||
>
|
||||
Pending/Received Interest
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeReport === 'amortizeLedger'"
|
||||
(click)="setReport('amortizeLedger')"
|
||||
>
|
||||
Amortize Ledger
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeReport === 'dealSlip'"
|
||||
(click)="setReport('dealSlip')"
|
||||
>
|
||||
Deal Slip
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (
|
||||
activeReport === 'fdLedger' ||
|
||||
activeReport === 'bondLedger' ||
|
||||
activeReport === 'shareLedger' ||
|
||||
activeReport === 'amortizeLedger' ||
|
||||
activeReport === 'dealSlip'
|
||||
) {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">
|
||||
{{
|
||||
activeReport === 'fdLedger'
|
||||
? 'FD Ledger'
|
||||
: activeReport === 'bondLedger'
|
||||
? 'Govt/Bond Ledger'
|
||||
: activeReport === 'shareLedger'
|
||||
? 'Share Ledger'
|
||||
: activeReport === 'amortizeLedger'
|
||||
? 'Amortize Ledger'
|
||||
: 'Deal Slip Register'
|
||||
}}
|
||||
</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Voucher No</th>
|
||||
<th>Cert No</th>
|
||||
<th>Type</th>
|
||||
<th>Particulars</th>
|
||||
<th class="num">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (
|
||||
t of activeReport === 'fdLedger'
|
||||
? fdTxns
|
||||
: activeReport === 'bondLedger'
|
||||
? bondTxns
|
||||
: activeReport === 'shareLedger'
|
||||
? shareTxns
|
||||
: activeReport === 'amortizeLedger'
|
||||
? amortizeTxns
|
||||
: dealSlips;
|
||||
track t.voucherNo
|
||||
) {
|
||||
<tr>
|
||||
<td>{{ t.txnDate }}</td>
|
||||
<td>
|
||||
<span class="pg-chip">{{ t.voucherNo }}</span>
|
||||
</td>
|
||||
<td>{{ t.certNo }}</td>
|
||||
<td>{{ t.txnType }}</td>
|
||||
<td>{{ t.particulars }}</td>
|
||||
<td class="num">₹{{ t.amount | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="6" class="pg-empty">No entries.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
@if (activeReport === 'intDueRegister') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Interest Due Register (Non-Cumulative Holdings)</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Cert No</th>
|
||||
<th>Script / Party</th>
|
||||
<th>Frequency</th>
|
||||
<th class="num">Rate</th>
|
||||
<th class="num col-in">Due Amount (as on {{ asOnDate }})</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (h of interestDueRegister; track h.certNo) {
|
||||
<tr>
|
||||
<td>
|
||||
<span class="pg-chip">{{ h.certNo }}</span>
|
||||
</td>
|
||||
<td>{{ h.scriptName }} — {{ h.party }}</td>
|
||||
<td>{{ h.interestFreq }}</td>
|
||||
<td class="num">{{ h.interestRate }}%</td>
|
||||
<td class="num col-in">₹{{ h.dueAmount | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
@if (activeReport === 'slrRegister') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">SLR Bond Register</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Cert No</th>
|
||||
<th>Script</th>
|
||||
<th class="num">Face Value</th>
|
||||
<th class="num">Rate</th>
|
||||
<th>Maturity Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (h of slrBondRegister; track h.certNo) {
|
||||
<tr>
|
||||
<td>
|
||||
<span class="pg-chip">{{ h.certNo }}</span>
|
||||
</td>
|
||||
<td>{{ h.scriptName }}</td>
|
||||
<td class="num">₹{{ h.faceValue | number: '1.2-2' }}</td>
|
||||
<td class="num">{{ h.interestRate }}%</td>
|
||||
<td>{{ h.maturityDate }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
@if (activeReport === 'pendingReceived') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Received Interest / Dividend</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Cert No</th>
|
||||
<th>Particulars</th>
|
||||
<th class="num col-in">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (t of receivedInterestTxns; track t.voucherNo) {
|
||||
<tr>
|
||||
<td>{{ t.txnDate }}</td>
|
||||
<td>{{ t.certNo }}</td>
|
||||
<td>{{ t.particulars }}</td>
|
||||
<td class="num col-in">₹{{ t.amount | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Pending Interest (No Interest Posted Yet)</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Cert No</th>
|
||||
<th>Script / Party</th>
|
||||
<th class="num col-out">Accrued (Pending)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (h of pendingInterestHoldings; track h.certNo) {
|
||||
<tr>
|
||||
<td>
|
||||
<span class="pg-chip">{{ h.certNo }}</span>
|
||||
</td>
|
||||
<td>{{ h.scriptName }} — {{ h.party }}</td>
|
||||
<td class="num col-out">₹{{ accrued(h) | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- ===================== MATURITY & LISTS ===================== -->
|
||||
@if (activeGroup === 'maturity') {
|
||||
<div class="cctr-tabs">
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeReport === 'maturedFd'"
|
||||
(click)="setReport('maturedFd')"
|
||||
>
|
||||
List of Matured FD
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeReport === 'maturingList'"
|
||||
(click)="setReport('maturingList')"
|
||||
>
|
||||
Govt/FD Mature List
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeReport === 'maturityPattern'"
|
||||
(click)="setReport('maturityPattern')"
|
||||
>
|
||||
Maturity Pattern Register
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeReport === 'govtSecList'"
|
||||
(click)="setReport('govtSecList')"
|
||||
>
|
||||
Govt Security List
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeReport === 'fdSecList'"
|
||||
(click)="setReport('fdSecList')"
|
||||
>
|
||||
FD Security List
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeReport === 'shareSecList'"
|
||||
(click)="setReport('shareSecList')"
|
||||
>
|
||||
Share Security List
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeReport === 'mfList'"
|
||||
(click)="setReport('mfList')"
|
||||
>
|
||||
Mutual Fund List
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeReport === 'shiftingReport'"
|
||||
(click)="setReport('shiftingReport')"
|
||||
>
|
||||
Shifting Report
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (activeReport === 'maturedFd') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">List of Matured FD</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Cert No</th>
|
||||
<th>Party</th>
|
||||
<th class="num">Face Value</th>
|
||||
<th>Matured On</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (h of maturedFdList; track h.certNo) {
|
||||
<tr>
|
||||
<td>
|
||||
<span class="pg-chip">{{ h.certNo }}</span>
|
||||
</td>
|
||||
<td>{{ h.party }}</td>
|
||||
<td class="num">₹{{ h.faceValue | number: '1.2-2' }}</td>
|
||||
<td>{{ h.maturityDate }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="4" class="pg-empty">No matured FDs.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
@if (activeReport === 'maturingList') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Govt Security / FD Maturity List (sorted by days-to-maturity)</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Cert No</th>
|
||||
<th>Instrument</th>
|
||||
<th>Script / Party</th>
|
||||
<th>Maturity Date</th>
|
||||
<th class="num">Days Left</th>
|
||||
<th class="num col-net">Maturity Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (h of maturingList; track h.certNo) {
|
||||
<tr>
|
||||
<td>
|
||||
<span class="pg-chip">{{ h.certNo }}</span>
|
||||
</td>
|
||||
<td>{{ instrumentLabel(h.instrumentType) }}</td>
|
||||
<td>{{ h.scriptName }} — {{ h.party }}</td>
|
||||
<td>{{ h.maturityDate }}</td>
|
||||
<td class="num">{{ daysLeft(h) }}</td>
|
||||
<td class="num col-net">₹{{ maturity(h) | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
@if (activeReport === 'maturityPattern') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Maturity Pattern Register</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Bucket</th>
|
||||
<th class="num">Holdings</th>
|
||||
<th class="num">Face Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (b of maturityBuckets; track b.label) {
|
||||
<tr>
|
||||
<td>{{ b.label }}</td>
|
||||
<td class="num">{{ b.count }}</td>
|
||||
<td class="num">₹{{ b.faceValue | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
@if (
|
||||
activeReport === 'govtSecList' ||
|
||||
activeReport === 'fdSecList' ||
|
||||
activeReport === 'shareSecList' ||
|
||||
activeReport === 'mfList'
|
||||
) {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">
|
||||
{{
|
||||
activeReport === 'govtSecList'
|
||||
? 'Govt Security List'
|
||||
: activeReport === 'fdSecList'
|
||||
? 'FD Security List'
|
||||
: activeReport === 'shareSecList'
|
||||
? 'Share Security List'
|
||||
: 'Mutual Fund List'
|
||||
}}
|
||||
</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Cert No</th>
|
||||
<th>Script</th>
|
||||
<th>Party</th>
|
||||
<th class="num">Face Value</th>
|
||||
<th class="num">Rate</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (
|
||||
h of activeReport === 'govtSecList'
|
||||
? govtSecList
|
||||
: activeReport === 'fdSecList'
|
||||
? fdSecList
|
||||
: activeReport === 'shareSecList'
|
||||
? shareSecList
|
||||
: mutualFundList;
|
||||
track h.certNo
|
||||
) {
|
||||
<tr>
|
||||
<td>
|
||||
<span class="pg-chip">{{ h.certNo }}</span>
|
||||
</td>
|
||||
<td>{{ h.scriptName }}</td>
|
||||
<td>{{ h.party }}</td>
|
||||
<td class="num">₹{{ h.faceValue | number: '1.2-2' }}</td>
|
||||
<td class="num">{{ h.interestRate }}%</td>
|
||||
<td>
|
||||
<span [class]="statusClass(h)">{{ h.status }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
@if (activeReport === 'shiftingReport') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">
|
||||
Shifting Report — SLR Securities with Market Deviation ≥ 1% (AFS/HTM review candidates)
|
||||
</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Cert No</th>
|
||||
<th>Script</th>
|
||||
<th class="num">Purchase Rate</th>
|
||||
<th class="num">Market Price</th>
|
||||
<th class="num">Deviation %</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (h of shiftingCandidates; track h.certNo) {
|
||||
<tr>
|
||||
<td>
|
||||
<span class="pg-chip">{{ h.certNo }}</span>
|
||||
</td>
|
||||
<td>{{ h.scriptName }}</td>
|
||||
<td class="num">{{ h.purchaseRate }}</td>
|
||||
<td class="num">{{ h.marketPrice }}</td>
|
||||
<td class="num" [class.col-in]="h.deviationPct > 0" [class.col-out]="h.deviationPct < 0">
|
||||
{{ h.deviationPct }}%
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="5" class="pg-empty">No securities crossing the 1% deviation threshold today.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- ===================== PORTFOLIO ===================== -->
|
||||
@if (activeGroup === 'portfolio') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Investment Portfolio — Consolidated Summary</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Instrument Type</th>
|
||||
<th class="num">Holdings</th>
|
||||
<th class="num">Face Value</th>
|
||||
<th class="num col-in">Accrued Interest</th>
|
||||
<th class="num col-net">Maturity Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of portfolioSummary; track r.label) {
|
||||
<tr>
|
||||
<td>{{ r.label }}</td>
|
||||
<td class="num">{{ r.count }}</td>
|
||||
<td class="num">₹{{ r.faceValue | number: '1.2-2' }}</td>
|
||||
<td class="num col-in">₹{{ r.accrued | number: '1.2-2' }}</td>
|
||||
<td class="num col-net">₹{{ r.maturityValue | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="cctr-total-row">
|
||||
<td>Total</td>
|
||||
<td class="num"></td>
|
||||
<td class="num">₹{{ portfolioTotalFaceValue | number: '1.2-2' }}</td>
|
||||
<td class="num">₹{{ portfolioTotalAccrued | number: '1.2-2' }}</td>
|
||||
<td class="num">₹{{ portfolioTotalMaturity | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="inv-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
|
||||
@@ -0,0 +1,613 @@
|
||||
/**
|
||||
* 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 } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ALL_HOLDINGS,
|
||||
ALL_TXNS,
|
||||
FD_HOLDINGS,
|
||||
GOVT_SECURITY_HOLDINGS,
|
||||
NON_SLR_HOLDINGS,
|
||||
BOND_HOLDINGS,
|
||||
MUTUAL_FUND_HOLDINGS,
|
||||
SHARE_HOLDINGS,
|
||||
SCRIPT_MARKET_DETAIL,
|
||||
InstrumentType,
|
||||
InvestmentHolding,
|
||||
InvestmentTxn,
|
||||
INSTRUMENT_LABELS,
|
||||
accruedInterest,
|
||||
maturityAmount,
|
||||
daysToMaturity,
|
||||
todayDDMMYYYY
|
||||
} from '../investment-data';
|
||||
import { exportWorkbook, ExportSheetSpec } from '../investment-export';
|
||||
|
||||
type ReportGroup = 'balance' | 'ledger' | 'maturity' | 'portfolio';
|
||||
|
||||
/**
|
||||
* ~20 legacy Investment reports, grouped into 4 tabs. Every figure here is derived from the
|
||||
* same ALL_HOLDINGS/ALL_TXNS arrays used by the Entry screens and Dashboard — so a report can
|
||||
* never drift out of sync with what a Purchase/Sell/Interest entry actually recorded.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-investment-reports',
|
||||
templateUrl: './investment-reports.component.html',
|
||||
styleUrls: [
|
||||
'../../cashier/cashier-shared.scss',
|
||||
'../investment-shared.scss'
|
||||
],
|
||||
standalone: true,
|
||||
imports: [CommonModule]
|
||||
})
|
||||
export class InvestmentReportsComponent {
|
||||
activeGroup: ReportGroup = 'balance';
|
||||
activeReport = 'govtSecBalance';
|
||||
asOnDate = todayDDMMYYYY();
|
||||
|
||||
generating = false;
|
||||
toastMsg = '';
|
||||
toastVisible = false;
|
||||
private toastTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
setGroup(group: ReportGroup, defaultReport: string): void {
|
||||
this.activeGroup = group;
|
||||
this.activeReport = defaultReport;
|
||||
}
|
||||
|
||||
setReport(report: string): void {
|
||||
this.activeReport = report;
|
||||
}
|
||||
|
||||
instrumentLabel(type: InstrumentType): string {
|
||||
return INSTRUMENT_LABELS[type];
|
||||
}
|
||||
|
||||
accrued(h: InvestmentHolding): number {
|
||||
return accruedInterest(h, this.asOnDate);
|
||||
}
|
||||
|
||||
maturity(h: InvestmentHolding): number {
|
||||
return maturityAmount(h);
|
||||
}
|
||||
|
||||
daysLeft(h: InvestmentHolding): number {
|
||||
return daysToMaturity(h, this.asOnDate);
|
||||
}
|
||||
|
||||
statusClass(h: InvestmentHolding): string {
|
||||
return `inv-badge inv-badge--${h.status.toLowerCase()}`;
|
||||
}
|
||||
|
||||
marketPrice(scriptCode: string): number | null {
|
||||
return SCRIPT_MARKET_DETAIL.find((m) => m.scriptCode === scriptCode)?.marketPrice ?? null;
|
||||
}
|
||||
|
||||
// -------------------- Balance Reports --------------------
|
||||
get govtSecHoldings(): InvestmentHolding[] {
|
||||
return GOVT_SECURITY_HOLDINGS;
|
||||
}
|
||||
get shareHoldings(): InvestmentHolding[] {
|
||||
return SHARE_HOLDINGS;
|
||||
}
|
||||
get fdHoldings(): InvestmentHolding[] {
|
||||
return FD_HOLDINGS;
|
||||
}
|
||||
get bankInvestmentList(): Array<{ party: string; count: number; totalFaceValue: number }> {
|
||||
const map = new Map<string, { party: string; count: number; totalFaceValue: number }>();
|
||||
for (const h of ALL_HOLDINGS) {
|
||||
const row = map.get(h.party) || { party: h.party, count: 0, totalFaceValue: 0 };
|
||||
row.count += 1;
|
||||
row.totalFaceValue += h.faceValue;
|
||||
map.set(h.party, row);
|
||||
}
|
||||
return [...map.values()].sort((a, b) => b.totalFaceValue - a.totalFaceValue);
|
||||
}
|
||||
|
||||
// -------------------- Ledgers & Registers --------------------
|
||||
get fdTxns(): InvestmentTxn[] {
|
||||
return ALL_TXNS.filter((t) => t.instrumentType === 'FD');
|
||||
}
|
||||
get bondTxns(): InvestmentTxn[] {
|
||||
return ALL_TXNS.filter((t) => ([
|
||||
'GOVT_SECURITY',
|
||||
'NON_SLR',
|
||||
'BOND'
|
||||
] as InstrumentType[]).includes(t.instrumentType));
|
||||
}
|
||||
get shareTxns(): InvestmentTxn[] {
|
||||
return ALL_TXNS.filter((t) => t.instrumentType === 'SHARE');
|
||||
}
|
||||
get amortizeTxns(): InvestmentTxn[] {
|
||||
return ALL_TXNS.filter((t) => t.txnType === 'AMORTIZE');
|
||||
}
|
||||
get dealSlips(): InvestmentTxn[] {
|
||||
return ALL_TXNS.filter((t) => t.txnType === 'PURCHASE');
|
||||
}
|
||||
get slrBondRegister(): InvestmentHolding[] {
|
||||
return GOVT_SECURITY_HOLDINGS.filter((h) => h.subType === 'SLR');
|
||||
}
|
||||
get interestDueRegister(): Array<InvestmentHolding & { dueAmount: number }> {
|
||||
return ALL_HOLDINGS.filter((h) => h.status === 'ACTIVE' && h.interestFreq !== 'CUMULATIVE').map((h) => ({
|
||||
...h,
|
||||
dueAmount: this.accrued(h)
|
||||
}));
|
||||
}
|
||||
get receivedInterestTxns(): InvestmentTxn[] {
|
||||
return ALL_TXNS.filter((t) => t.txnType === 'INTEREST' || t.txnType === 'DIVIDEND');
|
||||
}
|
||||
get pendingInterestHoldings(): InvestmentHolding[] {
|
||||
const receivedCerts = new Set(this.receivedInterestTxns.map((t) => t.certNo));
|
||||
return ALL_HOLDINGS.filter(
|
||||
(h) => h.status === 'ACTIVE' && h.interestFreq !== 'CUMULATIVE' && !receivedCerts.has(h.certNo)
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------- Maturity & Lists --------------------
|
||||
get maturedFdList(): InvestmentHolding[] {
|
||||
return FD_HOLDINGS.filter((h) => h.status === 'MATURED');
|
||||
}
|
||||
get maturingList(): InvestmentHolding[] {
|
||||
return [
|
||||
...FD_HOLDINGS,
|
||||
...GOVT_SECURITY_HOLDINGS
|
||||
]
|
||||
.filter((h) => h.status === 'ACTIVE')
|
||||
.sort((a, b) => this.daysLeft(a) - this.daysLeft(b));
|
||||
}
|
||||
get maturityBuckets(): Array<{ label: string; count: number; faceValue: number }> {
|
||||
const buckets = [
|
||||
{ label: '0-1 Year', min: -99999, max: 365, count: 0, faceValue: 0 },
|
||||
{ label: '1-3 Years', min: 366, max: 1095, count: 0, faceValue: 0 },
|
||||
{ label: '3-5 Years', min: 1096, max: 1825, count: 0, faceValue: 0 },
|
||||
{ label: 'Above 5 Years', min: 1826, max: 999999, count: 0, faceValue: 0 }
|
||||
];
|
||||
for (const h of ALL_HOLDINGS.filter((x) => x.status === 'ACTIVE')) {
|
||||
const days = this.daysLeft(h);
|
||||
const bucket = buckets.find((b) => days >= b.min && days <= b.max);
|
||||
if (bucket) {
|
||||
bucket.count += 1;
|
||||
bucket.faceValue += h.faceValue;
|
||||
}
|
||||
}
|
||||
return buckets;
|
||||
}
|
||||
get govtSecList(): InvestmentHolding[] {
|
||||
return GOVT_SECURITY_HOLDINGS;
|
||||
}
|
||||
get fdSecList(): InvestmentHolding[] {
|
||||
return FD_HOLDINGS;
|
||||
}
|
||||
get shareSecList(): InvestmentHolding[] {
|
||||
return SHARE_HOLDINGS;
|
||||
}
|
||||
get mutualFundList(): InvestmentHolding[] {
|
||||
return MUTUAL_FUND_HOLDINGS;
|
||||
}
|
||||
get nonSlrList(): InvestmentHolding[] {
|
||||
return NON_SLR_HOLDINGS;
|
||||
}
|
||||
get bondList(): InvestmentHolding[] {
|
||||
return BOND_HOLDINGS;
|
||||
}
|
||||
/** Shifting Report — SLR securities whose market value has moved away from book value by
|
||||
* more than 1%, a proxy for "category shift" candidates (AFS/HTM reclassification review). */
|
||||
get shiftingCandidates(): Array<InvestmentHolding & { marketPrice: number; deviationPct: number }> {
|
||||
return GOVT_SECURITY_HOLDINGS.map((h) => {
|
||||
const mp = this.marketPrice(h.scriptCode);
|
||||
if (mp === null) {
|
||||
return null;
|
||||
}
|
||||
const deviationPct = +(((mp - h.purchaseRate) / h.purchaseRate) * 100).toFixed(2);
|
||||
return { ...h, marketPrice: mp, deviationPct };
|
||||
})
|
||||
.filter(
|
||||
(x): x is InvestmentHolding & { marketPrice: number; deviationPct: number } =>
|
||||
x !== null && Math.abs(x.deviationPct) >= 1
|
||||
)
|
||||
.sort((a, b) => Math.abs(b.deviationPct) - Math.abs(a.deviationPct));
|
||||
}
|
||||
|
||||
// -------------------- Portfolio --------------------
|
||||
private readonly portfolioTypes: InstrumentType[] = [
|
||||
'FD',
|
||||
'GOVT_SECURITY',
|
||||
'NON_SLR',
|
||||
'BOND',
|
||||
'MUTUAL_FUND',
|
||||
'SHARE'
|
||||
];
|
||||
get portfolioSummary(): Array<{
|
||||
label: string;
|
||||
count: number;
|
||||
faceValue: number;
|
||||
accrued: number;
|
||||
maturityValue: number;
|
||||
}> {
|
||||
return this.portfolioTypes.map((type) => {
|
||||
const holdings = ALL_HOLDINGS.filter((h) => h.instrumentType === type);
|
||||
return {
|
||||
label: INSTRUMENT_LABELS[type],
|
||||
count: holdings.length,
|
||||
faceValue: holdings.reduce((s, h) => s + h.faceValue, 0),
|
||||
accrued: holdings.reduce((s, h) => s + this.accrued(h), 0),
|
||||
maturityValue: holdings.reduce((s, h) => s + this.maturity(h), 0)
|
||||
};
|
||||
});
|
||||
}
|
||||
get portfolioTotalFaceValue(): number {
|
||||
return this.portfolioSummary.reduce((s, r) => s + r.faceValue, 0);
|
||||
}
|
||||
get portfolioTotalAccrued(): number {
|
||||
return this.portfolioSummary.reduce((s, r) => s + r.accrued, 0);
|
||||
}
|
||||
get portfolioTotalMaturity(): number {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* 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 { InvestmentDashboardComponent } from './investment-dashboard/investment-dashboard.component';
|
||||
import { FdInvestmentComponent } from './fd-investment/fd-investment.component';
|
||||
import { BondInvestmentComponent } from './bond-investment/bond-investment.component';
|
||||
import { ShareInvestmentComponent } from './share-investment/share-investment.component';
|
||||
import { InvestmentMastersComponent } from './investment-masters/investment-masters.component';
|
||||
import { InvestmentReportsComponent } from './investment-reports/investment-reports.component';
|
||||
import { InvestmentInterestComponent } from './investment-interest/investment-interest.component';
|
||||
|
||||
const routes: Routes = [
|
||||
Route.withShell([
|
||||
{
|
||||
path: 'investment',
|
||||
data: { title: 'Investment', breadcrumb: 'Investment' },
|
||||
children: [
|
||||
{
|
||||
path: 'dashboard',
|
||||
component: InvestmentDashboardComponent,
|
||||
data: { title: 'Investment Dashboard', breadcrumb: 'Investment Dashboard' }
|
||||
},
|
||||
{
|
||||
path: 'fd',
|
||||
component: FdInvestmentComponent,
|
||||
data: { title: 'FD Investment', breadcrumb: 'FD Investment' }
|
||||
},
|
||||
{
|
||||
path: 'bonds',
|
||||
component: BondInvestmentComponent,
|
||||
data: { title: 'Govt Security / Bond Investment', breadcrumb: 'Govt Security / Bond Investment' }
|
||||
},
|
||||
{
|
||||
path: 'share',
|
||||
component: ShareInvestmentComponent,
|
||||
data: { title: 'Share Investment', breadcrumb: 'Share Investment' }
|
||||
},
|
||||
{
|
||||
path: 'masters',
|
||||
component: InvestmentMastersComponent,
|
||||
data: { title: 'Investment Masters', breadcrumb: 'Investment Masters' }
|
||||
},
|
||||
{
|
||||
path: 'interest',
|
||||
component: InvestmentInterestComponent,
|
||||
data: { title: 'Investment Interest', breadcrumb: 'Investment Interest' }
|
||||
},
|
||||
{
|
||||
path: 'reports',
|
||||
component: InvestmentReportsComponent,
|
||||
data: { title: 'Investment Reports', breadcrumb: 'Investment Reports' }
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class InvestmentRoutingModule {}
|
||||
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
// Extra styles layered on top of ../cashier/cashier-shared.scss (pg-*, cctr-*) for the
|
||||
// Investment module — instrument nav cards, status badges, KPI tiles, entry-form layout.
|
||||
|
||||
.inv-kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
|
||||
@media (width <= 1100px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.inv-kpi {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
&__label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
&__sub {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
.inv-nav-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.inv-nav-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
box-shadow 0.15s;
|
||||
|
||||
&:hover {
|
||||
border-color: #0f4f3c;
|
||||
box-shadow: 0 2px 10px rgb(15 79 60 / 10%);
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
&__count {
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #0f4f3c;
|
||||
}
|
||||
}
|
||||
|
||||
.inv-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;
|
||||
|
||||
&--active {
|
||||
background: #f0fdf4;
|
||||
color: #15803d;
|
||||
border: 1px solid #bbf7d0;
|
||||
}
|
||||
|
||||
&--matured {
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
border: 1px solid #bfdbfe;
|
||||
}
|
||||
|
||||
&--sold {
|
||||
background: #fefce8;
|
||||
color: #a16207;
|
||||
border: 1px solid #fde68a;
|
||||
}
|
||||
|
||||
&--written_off {
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 14px 20px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.inv-form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
|
||||
input,
|
||||
select {
|
||||
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;
|
||||
}
|
||||
|
||||
// Long "code — name (branch)" style option text must never run under the native dropdown
|
||||
// arrow — truncate it with an ellipsis instead of letting the browser clip it mid-word.
|
||||
select {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// Computed / lookup-filled fields are rendered read-only and greyed out so they read as
|
||||
// derived values rather than something the user is expected to type into.
|
||||
/* stylelint-disable-next-line selector-no-qualifying-type -- [readonly] only applies to inputs */
|
||||
input[readonly] {
|
||||
background: #f8fafc;
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
.inv-form-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding: 0 18px 18px;
|
||||
}
|
||||
|
||||
.inv-maturity-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px dotted #eef2f7;
|
||||
font-size: 13px;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.inv-maturity-days {
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
&--soon {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
&--ok {
|
||||
color: #16a34a;
|
||||
}
|
||||
}
|
||||
|
||||
.inv-toast {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(20px);
|
||||
background: #0f172a;
|
||||
color: #fff;
|
||||
padding: 12px 20px;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 8px 24px rgb(0 0 0 / 25%);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
transform 0.2s;
|
||||
z-index: 2000;
|
||||
|
||||
&.show {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* 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 { InvestmentRoutingModule } from './investment-routing.module';
|
||||
import { InvestmentDashboardComponent } from './investment-dashboard/investment-dashboard.component';
|
||||
import { FdInvestmentComponent } from './fd-investment/fd-investment.component';
|
||||
import { BondInvestmentComponent } from './bond-investment/bond-investment.component';
|
||||
import { ShareInvestmentComponent } from './share-investment/share-investment.component';
|
||||
import { InvestmentMastersComponent } from './investment-masters/investment-masters.component';
|
||||
import { InvestmentReportsComponent } from './investment-reports/investment-reports.component';
|
||||
import { InvestmentInterestComponent } from './investment-interest/investment-interest.component';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
SharedModule,
|
||||
InvestmentRoutingModule,
|
||||
InvestmentDashboardComponent,
|
||||
FdInvestmentComponent,
|
||||
BondInvestmentComponent,
|
||||
ShareInvestmentComponent,
|
||||
InvestmentMastersComponent,
|
||||
InvestmentReportsComponent,
|
||||
InvestmentInterestComponent
|
||||
]
|
||||
})
|
||||
export class InvestmentModule {}
|
||||
@@ -0,0 +1,283 @@
|
||||
<!--
|
||||
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">TREASURY · INVESTMENT · SHARE</div>
|
||||
<h1 class="pg-title">Share Entry</h1>
|
||||
<p class="pg-sub">
|
||||
Equity and cooperative-society share holdings — Purchase, Dividend, Sell and Write-off entries.
|
||||
</p>
|
||||
</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>
|
||||
|
||||
<div class="cctr-tabs">
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeTab === 'holdings'"
|
||||
(click)="setTab('holdings')"
|
||||
>
|
||||
Holdings
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeTab === 'purchase'"
|
||||
(click)="setTab('purchase')"
|
||||
>
|
||||
01 Share Purchase Entry
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeTab === 'dividend'"
|
||||
(click)="setTab('dividend')"
|
||||
>
|
||||
02 Share Interest / Dividend Entry
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeTab === 'sell'"
|
||||
(click)="setTab('sell')"
|
||||
>
|
||||
03 Share Sell Entry
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="activeTab === 'writeoff'"
|
||||
(click)="setTab('writeoff')"
|
||||
>
|
||||
04 Share Write Off Entry
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- HOLDINGS -->
|
||||
@if (activeTab === 'holdings') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Share Holdings Register</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Cert No</th>
|
||||
<th>Script</th>
|
||||
<th>Party</th>
|
||||
<th class="num">Units</th>
|
||||
<th class="num">Rate</th>
|
||||
<th class="num">Face Value</th>
|
||||
<th>Purchase Date</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (h of holdings; track h.certNo) {
|
||||
<tr>
|
||||
<td>
|
||||
<span class="pg-chip">{{ h.certNo }}</span>
|
||||
</td>
|
||||
<td>{{ h.scriptName }}</td>
|
||||
<td>{{ h.party }}</td>
|
||||
<td class="num">{{ h.units | number }}</td>
|
||||
<td class="num">₹{{ h.purchaseRate | number: '1.2-2' }}</td>
|
||||
<td class="num">₹{{ h.faceValue | number: '1.2-2' }}</td>
|
||||
<td>{{ h.purchaseDate }}</td>
|
||||
<td>
|
||||
<span [class]="statusClass(h)">{{ h.status }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- PURCHASE ENTRY -->
|
||||
@if (activeTab === 'purchase') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Investment - Share Purchase Entry</div>
|
||||
<div class="inv-form-grid">
|
||||
<label class="inv-form-field"
|
||||
><span>Cert No</span><input type="text" [(ngModel)]="purchaseForm.certNo" readonly
|
||||
/></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Script Code</span><input type="text" [(ngModel)]="purchaseForm.scriptCode"
|
||||
/></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Script/Company Name *</span><input type="text" [(ngModel)]="purchaseForm.scriptName"
|
||||
/></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Party / Exchange</span><input type="text" [(ngModel)]="purchaseForm.party"
|
||||
/></label>
|
||||
<label class="inv-form-field">
|
||||
<span>Sub Type</span>
|
||||
<select [(ngModel)]="purchaseForm.subType">
|
||||
<option value="EQUITY">EQUITY</option>
|
||||
<option value="CO-OP SHARE">CO-OP SHARE</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="inv-form-field"
|
||||
><span>No. of Units *</span><input type="number" [(ngModel)]="purchaseForm.units" min="0"
|
||||
/></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Purchase Rate *</span><input type="number" [(ngModel)]="purchaseForm.purchaseRate" min="0" step="0.01"
|
||||
/></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Purchase Date *</span
|
||||
><mifosx-date-ddmmyyyy [(ngModel)]="purchaseForm.purchaseDate"></mifosx-date-ddmmyyyy
|
||||
></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Broker Code</span><input type="text" [(ngModel)]="purchaseForm.brokerCode"
|
||||
/></label>
|
||||
<label class="inv-form-field"
|
||||
><span>CR To A/c</span><input type="text" [(ngModel)]="purchaseForm.crToAc"
|
||||
/></label>
|
||||
</div>
|
||||
<div class="inv-form-actions">
|
||||
<button type="button" class="pg-btn pg-btn--ghost" (click)="cancelPurchase()">Cancel</button>
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="savePurchase()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- DIVIDEND ENTRY -->
|
||||
@if (activeTab === 'dividend') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Investment - Share Interest / Dividend Entry</div>
|
||||
<div class="inv-form-grid">
|
||||
<label class="inv-form-field">
|
||||
<span>Cert No *</span>
|
||||
<select [(ngModel)]="dividendCertNo">
|
||||
<option value="">-- Select --</option>
|
||||
@for (h of activeHoldings; track h.certNo) {
|
||||
<option [value]="h.certNo">{{ h.certNo }} — {{ h.scriptName }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
@if (dividendHolding; as h) {
|
||||
<label class="inv-form-field"><span>Units Held</span><input type="text" [value]="h.units" readonly /></label>
|
||||
}
|
||||
<label class="inv-form-field"
|
||||
><span>Dividend per Share *</span><input type="number" [(ngModel)]="dividendPerShare" min="0" step="0.01"
|
||||
/></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Total Dividend (computed)</span
|
||||
><input type="text" [value]="'₹' + (dividendAmount | number: '1.2-2')" readonly
|
||||
/></label>
|
||||
</div>
|
||||
<div class="inv-form-actions">
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="saveDividend()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- SELL ENTRY -->
|
||||
@if (activeTab === 'sell') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Investment - Share Sell Entry</div>
|
||||
<div class="inv-form-grid">
|
||||
<label class="inv-form-field">
|
||||
<span>Cert No *</span>
|
||||
<select [(ngModel)]="sellCertNo">
|
||||
<option value="">-- Select --</option>
|
||||
@for (h of activeHoldings; track h.certNo) {
|
||||
<option [value]="h.certNo">{{ h.certNo }} — {{ h.scriptName }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
@if (sellHolding; as h) {
|
||||
<label class="inv-form-field"><span>Units Held</span><input type="text" [value]="h.units" readonly /></label>
|
||||
}
|
||||
<label class="inv-form-field"
|
||||
><span>Units to Sell *</span><input type="number" [(ngModel)]="sellUnits" min="0"
|
||||
/></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Sell Rate *</span><input type="number" [(ngModel)]="sellRate" min="0" step="0.01"
|
||||
/></label>
|
||||
<label class="inv-form-field"
|
||||
><span>Consideration (computed)</span
|
||||
><input type="text" [value]="'₹' + (sellConsideration | number: '1.2-2')" readonly
|
||||
/></label>
|
||||
</div>
|
||||
<div class="inv-form-actions">
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="saveSell()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- WRITE OFF ENTRY -->
|
||||
@if (activeTab === 'writeoff') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Investment - Share Write Off Entry</div>
|
||||
<div class="inv-form-grid">
|
||||
<label class="inv-form-field">
|
||||
<span>Cert No *</span>
|
||||
<select [(ngModel)]="writeoffCertNo">
|
||||
<option value="">-- Select --</option>
|
||||
@for (h of activeHoldings; track h.certNo) {
|
||||
<option [value]="h.certNo">{{ h.certNo }} — {{ h.scriptName }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
@if (writeoffHolding; as h) {
|
||||
<label class="inv-form-field"
|
||||
><span>Face Value to be Written Off</span
|
||||
><input type="text" [value]="'₹' + (h.faceValue | number: '1.2-2')" readonly
|
||||
/></label>
|
||||
}
|
||||
<label class="inv-form-field"><span>Reason *</span><input type="text" [(ngModel)]="writeoffReason" /></label>
|
||||
</div>
|
||||
<div class="inv-form-actions">
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="saveWriteoff()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (activeTab !== 'holdings') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Share Transaction Log</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Voucher No</th>
|
||||
<th>Cert No</th>
|
||||
<th>Type</th>
|
||||
<th>Particulars</th>
|
||||
<th class="num">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (t of txns; track t.voucherNo) {
|
||||
<tr>
|
||||
<td>{{ t.txnDate }}</td>
|
||||
<td>
|
||||
<span class="pg-chip">{{ t.voucherNo }}</span>
|
||||
</td>
|
||||
<td>{{ t.certNo }}</td>
|
||||
<td>{{ t.txnType }}</td>
|
||||
<td>{{ t.particulars }}</td>
|
||||
<td class="num">₹{{ t.amount | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="inv-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
|
||||
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* 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 } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
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 { exportWorkbook } from '../investment-export';
|
||||
|
||||
type ShareTab = 'holdings' | 'purchase' | 'dividend' | 'sell' | 'writeoff';
|
||||
|
||||
interface SharePurchaseForm {
|
||||
certNo: string;
|
||||
scriptCode: string;
|
||||
scriptName: string;
|
||||
party: string;
|
||||
subType: string;
|
||||
units: number | null;
|
||||
purchaseRate: number | null;
|
||||
purchaseDate: string;
|
||||
brokerCode: string;
|
||||
crToAc: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Share Entry treasury screen — Holdings register + Purchase / Dividend / Sell / Write-off
|
||||
* entry tabs (legacy "02 Share Entry" menu). Dummy in-memory data; Save appends locally.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-share-investment',
|
||||
templateUrl: './share-investment.component.html',
|
||||
styleUrls: [
|
||||
'../../cashier/cashier-shared.scss',
|
||||
'../investment-shared.scss'
|
||||
],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
DateDdmmyyyyPickerComponent
|
||||
]
|
||||
})
|
||||
export class ShareInvestmentComponent {
|
||||
activeTab: ShareTab = 'holdings';
|
||||
asOnDate = todayDDMMYYYY();
|
||||
|
||||
holdings: InvestmentHolding[] = [...SHARE_HOLDINGS];
|
||||
txns: InvestmentTxn[] = ALL_TXNS.filter((t) => t.instrumentType === 'SHARE');
|
||||
|
||||
generating = false;
|
||||
toastMsg = '';
|
||||
toastVisible = false;
|
||||
private toastTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
purchaseForm: SharePurchaseForm = this.blankPurchaseForm();
|
||||
dividendCertNo = '';
|
||||
dividendPerShare: number | null = null;
|
||||
sellCertNo = '';
|
||||
sellUnits: number | null = null;
|
||||
sellRate: number | null = null;
|
||||
writeoffCertNo = '';
|
||||
writeoffReason = '';
|
||||
|
||||
setTab(tab: ShareTab): void {
|
||||
this.activeTab = tab;
|
||||
}
|
||||
|
||||
get activeHoldings(): InvestmentHolding[] {
|
||||
return this.holdings.filter((h) => h.status === 'ACTIVE');
|
||||
}
|
||||
|
||||
private blankPurchaseForm(): SharePurchaseForm {
|
||||
return {
|
||||
certNo: `SH-${6000 + this.holdings.length + 1}`,
|
||||
scriptCode: '',
|
||||
scriptName: '',
|
||||
party: 'NSE/BSE',
|
||||
subType: 'EQUITY',
|
||||
units: null,
|
||||
purchaseRate: null,
|
||||
purchaseDate: this.asOnDate,
|
||||
brokerCode: '',
|
||||
crToAc: '310070'
|
||||
};
|
||||
}
|
||||
|
||||
savePurchase(): void {
|
||||
const f = this.purchaseForm;
|
||||
if (!f.scriptName || !f.units || !f.purchaseRate) {
|
||||
this.toast('Please fill Script Name, No. of Units and Purchase Rate.');
|
||||
return;
|
||||
}
|
||||
const faceValue = f.units * f.purchaseRate;
|
||||
const holding: InvestmentHolding = {
|
||||
certNo: f.certNo,
|
||||
instrumentType: 'SHARE',
|
||||
scriptCode: f.scriptCode || f.scriptName,
|
||||
scriptName: f.scriptName,
|
||||
party: f.party,
|
||||
subType: f.subType,
|
||||
faceValue,
|
||||
purchaseRate: f.purchaseRate,
|
||||
units: f.units,
|
||||
purchaseDate: f.purchaseDate,
|
||||
maturityDate: f.purchaseDate,
|
||||
interestRate: 0,
|
||||
interestFreq: 'YEARLY',
|
||||
glCode: '310060',
|
||||
glName: 'INVESTMENT IN SHARES',
|
||||
crToAc: f.crToAc,
|
||||
brokerCode: f.brokerCode,
|
||||
status: 'ACTIVE'
|
||||
};
|
||||
this.holdings = [
|
||||
holding,
|
||||
...this.holdings
|
||||
];
|
||||
this.txns = [
|
||||
{
|
||||
certNo: holding.certNo,
|
||||
instrumentType: 'SHARE',
|
||||
txnType: 'PURCHASE',
|
||||
voucherNo: `INV/PUR/${holding.certNo}`,
|
||||
txnDate: f.purchaseDate,
|
||||
amount: faceValue,
|
||||
particulars: `Purchase of ${f.units} shares - ${holding.scriptName}`
|
||||
},
|
||||
...this.txns
|
||||
];
|
||||
this.toast(`Share holding ${holding.certNo} saved successfully.`);
|
||||
this.purchaseForm = this.blankPurchaseForm();
|
||||
this.activeTab = 'holdings';
|
||||
}
|
||||
|
||||
cancelPurchase(): void {
|
||||
this.purchaseForm = this.blankPurchaseForm();
|
||||
}
|
||||
|
||||
// -------------------- Dividend --------------------
|
||||
get dividendHolding(): InvestmentHolding | undefined {
|
||||
return this.holdings.find((h) => h.certNo === this.dividendCertNo);
|
||||
}
|
||||
|
||||
get dividendAmount(): number {
|
||||
const h = this.dividendHolding;
|
||||
if (!h || !this.dividendPerShare) {
|
||||
return 0;
|
||||
}
|
||||
return Math.round(h.units * this.dividendPerShare);
|
||||
}
|
||||
|
||||
saveDividend(): void {
|
||||
const h = this.dividendHolding;
|
||||
if (!h || !this.dividendPerShare) {
|
||||
this.toast('Select a holding and enter dividend per share.');
|
||||
return;
|
||||
}
|
||||
const amt = this.dividendAmount;
|
||||
this.txns = [
|
||||
{
|
||||
certNo: h.certNo,
|
||||
instrumentType: 'SHARE',
|
||||
txnType: 'DIVIDEND',
|
||||
voucherNo: `INV/DIV/${h.certNo}-${this.txns.length + 1}`,
|
||||
txnDate: this.asOnDate,
|
||||
amount: amt,
|
||||
particulars: `Dividend received - ${h.scriptName} (₹${this.dividendPerShare}/share)`
|
||||
},
|
||||
...this.txns
|
||||
];
|
||||
this.toast(`Dividend of ₹${amt.toLocaleString('en-IN')} recorded for ${h.certNo}.`);
|
||||
this.dividendCertNo = '';
|
||||
this.dividendPerShare = null;
|
||||
}
|
||||
|
||||
// -------------------- Sell --------------------
|
||||
get sellHolding(): InvestmentHolding | undefined {
|
||||
return this.holdings.find((h) => h.certNo === this.sellCertNo);
|
||||
}
|
||||
|
||||
get sellConsideration(): number {
|
||||
if (!this.sellUnits || !this.sellRate) {
|
||||
return 0;
|
||||
}
|
||||
return Math.round(this.sellUnits * this.sellRate);
|
||||
}
|
||||
|
||||
saveSell(): void {
|
||||
const h = this.sellHolding;
|
||||
if (!h || !this.sellUnits || !this.sellRate || this.sellUnits > h.units) {
|
||||
this.toast('Select a holding and enter valid units (≤ held) and sell rate.');
|
||||
return;
|
||||
}
|
||||
const consideration = this.sellConsideration;
|
||||
h.units -= this.sellUnits;
|
||||
h.faceValue = Math.round(h.units * h.purchaseRate);
|
||||
if (h.units === 0) {
|
||||
h.status = 'SOLD';
|
||||
}
|
||||
this.txns = [
|
||||
{
|
||||
certNo: h.certNo,
|
||||
instrumentType: 'SHARE',
|
||||
txnType: 'SELL',
|
||||
voucherNo: `INV/SEL/${h.certNo}-${this.txns.length + 1}`,
|
||||
txnDate: this.asOnDate,
|
||||
amount: consideration,
|
||||
particulars: `Sold ${this.sellUnits} shares of ${h.scriptName} @ ${this.sellRate}`
|
||||
},
|
||||
...this.txns
|
||||
];
|
||||
this.toast(`Sold ${this.sellUnits} shares. Consideration ₹${consideration.toLocaleString('en-IN')}.`);
|
||||
this.sellCertNo = '';
|
||||
this.sellUnits = null;
|
||||
this.sellRate = null;
|
||||
this.activeTab = 'holdings';
|
||||
}
|
||||
|
||||
// -------------------- Write-off --------------------
|
||||
get writeoffHolding(): InvestmentHolding | undefined {
|
||||
return this.holdings.find((h) => h.certNo === this.writeoffCertNo);
|
||||
}
|
||||
|
||||
saveWriteoff(): void {
|
||||
const h = this.writeoffHolding;
|
||||
if (!h || !this.writeoffReason) {
|
||||
this.toast('Select a holding and enter a write-off reason.');
|
||||
return;
|
||||
}
|
||||
h.status = 'WRITTEN_OFF';
|
||||
this.txns = [
|
||||
{
|
||||
certNo: h.certNo,
|
||||
instrumentType: 'SHARE',
|
||||
txnType: 'WRITE_OFF',
|
||||
voucherNo: `INV/WOF/${h.certNo}`,
|
||||
txnDate: this.asOnDate,
|
||||
amount: h.faceValue,
|
||||
particulars: `Written off - ${this.writeoffReason}`
|
||||
},
|
||||
...this.txns
|
||||
];
|
||||
this.toast(`${h.certNo} written off.`);
|
||||
this.writeoffCertNo = '';
|
||||
this.writeoffReason = '';
|
||||
this.activeTab = 'holdings';
|
||||
}
|
||||
|
||||
statusClass(h: InvestmentHolding): string {
|
||||
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 {
|
||||
this.toastMsg = msg;
|
||||
this.toastVisible = true;
|
||||
if (this.toastTimer) {
|
||||
clearTimeout(this.toastTimer);
|
||||
}
|
||||
this.toastTimer = setTimeout(() => (this.toastVisible = false), 3200);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,981 @@
|
||||
/**
|
||||
* 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 NPA (Non-Performing Asset) data layer for the NPA Reports / Analysis / Classification
|
||||
// screens. Everything downstream — classification, provisioning, interest reversal, GNPA/NNPA —
|
||||
// is *derived* from LOAN_ACCOUNTS via pure functions below, so no two reports can ever disagree
|
||||
// on the same account's numbers.
|
||||
//
|
||||
// The book is generated with a seeded RNG (not Math.random()) so the "large, real-world-CBS"
|
||||
// dataset stays identical across reloads instead of reshuffling every time the page opens.
|
||||
|
||||
export type NpaStage =
|
||||
| 'STANDARD'
|
||||
| 'SMA0'
|
||||
| 'SMA1'
|
||||
| 'SMA2'
|
||||
| 'SUBSTANDARD'
|
||||
| 'DOUBTFUL1'
|
||||
| 'DOUBTFUL2'
|
||||
| 'DOUBTFUL3'
|
||||
| 'LOSS';
|
||||
|
||||
export const STAGE_ORDER: NpaStage[] = [
|
||||
'STANDARD',
|
||||
'SMA0',
|
||||
'SMA1',
|
||||
'SMA2',
|
||||
'SUBSTANDARD',
|
||||
'DOUBTFUL1',
|
||||
'DOUBTFUL2',
|
||||
'DOUBTFUL3',
|
||||
'LOSS'
|
||||
];
|
||||
|
||||
export const STAGE_LABELS: Record<NpaStage, string> = {
|
||||
STANDARD: 'Standard',
|
||||
SMA0: 'SMA-0',
|
||||
SMA1: 'SMA-1',
|
||||
SMA2: 'SMA-2',
|
||||
SUBSTANDARD: 'Sub-Standard',
|
||||
DOUBTFUL1: 'Doubtful-1',
|
||||
DOUBTFUL2: 'Doubtful-2',
|
||||
DOUBTFUL3: 'Doubtful-3',
|
||||
LOSS: 'Loss Asset'
|
||||
};
|
||||
|
||||
export function isNpaStage(stage: NpaStage): boolean {
|
||||
return stage !== 'STANDARD' && stage !== 'SMA0' && stage !== 'SMA1' && stage !== 'SMA2';
|
||||
}
|
||||
|
||||
export function isSmaStage(stage: NpaStage): boolean {
|
||||
return stage === 'SMA0' || stage === 'SMA1' || stage === 'SMA2';
|
||||
}
|
||||
|
||||
export function stageBadgeClass(stage: NpaStage): string {
|
||||
return `npa-badge npa-badge--${stage.toLowerCase()}`;
|
||||
}
|
||||
|
||||
/** Recovery-workflow stage tracked separately from IRAC classification — a Doubtful/Loss
|
||||
* account can sit at any of these while its classification stays driven purely by overdueDays. */
|
||||
export type LegalActionStatus = 'NONE' | 'NOTICE_ISSUED' | 'SARFAESI_INITIATED' | 'SUIT_FILED' | 'OTS_NEGOTIATION';
|
||||
|
||||
export const LEGAL_ACTION_LABELS: Record<LegalActionStatus, string> = {
|
||||
NONE: 'No Action Yet',
|
||||
NOTICE_ISSUED: 'Demand Notice Issued',
|
||||
SARFAESI_INITIATED: 'SARFAESI Action Initiated',
|
||||
SUIT_FILED: 'Recovery Suit Filed',
|
||||
OTS_NEGOTIATION: 'One-Time Settlement Under Negotiation'
|
||||
};
|
||||
|
||||
export interface RecoveryTxn {
|
||||
date: string;
|
||||
amount: number;
|
||||
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 {
|
||||
accountNo: string;
|
||||
customerName: string;
|
||||
guarantorName: string;
|
||||
branchCode: string;
|
||||
branchName: string;
|
||||
productCode: string;
|
||||
productName: string;
|
||||
secured: boolean;
|
||||
repaymentType: RepaymentType;
|
||||
sanctionedAmount: number;
|
||||
disbursementDate: string;
|
||||
interestRate: number;
|
||||
outstandingBalance: number;
|
||||
overdueAmount: number;
|
||||
overdueDays: number;
|
||||
overdueDaysQuarterAgo: 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;
|
||||
isLossAsset: boolean;
|
||||
isRestructured: boolean;
|
||||
provisionCoverageRatio: number;
|
||||
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. */
|
||||
npaReason: string;
|
||||
/** '—' for Standard accounts — SMA and NPA accounts get a recovery officer assigned for follow-up. */
|
||||
recoveryOfficer: string;
|
||||
legalActionStatus: LegalActionStatus;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Date helpers
|
||||
// -------------------------------------------------------------------------
|
||||
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;
|
||||
}
|
||||
|
||||
/** Parses a "DD-MM-YYYY" string back into a Date, so generated dates can be compared/ordered
|
||||
* against each other (e.g. "is this recovery more recent than the last-payment estimate?"). */
|
||||
function parseDMY(s: string): Date {
|
||||
const [
|
||||
d,
|
||||
m,
|
||||
y
|
||||
] = s.split('-').map(Number);
|
||||
return new Date(y, m - 1, d);
|
||||
}
|
||||
|
||||
export function todayDDMMYYYY(): string {
|
||||
return fmt(new Date());
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Seeded RNG — keeps the generated book stable across reloads
|
||||
// -------------------------------------------------------------------------
|
||||
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(19870614);
|
||||
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);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Masters
|
||||
// -------------------------------------------------------------------------
|
||||
export interface BranchMaster {
|
||||
code: string;
|
||||
name: string;
|
||||
}
|
||||
// BR01/BR02 are deliberately named "Branch 1"/"Branch 2" — identical to the two branches in
|
||||
// cash-clearing-transfer-summary.component.ts — so a single pair of real office names in the DB
|
||||
// gets recognised by BOTH reports instead of each module expecting a different branch name.
|
||||
export const BRANCHES: BranchMaster[] = [
|
||||
{ code: 'BR01', name: 'Branch 1' },
|
||||
{ code: 'BR02', name: 'Branch 2' },
|
||||
{ code: 'BR03', name: 'Borivali Branch' },
|
||||
{ code: 'BR04', name: 'Thane Branch' },
|
||||
{ code: 'BR05', name: 'Pune Branch' },
|
||||
{ code: 'BR06', name: 'Nashik Branch' },
|
||||
{ code: 'BR07', name: 'Nagpur 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 {
|
||||
code: string;
|
||||
name: string;
|
||||
secured: boolean;
|
||||
minAmt: number;
|
||||
maxAmt: number;
|
||||
minRate: 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[] = [
|
||||
{
|
||||
code: '01',
|
||||
name: 'Cash Credit (MSME)',
|
||||
secured: true,
|
||||
minAmt: 500000,
|
||||
maxAmt: 5000000,
|
||||
minRate: 11,
|
||||
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: '09',
|
||||
name: 'SHG / Micro Finance Loan',
|
||||
secured: false,
|
||||
minAmt: 20000,
|
||||
maxAmt: 200000,
|
||||
minRate: 12,
|
||||
maxRate: 14,
|
||||
repaymentType: 'EMI',
|
||||
tenureMonths: 24
|
||||
},
|
||||
{
|
||||
code: '10',
|
||||
name: 'Overdraft Against Property',
|
||||
secured: true,
|
||||
minAmt: 500000,
|
||||
maxAmt: 2500000,
|
||||
minRate: 11,
|
||||
maxRate: 13,
|
||||
repaymentType: 'OVERDRAWN',
|
||||
tenureMonths: 12
|
||||
}
|
||||
];
|
||||
|
||||
const FIRST_NAMES = [
|
||||
'Ramesh',
|
||||
'Suresh',
|
||||
'Mahesh',
|
||||
'Ganesh',
|
||||
'Dinesh',
|
||||
'Vijay',
|
||||
'Sanjay',
|
||||
'Ajay',
|
||||
'Prakash',
|
||||
'Vinod',
|
||||
'Sunita',
|
||||
'Anita',
|
||||
'Kavita',
|
||||
'Savita',
|
||||
'Rekha',
|
||||
'Meena',
|
||||
'Pooja',
|
||||
'Sangita',
|
||||
'Nirmala',
|
||||
'Usha',
|
||||
'Ravindra',
|
||||
'Mahendra',
|
||||
'Narendra',
|
||||
'Jitendra',
|
||||
'Rajendra',
|
||||
'Bhagwan',
|
||||
'Balasaheb',
|
||||
'Vasant',
|
||||
'Shivaji',
|
||||
'Yogesh',
|
||||
'Kiran',
|
||||
'Manisha',
|
||||
'Deepak',
|
||||
'Sandeep',
|
||||
'Pradeep',
|
||||
'Ashok',
|
||||
'Vishal',
|
||||
'Nitin',
|
||||
'Amol',
|
||||
'Santosh'
|
||||
];
|
||||
const LAST_NAMES = [
|
||||
'Patil',
|
||||
'More',
|
||||
'Jadhav',
|
||||
'Deshmukh',
|
||||
'Pawar',
|
||||
'Kulkarni',
|
||||
'Shinde',
|
||||
'Gaikwad',
|
||||
'Chavan',
|
||||
'Bhosale',
|
||||
'Kale',
|
||||
'Salunkhe',
|
||||
'Sawant',
|
||||
'Kadam',
|
||||
'Joshi',
|
||||
'Wagh',
|
||||
'Thorat',
|
||||
'Waghmare',
|
||||
'Mane',
|
||||
'Shaikh'
|
||||
];
|
||||
|
||||
const NPA_REASONS = [
|
||||
'Crop Failure / Agri Distress',
|
||||
'Business Loss',
|
||||
'Health Issue / Death of Borrower',
|
||||
'Diversion of Funds',
|
||||
'Wilful Default',
|
||||
'Natural Calamity',
|
||||
'Loss of Employment',
|
||||
'Family Dispute / Litigation',
|
||||
'Delayed Receivables from Debtors',
|
||||
'Unit Closure'
|
||||
];
|
||||
|
||||
/** Recovery officers are bank staff, kept distinct from the FIRST_NAMES/LAST_NAMES pool used for
|
||||
* customers so a name never accidentally reads as "the officer is also the borrower". */
|
||||
const RECOVERY_OFFICERS = [
|
||||
'Anil Deshpande',
|
||||
'Sunil Kadam',
|
||||
'Vikas Pathak',
|
||||
'Rajendra More',
|
||||
'Sachin Bhagat',
|
||||
'Prashant Jagtap',
|
||||
'Milind Kulkarni',
|
||||
'Ravi Ghadge'
|
||||
];
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Weighted stage buckets — used only to seed a realistic distribution of
|
||||
// overdue-day counts; the actual classification is always re-derived from
|
||||
// `overdueDays` via classifyDays(), never stored directly.
|
||||
// -------------------------------------------------------------------------
|
||||
interface StageBucket {
|
||||
weight: number;
|
||||
minDays: number;
|
||||
maxDays: number;
|
||||
isLossPool: boolean;
|
||||
}
|
||||
const BUCKETS: StageBucket[] = [
|
||||
{ weight: 60, minDays: 0, maxDays: 0, isLossPool: false },
|
||||
{ weight: 10, minDays: 1, maxDays: 30, isLossPool: false },
|
||||
{ weight: 7, minDays: 31, maxDays: 60, isLossPool: false },
|
||||
{ weight: 6, minDays: 61, maxDays: 90, isLossPool: false },
|
||||
{ weight: 9, minDays: 91, maxDays: 365, isLossPool: false },
|
||||
{ weight: 4, minDays: 366, maxDays: 730, isLossPool: false },
|
||||
{ weight: 2.5, minDays: 731, maxDays: 1095, isLossPool: false },
|
||||
{ weight: 1.5, minDays: 1096, maxDays: 1900, isLossPool: true }
|
||||
];
|
||||
const TOTAL_WEIGHT = BUCKETS.reduce((s, b) => s + b.weight, 0);
|
||||
|
||||
function pickBucket(): StageBucket {
|
||||
let r = rng() * TOTAL_WEIGHT;
|
||||
for (const b of BUCKETS) {
|
||||
if (r < b.weight) {
|
||||
return b;
|
||||
}
|
||||
r -= b.weight;
|
||||
}
|
||||
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
|
||||
* advanced/stressed the classification, the more likely the bank has already escalated. */
|
||||
function legalActionFor(stage: NpaStage): LegalActionStatus {
|
||||
if (!isNpaStage(stage)) {
|
||||
return 'NONE';
|
||||
}
|
||||
const r = rng();
|
||||
if (stage === 'SUBSTANDARD') {
|
||||
return r < 0.2 ? 'NOTICE_ISSUED' : 'NONE';
|
||||
}
|
||||
if (stage === 'DOUBTFUL1') {
|
||||
if (r < 0.15) return 'OTS_NEGOTIATION';
|
||||
if (r < 0.45) return 'SARFAESI_INITIATED';
|
||||
if (r < 0.65) return 'NOTICE_ISSUED';
|
||||
return 'NONE';
|
||||
}
|
||||
// DOUBTFUL2, DOUBTFUL3, LOSS
|
||||
if (r < 0.1) return 'OTS_NEGOTIATION';
|
||||
if (r < 0.55) return 'SUIT_FILED';
|
||||
if (r < 0.8) return 'SARFAESI_INITIATED';
|
||||
if (r < 0.9) return 'NOTICE_ISSUED';
|
||||
return 'NONE';
|
||||
}
|
||||
|
||||
function generateAccounts(count: number): LoanAccount[] {
|
||||
const today = new Date();
|
||||
const accounts: LoanAccount[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const branch = pick(BRANCHES);
|
||||
const product = pick(LOAN_PRODUCTS);
|
||||
const bucket = pickBucket();
|
||||
const overdueDays = bucket.maxDays === 0 ? 0 : round(between(bucket.minDays, bucket.maxDays));
|
||||
const isLossAsset = bucket.isLossPool && rng() < 0.4;
|
||||
// Restructured accounts skew towards the already-stressed buckets (SMA-2 and worse),
|
||||
// matching how RBI restructuring relief is actually used in practice.
|
||||
const stressed = classifyDays(overdueDays, isLossAsset);
|
||||
const isRestructured =
|
||||
rng() < (stressed === 'STANDARD' || stressed === 'SMA0' || stressed === 'SMA1' ? 0.04 : 0.18);
|
||||
|
||||
const sanctionedAmount = round(between(product.minAmt, product.maxAmt) / 1000) * 1000;
|
||||
// The loan must be old enough to have actually accrued `overdueDays` of delinquency, plus a
|
||||
// minimum "good" performing stretch beforehand — otherwise a badly-overdue account could end
|
||||
// up with a disbursement date that falls AFTER its own last-payment/NPA date, which would look
|
||||
// wrong no matter what day this report is viewed on.
|
||||
const minGoodPeriodDays = round(between(30, 180));
|
||||
const loanAgeDays = Math.max(round(between(0.5, 8) * 365), overdueDays + minGoodPeriodDays);
|
||||
const disbursementDate = fmt(addDays(today, -loanAgeDays));
|
||||
const interestRate = +between(product.minRate, product.maxRate).toFixed(2);
|
||||
const outstandingBalance = round(sanctionedAmount * between(0.25, 0.9));
|
||||
const overdueFrac = overdueDays === 0 ? 0 : Math.min(0.85, overdueDays / 1200 + between(0, 0.15));
|
||||
const overdueAmount = round(outstandingBalance * overdueFrac);
|
||||
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 recoveryOfficer = isNpaStage(stressed) || isSmaStage(stressed) ? pick(RECOVERY_OFFICERS) : '—';
|
||||
const legalActionStatus = legalActionFor(stressed);
|
||||
|
||||
// Quarter-ago overdue count is capped at how old the loan actually was 90 days back — a loan
|
||||
// can't have been overdue for longer than it had existed at that point in time.
|
||||
const drift = round((rng() - 0.4) * 130);
|
||||
const maxQuarterAgoOverdueDays = Math.max(0, loanAgeDays - 90);
|
||||
const overdueDaysQuarterAgo = Math.min(Math.max(0, overdueDays - drift), maxQuarterAgoOverdueDays);
|
||||
|
||||
const recoveries: RecoveryTxn[] = [];
|
||||
if (overdueDays > 90 && rng() < 0.5) {
|
||||
const numRecoveries = 1 + Math.floor(rng() * 3);
|
||||
for (let r = 0; r < numRecoveries; r++) {
|
||||
recoveries.push({
|
||||
date: fmt(addDays(today, -round(between(10, Math.min(overdueDays, 400))))),
|
||||
amount: round(outstandingBalance * between(0.02, 0.12)),
|
||||
mode: pick([
|
||||
'Cash Deposit',
|
||||
'Cheque',
|
||||
'NEFT/RTGS',
|
||||
'Auction / SARFAESI',
|
||||
'One-Time Settlement'
|
||||
])
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// A recorded recovery IS a payment — if one landed more recently than the formula-based
|
||||
// last-payment estimate, the account's "last payment" must reflect that, otherwise the two
|
||||
// fields on the Account Detail screen would visibly contradict each other.
|
||||
const formulaLastPaymentDate = fmt(addDays(today, -Math.max(5, overdueDays - round(between(0, 20)))));
|
||||
const latestRecoveryDate = recoveries.reduce<Date | null>((latest, r) => {
|
||||
const d = parseDMY(r.date);
|
||||
return !latest || d > latest ? d : latest;
|
||||
}, null);
|
||||
const lastPaymentDate =
|
||||
latestRecoveryDate && latestRecoveryDate > parseDMY(formulaLastPaymentDate)
|
||||
? fmt(latestRecoveryDate)
|
||||
: formulaLastPaymentDate;
|
||||
|
||||
const collectionTimeline = buildCollectionTimeline(overdueDays, recoveries, today);
|
||||
|
||||
const seq = String(1000 + i).padStart(6, '0');
|
||||
accounts.push({
|
||||
accountNo: `1${branch.code.slice(2)}${product.code}${seq}`,
|
||||
customerName: `${pick(FIRST_NAMES)} ${pick(LAST_NAMES)}`,
|
||||
guarantorName: `${pick(FIRST_NAMES)} ${pick(LAST_NAMES)}`,
|
||||
branchCode: branch.code,
|
||||
branchName: branch.name,
|
||||
productCode: product.code,
|
||||
productName: product.name,
|
||||
secured: product.secured,
|
||||
repaymentType: product.repaymentType,
|
||||
sanctionedAmount,
|
||||
disbursementDate,
|
||||
interestRate,
|
||||
outstandingBalance,
|
||||
overdueAmount,
|
||||
overdueDays,
|
||||
overdueDaysQuarterAgo,
|
||||
collateralValue,
|
||||
collateralMarketValue,
|
||||
emiAmount,
|
||||
drawingPower,
|
||||
maturityDate,
|
||||
lastPaymentDate,
|
||||
isLossAsset,
|
||||
isRestructured,
|
||||
provisionCoverageRatio: +between(0.75, 1.1).toFixed(2),
|
||||
recoveries,
|
||||
collectionTimeline,
|
||||
npaReason,
|
||||
recoveryOfficer,
|
||||
legalActionStatus
|
||||
});
|
||||
}
|
||||
return accounts;
|
||||
}
|
||||
|
||||
// 8 branches x ~80 accounts/branch on average — sized to look like a real cooperative bank's
|
||||
// loan book rather than a token demo sample. Pagination in the reports UI is what makes a book
|
||||
// this size usable, not a smaller dataset.
|
||||
export const LOAN_ACCOUNTS: LoanAccount[] = generateAccounts(640);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Classification & provisioning (RBI IRAC norms, simplified for a demo book)
|
||||
// -------------------------------------------------------------------------
|
||||
export function classifyDays(days: number, isLossAsset: boolean): NpaStage {
|
||||
if (isLossAsset && days > 1095) {
|
||||
return 'LOSS';
|
||||
}
|
||||
if (days <= 0) {
|
||||
return 'STANDARD';
|
||||
}
|
||||
if (days <= 30) {
|
||||
return 'SMA0';
|
||||
}
|
||||
if (days <= 60) {
|
||||
return 'SMA1';
|
||||
}
|
||||
if (days <= 90) {
|
||||
return 'SMA2';
|
||||
}
|
||||
if (days <= 365) {
|
||||
return 'SUBSTANDARD';
|
||||
}
|
||||
if (days <= 730) {
|
||||
return 'DOUBTFUL1';
|
||||
}
|
||||
if (days <= 1095) {
|
||||
return 'DOUBTFUL2';
|
||||
}
|
||||
return 'DOUBTFUL3';
|
||||
}
|
||||
|
||||
export function currentStage(a: LoanAccount): NpaStage {
|
||||
return classifyDays(a.overdueDays, a.isLossAsset);
|
||||
}
|
||||
|
||||
export function previousStage(a: LoanAccount): NpaStage {
|
||||
return classifyDays(a.overdueDaysQuarterAgo, a.isLossAsset && a.overdueDaysQuarterAgo > 1095);
|
||||
}
|
||||
|
||||
export function provisionRatePct(stage: NpaStage, secured: boolean): number {
|
||||
switch (stage) {
|
||||
case 'STANDARD':
|
||||
case 'SMA0':
|
||||
case 'SMA1':
|
||||
return 0.4;
|
||||
case 'SMA2':
|
||||
return 0.75;
|
||||
case 'SUBSTANDARD':
|
||||
return secured ? 15 : 25;
|
||||
case 'DOUBTFUL1':
|
||||
return secured ? 25 : 100;
|
||||
case 'DOUBTFUL2':
|
||||
return secured ? 40 : 100;
|
||||
case 'DOUBTFUL3':
|
||||
case 'LOSS':
|
||||
return 100;
|
||||
}
|
||||
}
|
||||
|
||||
export function provisionRequired(a: LoanAccount): number {
|
||||
const stage = currentStage(a);
|
||||
if (!isNpaStage(stage)) {
|
||||
return round(a.outstandingBalance * (provisionRatePct(stage, a.secured) / 100));
|
||||
}
|
||||
const securedPortion = Math.min(a.outstandingBalance, a.collateralValue);
|
||||
const unsecuredPortion = Math.max(0, a.outstandingBalance - a.collateralValue);
|
||||
const securedRate = provisionRatePct(stage, true) / 100;
|
||||
const unsecuredRate = provisionRatePct(stage, false) / 100;
|
||||
return round(securedPortion * securedRate + unsecuredPortion * unsecuredRate);
|
||||
}
|
||||
|
||||
/** Provision held can run a little above or below what's required (over/under-provisioning
|
||||
* happens in real banks) — but it can never exceed the account's own outstanding balance, since
|
||||
* a bank cannot hold more provision than the money actually at risk on that one loan. */
|
||||
export function provisionHeld(a: LoanAccount): number {
|
||||
return Math.min(a.outstandingBalance, round(provisionRequired(a) * a.provisionCoverageRatio));
|
||||
}
|
||||
|
||||
/** Days elapsed since the account first crossed 90 days overdue (i.e. became NPA) — 0 for any
|
||||
* account that isn't currently NPA. The single source both interest-reversal and NPA-since-date
|
||||
* are built from, so neither can ever quietly drift out of step with the other. */
|
||||
export function daysSinceNpa(a: LoanAccount): number {
|
||||
if (!isNpaStage(currentStage(a))) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(0, a.overdueDays - 90);
|
||||
}
|
||||
|
||||
/** 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,
|
||||
* never stored separately, so it always agrees with the account's current classification. */
|
||||
export function npaSinceDate(a: LoanAccount): string {
|
||||
if (!isNpaStage(currentStage(a))) {
|
||||
return '—';
|
||||
}
|
||||
return fmt(addDays(new Date(), -daysSinceNpa(a)));
|
||||
}
|
||||
|
||||
export function totalRecovered(a: LoanAccount): number {
|
||||
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 {
|
||||
if (prev === curr) {
|
||||
return 'No Change';
|
||||
}
|
||||
const prevIdx = STAGE_ORDER.indexOf(prev);
|
||||
const currIdx = STAGE_ORDER.indexOf(curr);
|
||||
if (currIdx > prevIdx) {
|
||||
return !isNpaStage(prev) && isNpaStage(curr) ? 'Fresh Slippage to NPA' : 'Downgraded';
|
||||
}
|
||||
return isNpaStage(prev) && !isNpaStage(curr) ? 'Upgraded to Standard' : 'Improved';
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Report menu metadata — drives both the shell mega-menu routerLinks and the
|
||||
// in-page group/report tab rows, so a menu item and its screen can never
|
||||
// point at different labels.
|
||||
// -------------------------------------------------------------------------
|
||||
export type ReportGroupId = 'reports' | 'analysis' | 'classification' | 'recovery-mgmt';
|
||||
export interface ReportMeta {
|
||||
id: string;
|
||||
group: ReportGroupId;
|
||||
label: string;
|
||||
}
|
||||
export const REPORT_META: ReportMeta[] = [
|
||||
{ id: 'interest', group: 'reports', label: 'NPA Report' },
|
||||
{ id: 'status', group: 'reports', label: 'NPA Status Report' },
|
||||
{ id: 'recovery', group: 'reports', label: 'NPA Recovery Report' },
|
||||
{ id: 'summary', group: 'reports', label: 'NPA Summary Report' },
|
||||
{ id: 'account-detail', group: 'analysis', label: 'NPA Account Detail Report' },
|
||||
{ id: 'movement', group: 'analysis', label: 'NPA Movement Report' },
|
||||
{ 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: '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> = {
|
||||
reports: 'NPA Reports',
|
||||
analysis: 'NPA Analysis',
|
||||
classification: 'NPA Classification',
|
||||
'recovery-mgmt': 'Recovery Management'
|
||||
};
|
||||
export const DEFAULT_REPORT_ID = 'summary';
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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 { NpaReportsComponent } from './npa-reports/npa-reports.component';
|
||||
import { DEFAULT_REPORT_ID } from './npa-data';
|
||||
|
||||
const routes: Routes = [
|
||||
Route.withShell([
|
||||
{
|
||||
path: 'npa',
|
||||
data: { title: 'NPA', breadcrumb: 'NPA' },
|
||||
children: [
|
||||
{
|
||||
path: 'reports',
|
||||
redirectTo: `reports/${DEFAULT_REPORT_ID}`,
|
||||
pathMatch: 'full'
|
||||
},
|
||||
{
|
||||
path: 'reports/:reportId',
|
||||
component: NpaReportsComponent,
|
||||
data: { title: 'NPA Reports', breadcrumb: 'NPA Reports' }
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class NpaRoutingModule {}
|
||||
@@ -0,0 +1,452 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
// NPA-specific status colouring layered on top of ../cashier/cashier-shared.scss (pg-*, cctr-*)
|
||||
// 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).
|
||||
|
||||
.npa-report-note {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.npa-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;
|
||||
|
||||
&--standard {
|
||||
background: #f0fdf4;
|
||||
color: #15803d;
|
||||
border: 1px solid #bbf7d0;
|
||||
}
|
||||
|
||||
&--sma0 {
|
||||
background: #fefce8;
|
||||
color: #854d0e;
|
||||
border: 1px solid #fde68a;
|
||||
}
|
||||
|
||||
&--sma1 {
|
||||
background: #fff7ed;
|
||||
color: #9a3412;
|
||||
border: 1px solid #fed7aa;
|
||||
}
|
||||
|
||||
&--sma2 {
|
||||
background: #fff1f0;
|
||||
color: #b91c1c;
|
||||
border: 1px solid #fecdd3;
|
||||
}
|
||||
|
||||
&--substandard {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
&--doubtful1 {
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
border: 1px solid #fca5a5;
|
||||
}
|
||||
|
||||
&--doubtful2 {
|
||||
background: #fdf2f8;
|
||||
color: #9d174d;
|
||||
border: 1px solid #fbcfe8;
|
||||
}
|
||||
|
||||
&--doubtful3 {
|
||||
background: #f5f3ff;
|
||||
color: #5b21b6;
|
||||
border: 1px solid #ddd6fe;
|
||||
}
|
||||
|
||||
&--loss {
|
||||
background: #1f2937;
|
||||
color: #f9fafb;
|
||||
border: 1px solid #1f2937;
|
||||
}
|
||||
}
|
||||
|
||||
.npa-kpis--ratio {
|
||||
.inv-kpi__value {
|
||||
&.npa-warn {
|
||||
color: #dc2626;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 12px 20px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.npa-detail-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;
|
||||
}
|
||||
}
|
||||
|
||||
// Wraps tables with more columns than comfortably fit — .pg-card clips overflow with
|
||||
// border-radius, so the scroll container has to live one level in, around the <table> only.
|
||||
.pg-table-scroll {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
// Lets a single field span the whole .inv-form-grid row instead of being squeezed into one
|
||||
// ~220px column — needed for the Account Detail search box, whose "code — name (branch)"
|
||||
// option text is long enough to overflow a narrow column even with ellipsis truncation.
|
||||
.npa-form-field--full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.pg-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
padding: 10px 16px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
background: #f8fafc;
|
||||
font-size: 12.5px;
|
||||
color: #64748b;
|
||||
|
||||
&__controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
&__page {
|
||||
font-weight: 600;
|
||||
color: #0d1b2a;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
@@ -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 { NpaRoutingModule } from './npa-routing.module';
|
||||
import { NpaReportsComponent } from './npa-reports/npa-reports.component';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
SharedModule,
|
||||
NpaRoutingModule,
|
||||
NpaReportsComponent
|
||||
]
|
||||
})
|
||||
export class NpaModule {}
|
||||
@@ -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-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">
|
||||
<!-- Order Position Column -->
|
||||
<ng-container matColumnDef="orderPosition">
|
||||
@@ -45,6 +61,39 @@
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- What the min/max figures are measured against -->
|
||||
<ng-container matColumnDef="limitBasis">
|
||||
<th mat-header-cell *matHeaderCellDef>Limit Basis</th>
|
||||
<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>
|
||||
|
||||
<!-- Reason Column -->
|
||||
<ng-container matColumnDef="reason">
|
||||
<th mat-header-cell *matHeaderCellDef>Rule Reason / Criteria</th>
|
||||
|
||||
@@ -114,6 +114,143 @@
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.col-right {
|
||||
text-align: right;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* What the min/max amounts are measured against. */
|
||||
.basis-cell {
|
||||
color: #334155;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: normal;
|
||||
max-width: 150px;
|
||||
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 */
|
||||
.risk-badge {
|
||||
display: inline-flex;
|
||||
|
||||
@@ -42,23 +42,185 @@ export class AmlRulesComponent implements OnInit {
|
||||
'orderPosition',
|
||||
'ruleName',
|
||||
'riskCategory',
|
||||
'limitBasis',
|
||||
'minLimit',
|
||||
'maxLimit',
|
||||
'appliesOver',
|
||||
'reason',
|
||||
'amlCheckStatus',
|
||||
'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;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.clientsService.getClientRiskRules().subscribe({
|
||||
next: (data) => {
|
||||
this.rules.data = data || [];
|
||||
this.allRules = data || [];
|
||||
this.applyPeriodFilter();
|
||||
this.rules.paginator = this.paginator;
|
||||
},
|
||||
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 {
|
||||
switch (category) {
|
||||
case 1:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Shapes common to every RBI CIMS off-site surveillance return (OSS 1..7).
|
||||
* Kept free of any ExcelJS import so data files stay dependency-light.
|
||||
*/
|
||||
|
||||
/** One labelled row of a return. */
|
||||
export interface ReturnLine {
|
||||
label: string;
|
||||
/** 0 = numbered head, 1 = roman sub-item, 2 = a/b/c leaf. Drives indentation. */
|
||||
level: number;
|
||||
/** null renders as an empty cell, never as 0 — RBI treats blank and zero differently. */
|
||||
value: number | null;
|
||||
/** Derived/aggregate row — shaded and bold, like the blue cells in the workbook. */
|
||||
total?: boolean;
|
||||
/** Row is a headcount (depositors / borrowers / employees), not a rupee amount. */
|
||||
count?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* One row of a multi-column statement. Returns from OSS 3 onwards are matrices
|
||||
* (risk category x asset class) rather than the single-value lists of OSS 1/2.
|
||||
*/
|
||||
export interface MatrixRow {
|
||||
label: string;
|
||||
/** 0 = numbered head, 1..n = progressively indented sub-items. */
|
||||
level: number;
|
||||
/** One entry per data column; null renders blank, never 0. */
|
||||
values: (number | null)[];
|
||||
/** Derived/aggregate row — shaded and bold. */
|
||||
total?: boolean;
|
||||
/** Caption-only row that spans the table with no figures of its own. */
|
||||
header?: boolean;
|
||||
/** Render values as percentages rather than amounts. */
|
||||
percent?: boolean;
|
||||
}
|
||||
|
||||
/** A labelled statement made of a header band plus matrix rows. */
|
||||
export interface MatrixTable {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
/** Column captions excluding the leading label column. */
|
||||
columns: string[];
|
||||
rows: MatrixRow[];
|
||||
notes?: string[];
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
/** A signatory block as it appears on the Signatory sheet of every OSS return. */
|
||||
export interface ReturnSignatory {
|
||||
heading: string;
|
||||
signature: string;
|
||||
name: string;
|
||||
designation: string;
|
||||
email: string;
|
||||
telephoneOffice: string;
|
||||
telephoneResidence: string;
|
||||
place: string;
|
||||
date: string;
|
||||
}
|
||||
|
||||
/** A `label -> value` row on the General Information sheet. */
|
||||
export interface ReturnGeneralInfoRow {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/** Field order shared by the Signatory sheet of every OSS return. */
|
||||
export const SIGNATORY_FIELDS: { key: keyof ReturnSignatory; caption: string }[] = [
|
||||
{ key: 'signature', caption: 'Signature' },
|
||||
{ key: 'name', caption: 'Name' },
|
||||
{ key: 'designation', caption: 'Designation' },
|
||||
{ key: 'email', caption: 'e-mail ID' },
|
||||
{ key: 'telephoneOffice', caption: 'Tele. No. (O)' },
|
||||
{ key: 'telephoneResidence', caption: 'Tele. No. (R)' },
|
||||
{ key: 'place', caption: 'Place' },
|
||||
{ key: 'date', caption: 'Date' }
|
||||
];
|
||||
|
||||
/**
|
||||
* Identity of the reporting bank, shared by every OSS return so the General
|
||||
* Information sheets can never drift apart between OSS 1 and OSS 2.
|
||||
*/
|
||||
export const OSS_BANK = {
|
||||
name: 'CBSNEXORA BANK LTD., HEAD OFFICE',
|
||||
bankCode: '91007',
|
||||
roName: 'MUMBAI',
|
||||
institutionType: 'Urban Cooperative Banks',
|
||||
reportingFrequency: 'Quarterly',
|
||||
quarterEndDate: '30-Jun-2026',
|
||||
auditFlag: 'Un-Audited',
|
||||
dateOfAudit: '',
|
||||
returnVersion: 'V1.0',
|
||||
startDate: '01-Apr-2026',
|
||||
/** OSS 3 prints dates as DD-MM-YYYY rather than DD-Mon-YYYY. */
|
||||
startDateNumeric: '01-04-2026',
|
||||
quarterEndDateNumeric: '30-06-2026'
|
||||
};
|
||||
|
||||
/**
|
||||
* Signatories for the demo bank. The `.example` domain is reserved for
|
||||
* documentation, so these addresses can never reach a real inbox.
|
||||
*/
|
||||
export const OSS_SIGNATORIES: ReturnSignatory[] = [
|
||||
{
|
||||
heading: 'Authorised Reporting Official',
|
||||
signature: 'ROHAN DESHPANDE',
|
||||
name: 'ROHAN DESHPANDE',
|
||||
designation: 'MANAGER — REGULATORY REPORTING',
|
||||
email: 'rohan.deshpande@cbsnexorabank.example',
|
||||
telephoneOffice: '022-40015520',
|
||||
telephoneResidence: '022-40015588',
|
||||
place: 'MUMBAI',
|
||||
date: '14/07/2026'
|
||||
},
|
||||
{
|
||||
heading: 'Countersigned By',
|
||||
signature: 'ANJALI MENON',
|
||||
name: 'ANJALI MENON',
|
||||
designation: 'CHIEF EXECUTIVE OFFICER',
|
||||
email: 'ceo@cbsnexorabank.example',
|
||||
telephoneOffice: '022-40015500',
|
||||
telephoneResidence: '022-40015501',
|
||||
place: 'MUMBAI',
|
||||
date: '14/07/2026'
|
||||
}
|
||||
];
|
||||
@@ -0,0 +1,569 @@
|
||||
/**
|
||||
* 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 {
|
||||
/* Mirrors the RBI CIMS workbook palette so the page and the export read alike. */
|
||||
--o-navy: #002f5f;
|
||||
--o-blue: #1d4ed8;
|
||||
--o-blue-l: #eff6ff;
|
||||
--o-derived: #cfe2f8;
|
||||
--o-green: #15803d;
|
||||
--o-green-l: #dcfce7;
|
||||
--o-amber-l: #ffc;
|
||||
--o-amber-d: #92400e;
|
||||
--o-red: #c0392b;
|
||||
--o-red-l: #fdecea;
|
||||
--o-g50: #f8fafc;
|
||||
--o-g100: #f1f5f9;
|
||||
--o-g200: #e2e8f0;
|
||||
--o-g300: #cbd5e1;
|
||||
--o-g500: #64748b;
|
||||
--o-g700: #334155;
|
||||
--o-g900: #0f172a;
|
||||
|
||||
display: block;
|
||||
font-family:
|
||||
'DM Sans',
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
background: var(--o-g50);
|
||||
color: var(--o-g900);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.oss-root {
|
||||
padding: 18px 22px;
|
||||
max-width: 1500px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* HEADER */
|
||||
.oss-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.oss-head-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.oss-head-ic {
|
||||
font-size: 28px;
|
||||
color: var(--o-navy);
|
||||
}
|
||||
|
||||
.oss-head-t {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.oss-head-s {
|
||||
font-size: 12px;
|
||||
color: var(--o-g500);
|
||||
}
|
||||
|
||||
.oss-head-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.oss-audit-pill {
|
||||
padding: 4px 12px;
|
||||
border-radius: 999px;
|
||||
background: var(--o-amber-l);
|
||||
color: var(--o-amber-d);
|
||||
border: 1px solid #fcd34d;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* BUTTONS */
|
||||
.oss-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 9px 16px;
|
||||
border: 1px solid var(--o-g300);
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--o-g700);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.oss-btn-xls {
|
||||
background: #16a34a;
|
||||
border-color: #16a34a;
|
||||
color: #fff;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #15803d;
|
||||
border-color: #15803d;
|
||||
}
|
||||
}
|
||||
|
||||
/* BALANCE CHECK */
|
||||
.oss-balance {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 16px;
|
||||
border-radius: 10px;
|
||||
background: var(--o-green-l);
|
||||
border: 1px solid #86efac;
|
||||
color: #14532d;
|
||||
font-size: 13px;
|
||||
margin-bottom: 14px;
|
||||
|
||||
&--bad {
|
||||
background: var(--o-red-l);
|
||||
border-color: #fca5a5;
|
||||
color: #7f1d1d;
|
||||
}
|
||||
}
|
||||
|
||||
/* TABS */
|
||||
.oss-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--o-g200);
|
||||
border-radius: 10px;
|
||||
padding: 4px;
|
||||
margin-bottom: 16px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.oss-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 9px 14px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--o-g500);
|
||||
cursor: pointer;
|
||||
border-radius: 7px;
|
||||
white-space: nowrap;
|
||||
transition: all 0.15s;
|
||||
font-family: inherit;
|
||||
|
||||
&:hover {
|
||||
color: var(--o-g900);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--o-navy);
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 6px rgb(0 47 95 / 25%);
|
||||
}
|
||||
}
|
||||
|
||||
.oss-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Full-width banner, matching the dark blue header band in the workbook. */
|
||||
.oss-sheet-banner {
|
||||
background: var(--o-navy);
|
||||
color: #fff;
|
||||
padding: 10px 18px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* TABLES */
|
||||
.oss-tbl-wrap {
|
||||
background: #fff;
|
||||
border: 1px solid var(--o-g200);
|
||||
border-radius: 10px;
|
||||
overflow: auto;
|
||||
box-shadow: 0 1px 3px rgb(0 0 0 / 4%);
|
||||
}
|
||||
|
||||
.oss-tbl-h {
|
||||
background: var(--o-blue-l);
|
||||
color: var(--o-navy);
|
||||
padding: 10px 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
border-bottom: 1px solid var(--o-g200);
|
||||
}
|
||||
|
||||
.oss-tbl {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 9px 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--o-g700);
|
||||
background: var(--o-g100);
|
||||
border-bottom: 1px solid var(--o-g200);
|
||||
border-right: 1px solid var(--o-g200);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--o-g100);
|
||||
border-right: 1px solid var(--o-g100);
|
||||
font-size: 13px;
|
||||
color: var(--o-g700);
|
||||
}
|
||||
|
||||
tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
tbody tr:hover td {
|
||||
background: var(--o-g50);
|
||||
}
|
||||
}
|
||||
|
||||
/* Key/value layout used by General Information and Signatory. */
|
||||
.oss-tbl--kv {
|
||||
th {
|
||||
width: 40%;
|
||||
background: #d9d9d9;
|
||||
color: var(--o-g900);
|
||||
}
|
||||
|
||||
td.filled {
|
||||
background: var(--o-derived);
|
||||
}
|
||||
|
||||
td.audit {
|
||||
background: var(--o-amber-l);
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
/* Derived/aggregate rows — the blue cells in the original sheet. */
|
||||
.total-row td {
|
||||
font-weight: 700;
|
||||
color: var(--o-g900);
|
||||
}
|
||||
|
||||
td.derived {
|
||||
background: var(--o-derived);
|
||||
font-weight: 700;
|
||||
color: var(--o-navy);
|
||||
}
|
||||
|
||||
/* Indentation matching the return's numbered / roman / lettered hierarchy. */
|
||||
.lvl-0 {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.lvl-1 {
|
||||
padding-left: 30px !important;
|
||||
}
|
||||
|
||||
.lvl-2 {
|
||||
padding-left: 52px !important;
|
||||
}
|
||||
|
||||
.lvl-3 {
|
||||
padding-left: 74px !important;
|
||||
}
|
||||
|
||||
/* Caption-only row spanning a statement (e.g. "PRIORITY SECTOR"). */
|
||||
.section-row td {
|
||||
background: var(--o-g100);
|
||||
font-weight: 700;
|
||||
color: var(--o-g900);
|
||||
}
|
||||
|
||||
/* Wide matrices keep their label column readable while the figures scroll. */
|
||||
.oss-col-label {
|
||||
min-width: 320px;
|
||||
}
|
||||
|
||||
.oss-tbl-unit {
|
||||
float: right;
|
||||
font-weight: 600;
|
||||
color: var(--o-g500);
|
||||
}
|
||||
|
||||
.oss-tbl-sub {
|
||||
padding: 8px 16px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--o-g700);
|
||||
background: var(--o-g100);
|
||||
border-bottom: 1px solid var(--o-g200);
|
||||
}
|
||||
|
||||
.ta-c {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ta-r {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: 'DM Mono', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* NOTES */
|
||||
.oss-notes {
|
||||
background: var(--o-g100);
|
||||
border: 1px solid var(--o-g200);
|
||||
border-radius: 8px;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
|
||||
.oss-note-line {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--o-g700);
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.oss-foot-note {
|
||||
margin: -6px 2px 0;
|
||||
font-size: 11px;
|
||||
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). */
|
||||
.report-pill {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
|
||||
&.report-ok {
|
||||
background: var(--o-green-l);
|
||||
color: var(--o-green);
|
||||
}
|
||||
|
||||
&.report-bad {
|
||||
background: var(--o-red-l);
|
||||
color: var(--o-red);
|
||||
}
|
||||
}
|
||||
|
||||
.oss-sign-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* TOAST */
|
||||
.oss-toast {
|
||||
position: fixed;
|
||||
bottom: 32px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(20px);
|
||||
background: var(--o-navy);
|
||||
color: #fff;
|
||||
padding: 12px 22px;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
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 <= 1000px) {
|
||||
.oss-sign-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* 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 * as ExcelJS from 'exceljs';
|
||||
import { MatrixTable, ReturnGeneralInfoRow, ReturnLine, ReturnSignatory, SIGNATORY_FIELDS } from './oss-return.model';
|
||||
|
||||
/** Palette lifted from the RBI CIMS workbooks so exports look like the originals. */
|
||||
export const XL = {
|
||||
banner: 'FF002F5F',
|
||||
bannerText: 'FFFFFFFF',
|
||||
headerGrey: 'FFC0C0C0',
|
||||
derivedBlue: 'FF9CC3F5',
|
||||
auditYellow: 'FFFFFFCC',
|
||||
keyCell: 'FFD9D9D9',
|
||||
border: 'FF000000'
|
||||
};
|
||||
|
||||
const AMOUNT_FMT = '#,##0.00';
|
||||
const COUNT_FMT = '#,##0';
|
||||
|
||||
export function solidFill(argb: string): ExcelJS.Fill {
|
||||
return { type: 'pattern', pattern: 'solid', fgColor: { argb } };
|
||||
}
|
||||
|
||||
/** Dark-blue full-width banner that heads every sheet in the RBI templates. */
|
||||
export function addBanner(sheet: ExcelJS.Worksheet, title: string, lastCol: string): void {
|
||||
const row = sheet.addRow([title]);
|
||||
sheet.mergeCells(`A${row.number}:${lastCol}${row.number}`);
|
||||
row.height = 26;
|
||||
const cell = row.getCell(1);
|
||||
cell.fill = solidFill(XL.banner);
|
||||
cell.font = { bold: true, size: 13, color: { argb: XL.bannerText } };
|
||||
cell.alignment = { vertical: 'middle', horizontal: 'center' };
|
||||
sheet.addRow([]);
|
||||
}
|
||||
|
||||
export function boxAllCells(sheet: ExcelJS.Worksheet, from: number, to: number, lastColIndex: number): void {
|
||||
for (let r = from; r <= to; r++) {
|
||||
for (let c = 1; c <= lastColIndex; c++) {
|
||||
sheet.getRow(r).getCell(c).border = {
|
||||
top: { style: 'thin', color: { argb: XL.border } },
|
||||
left: { style: 'thin', color: { argb: XL.border } },
|
||||
bottom: { style: 'thin', color: { argb: XL.border } },
|
||||
right: { style: 'thin', color: { argb: XL.border } }
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function addSectionHeader(sheet: ExcelJS.Worksheet, label: string, unitLabel?: string): void {
|
||||
const row = sheet.addRow([
|
||||
label,
|
||||
unitLabel ?? ''
|
||||
]);
|
||||
row.font = { bold: true };
|
||||
row.eachCell((cell) => (cell.fill = solidFill(XL.headerGrey)));
|
||||
row.getCell(2).alignment = { horizontal: 'right' };
|
||||
}
|
||||
|
||||
/** Renders a labelled block; indents by level and shades derived totals. */
|
||||
export function addLineRows(sheet: ExcelJS.Worksheet, lines: ReturnLine[]): void {
|
||||
for (const line of lines) {
|
||||
const row = sheet.addRow([
|
||||
line.label,
|
||||
line.value
|
||||
]);
|
||||
const labelCell = row.getCell(1);
|
||||
const valueCell = row.getCell(2);
|
||||
|
||||
labelCell.alignment = { wrapText: true, vertical: 'middle', indent: line.level };
|
||||
if (line.level === 0) {
|
||||
labelCell.font = { bold: true };
|
||||
}
|
||||
valueCell.numFmt = line.count ? COUNT_FMT : AMOUNT_FMT;
|
||||
valueCell.alignment = { horizontal: 'right' };
|
||||
if (line.total) {
|
||||
valueCell.fill = solidFill(XL.derivedBlue);
|
||||
valueCell.font = { bold: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a multi-column statement: title band, column header row, then the matrix
|
||||
* rows. Returns the row number after the block so callers can keep stacking.
|
||||
*/
|
||||
export function addMatrixTable(sheet: ExcelJS.Worksheet, table: MatrixTable): void {
|
||||
const colCount = table.columns.length + 1;
|
||||
const start = sheet.rowCount + 1;
|
||||
|
||||
const titleRow = sheet.addRow([
|
||||
table.title,
|
||||
...(table.unit ? [table.unit] : [])
|
||||
]);
|
||||
if (!table.unit) {
|
||||
sheet.mergeCells(titleRow.number, 1, titleRow.number, colCount);
|
||||
}
|
||||
titleRow.font = { bold: true };
|
||||
for (let c = 1; c <= colCount; c++) {
|
||||
titleRow.getCell(c).fill = solidFill(XL.headerGrey);
|
||||
}
|
||||
|
||||
if (table.subtitle) {
|
||||
const sub = sheet.addRow([table.subtitle]);
|
||||
sheet.mergeCells(sub.number, 1, sub.number, colCount);
|
||||
sub.font = { bold: true, size: 10 };
|
||||
sub.alignment = { wrapText: true };
|
||||
sub.getCell(1).fill = solidFill(XL.headerGrey);
|
||||
}
|
||||
|
||||
const head = sheet.addRow([
|
||||
'Particulars',
|
||||
...table.columns
|
||||
]);
|
||||
head.font = { bold: true };
|
||||
head.alignment = { wrapText: true, horizontal: 'center', vertical: 'middle' };
|
||||
head.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
|
||||
|
||||
for (const row of table.rows) {
|
||||
const excelRow = sheet.addRow([
|
||||
row.label,
|
||||
...row.values
|
||||
]);
|
||||
const labelCell = excelRow.getCell(1);
|
||||
labelCell.alignment = { wrapText: true, vertical: 'middle', indent: row.level };
|
||||
if (row.level === 0 || row.header) {
|
||||
labelCell.font = { bold: true };
|
||||
}
|
||||
if (row.header) {
|
||||
labelCell.fill = solidFill(XL.headerGrey);
|
||||
continue;
|
||||
}
|
||||
for (let c = 2; c <= colCount; c++) {
|
||||
const cell = excelRow.getCell(c);
|
||||
cell.numFmt = row.percent ? '0.00"%"' : AMOUNT_FMT;
|
||||
cell.alignment = { horizontal: 'right' };
|
||||
if (row.total) {
|
||||
cell.fill = solidFill(XL.derivedBlue);
|
||||
cell.font = { bold: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
boxAllCells(sheet, start, sheet.rowCount, colCount);
|
||||
|
||||
for (const note of table.notes ?? []) {
|
||||
const noteRow = sheet.addRow([note]);
|
||||
sheet.mergeCells(noteRow.number, 1, noteRow.number, colCount);
|
||||
noteRow.font = { bold: true, size: 9 };
|
||||
noteRow.alignment = { wrapText: true };
|
||||
noteRow.getCell(1).fill = solidFill(XL.headerGrey);
|
||||
}
|
||||
sheet.addRow([]);
|
||||
}
|
||||
|
||||
/** The General Information sheet is identical in shape across every OSS return. */
|
||||
export function buildGeneralInfoSheet(wb: ExcelJS.Workbook, rows: ReturnGeneralInfoRow[]): void {
|
||||
const sheet = wb.addWorksheet('General Information');
|
||||
sheet.columns = [
|
||||
{ width: 26 },
|
||||
{ width: 58 }];
|
||||
addBanner(sheet, 'General Information', 'B');
|
||||
|
||||
const first = sheet.rowCount + 1;
|
||||
for (const item of rows) {
|
||||
const row = sheet.addRow([
|
||||
item.label,
|
||||
item.value
|
||||
]);
|
||||
row.getCell(1).fill = solidFill(XL.keyCell);
|
||||
// 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;
|
||||
row.getCell(2).fill = solidFill(fill);
|
||||
}
|
||||
boxAllCells(sheet, first, sheet.rowCount, 2);
|
||||
}
|
||||
|
||||
/** The Signatory sheet is likewise identical across returns. */
|
||||
export function buildSignatorySheet(
|
||||
wb: ExcelJS.Workbook,
|
||||
signatories: ReturnSignatory[],
|
||||
sheetName = 'Signatory'
|
||||
): void {
|
||||
const sheet = wb.addWorksheet(sheetName);
|
||||
sheet.columns = [
|
||||
{ width: 44 },
|
||||
{ width: 24 }];
|
||||
addBanner(sheet, sheetName, 'B');
|
||||
|
||||
for (const person of signatories) {
|
||||
const start = sheet.rowCount + 1;
|
||||
const head = sheet.addRow([
|
||||
person.heading,
|
||||
''
|
||||
]);
|
||||
head.font = { bold: true };
|
||||
head.alignment = { horizontal: 'center' };
|
||||
head.getCell(1).fill = solidFill(XL.keyCell);
|
||||
|
||||
for (const f of SIGNATORY_FIELDS) {
|
||||
const row = sheet.addRow([
|
||||
person[f.key],
|
||||
f.caption
|
||||
]);
|
||||
row.getCell(2).font = { bold: true };
|
||||
row.getCell(2).alignment = { horizontal: 'center' };
|
||||
row.getCell(2).fill = solidFill(XL.keyCell);
|
||||
}
|
||||
boxAllCells(sheet, start, sheet.rowCount, 2);
|
||||
sheet.addRow([]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Serialises the workbook and triggers a browser download. */
|
||||
export async function downloadWorkbook(wb: ExcelJS.Workbook, filename: string): Promise<void> {
|
||||
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;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function newWorkbook(): ExcelJS.Workbook {
|
||||
const wb = new ExcelJS.Workbook();
|
||||
wb.creator = 'CBSNexora';
|
||||
wb.created = new Date();
|
||||
return wb;
|
||||
}
|
||||
@@ -0,0 +1,511 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
/**
|
||||
* OSS 1 — Return on Assets and Liabilities (RAL), return code R070.
|
||||
*
|
||||
* Mirrors the RBI CIMS "Form RAL_CIMS Template1 OSS 1" workbook sheet-for-sheet:
|
||||
* General Information / Assets & Liabilities / Annexure 1 / Annexure 2 / Signatory
|
||||
*
|
||||
* All monetary values are Rs in Thousands, exactly as the return prescribes.
|
||||
* A `null` value means the source return leaves that cell blank — blank and zero
|
||||
* are different to RBI, so they must not be collapsed into one another.
|
||||
*/
|
||||
|
||||
import { OSS_BANK, OSS_SIGNATORIES, ReturnGeneralInfoRow, ReturnLine, ReturnSignatory } from '../oss-return.model';
|
||||
|
||||
/** One labelled row of the return. */
|
||||
export type RalLine = ReturnLine;
|
||||
|
||||
export interface RalTopDeposit {
|
||||
srNo: number;
|
||||
institution: string;
|
||||
amount: number | null;
|
||||
rateOfInterest: number | null;
|
||||
dateOfReceipt: string;
|
||||
days: number | null;
|
||||
months: number | null;
|
||||
years: number | null;
|
||||
}
|
||||
|
||||
export interface RalAgeingRow {
|
||||
period: string;
|
||||
noOfDebitEntries: number | null;
|
||||
amtOfDebitEntries: number | null;
|
||||
noOfCreditEntries: number | null;
|
||||
amtOfCreditEntries: number | null;
|
||||
}
|
||||
|
||||
export interface RalUnsecuredGuarantee {
|
||||
srNo: number;
|
||||
borrower: string;
|
||||
beneficiary: string;
|
||||
amountOutstanding: number | null;
|
||||
}
|
||||
|
||||
export interface RalSundryDebtor {
|
||||
srNo: number;
|
||||
dueFromName: string;
|
||||
amountDue: number | null;
|
||||
provisionMade: number | null;
|
||||
}
|
||||
|
||||
export type RalSignatory = ReturnSignatory;
|
||||
|
||||
/**
|
||||
* Sheet 1 — General Information.
|
||||
* Return Name / Return Code are fixed by RBI; everything else is this bank's own
|
||||
* identity and the quarter being reported.
|
||||
*/
|
||||
export const RAL_GENERAL_INFO: ReturnGeneralInfoRow[] = [
|
||||
{ label: 'Return Name', value: 'Return on Assets and Liabilities (RAL)' },
|
||||
{ label: 'Return Code', value: 'R070' },
|
||||
{ label: 'Bank Name', value: OSS_BANK.name },
|
||||
{ label: 'Bank Code', value: OSS_BANK.bankCode },
|
||||
{ label: 'RO Name', value: OSS_BANK.roName },
|
||||
{ label: 'Reporting Frequency', value: OSS_BANK.reportingFrequency },
|
||||
{ label: 'Quarter End Date', value: OSS_BANK.quarterEndDate },
|
||||
{ label: 'Audit Flag', value: OSS_BANK.auditFlag },
|
||||
{ label: 'Date of Audit', value: OSS_BANK.dateOfAudit },
|
||||
{ label: 'Return Version', value: OSS_BANK.returnVersion },
|
||||
{ label: 'Start Date', value: OSS_BANK.startDate }
|
||||
];
|
||||
|
||||
/** Sheet 2 — Assets & Liabilities :: Liabilities */
|
||||
export const RAL_LIABILITIES: RalLine[] = [
|
||||
{ label: '1. Paid-up Capital', level: 0, value: 27278, total: true },
|
||||
{ label: 'i. Individuals', level: 1, value: 27278 },
|
||||
{ label: 'ii. State Government', level: 1, value: null },
|
||||
{ label: 'iii. Others', level: 1, value: null },
|
||||
|
||||
{ label: '2. Reserve Fund and other Reserves', level: 0, value: 60863, total: true },
|
||||
{ label: 'i. Statutory Reserve Fund', level: 1, value: 31149 },
|
||||
{ label: 'ii. Building Fund', level: 1, value: 13642 },
|
||||
{ label: 'iii. Dividend Equalization Fund', level: 1, value: 36 },
|
||||
{ label: 'iv. Revaluation Reserve', level: 1, value: null },
|
||||
{ label: 'v. Provision for Standard Assets', level: 1, value: 3750 },
|
||||
{ label: 'vi. General Provisions (other than provision for standard advances)', level: 1, value: 282 },
|
||||
{
|
||||
label: 'vii. Other Funds and Reserves not in the nature of outside liability-(Refer Annexure I)',
|
||||
level: 1,
|
||||
value: 12004
|
||||
},
|
||||
|
||||
{ label: '3. Profit (+) / Loss (-)', level: 0, value: 10397, total: true },
|
||||
{ label: 'i. Surplus Unallocated and Carried over from Previous years', level: 1, value: 5592 },
|
||||
{ label: 'ii. Accumulated Losses of Previous years', level: 1, value: null },
|
||||
{ label: 'iii. Operating Surplus in Current year', level: 1, value: 4805 },
|
||||
{ label: 'iv. Operating Deficit in Current year', level: 1, value: null },
|
||||
|
||||
{ label: '4. Total Capital And Reserves', level: 0, value: 98538, total: true },
|
||||
{ label: '5. Subordinated Debt (Tier II Capital) #', level: 0, value: null },
|
||||
|
||||
{ label: '6. Deposits and other Accounts', level: 0, value: 634309, total: true },
|
||||
{ label: 'Customer Deposits -', level: 1, value: 634309, total: true },
|
||||
{ label: 'Total No of Depositors', level: 1, value: 8156, total: true, count: true },
|
||||
{ label: 'A. Resident Deposits', level: 1, value: 634309, total: true },
|
||||
{
|
||||
label:
|
||||
'i. Term Deposits (including recurring deposits, cash certificates, staff provident funds,security deposits and matured term deposits)',
|
||||
level: 2,
|
||||
value: 406601
|
||||
},
|
||||
{ label: 'No. of Term Depositors', level: 2, value: 3462, count: true },
|
||||
{ label: 'ii. Savings Bank Deposits', level: 2, value: 208116 },
|
||||
{ label: 'No of Savings Depositors', level: 2, value: 4455, count: true },
|
||||
{ label: 'iii. Current Deposits', level: 2, value: 19592 },
|
||||
{ label: 'No of Current Depositors', level: 2, value: 239, count: true },
|
||||
{ label: 'B. Non Resident Deposits', level: 1, value: 0, total: true },
|
||||
{ label: '1. NRI Deposits (Other than deposits in NRO Accounts)', level: 2, value: 0, total: true },
|
||||
{ label: 'i. FCNR Deposits', level: 2, value: null },
|
||||
{ label: 'No of FCNR Depositors', level: 2, value: null, count: true },
|
||||
{ label: 'ii. NRE Deposits', level: 2, value: null },
|
||||
{ label: 'No of NRE Depositors', level: 2, value: null, count: true },
|
||||
{ label: '2. NRO Deposits', level: 2, value: null, total: true },
|
||||
{ label: 'No of NRO Depositors', level: 2, value: null, count: true },
|
||||
{ label: 'C. Deposit of Banks', level: 1, value: 0, total: true },
|
||||
{ label: 'i. Commercial Banks', level: 2, value: null },
|
||||
{ label: 'ii. Scheduled Cooperative Banks', level: 2, value: null },
|
||||
{ label: 'iii. Other Cooperative Banks', level: 2, value: null },
|
||||
|
||||
{ label: '7. Borrowings from', level: 0, value: 0, total: true },
|
||||
{ label: 'i. RBI / NABARD / SIDBI / NHB', level: 1, value: null },
|
||||
{ label: 'ii. State/Central Coop. Bank of the State/ District', level: 1, value: 0, total: true },
|
||||
{ label: 'a. Short-term loans', level: 2, value: null },
|
||||
{ label: 'b. Medium-term loans', level: 2, value: null },
|
||||
{ label: 'c. Cash credits and overdrafts', level: 2, value: null },
|
||||
{ label: 'iii. Other banks (exclude borrowings from call money market)', level: 1, value: null },
|
||||
{ label: 'iv. Call money market', level: 1, value: null },
|
||||
{ label: 'v. Long Term Deposits', level: 1, value: null },
|
||||
{ label: 'vi. Others', level: 1, value: null },
|
||||
|
||||
{ label: '8. Bills for collection being bills receivable as per contra', level: 0, value: null },
|
||||
{ label: '9. Branch Adjustments *', level: 0, value: null },
|
||||
{ label: '10. Overdue Interest Reserve', level: 0, value: 32096 },
|
||||
|
||||
{ label: '11. Interest Payable on', level: 0, value: 731, total: true },
|
||||
{ label: 'i. Deposits', level: 1, value: 731 },
|
||||
{ label: 'ii. Borrowings', level: 1, value: null },
|
||||
|
||||
{
|
||||
label: '12. Risk Provisions Other than netted off assets (BDDR, Spl BDDR, Depreciation on fixed assets etc.,)',
|
||||
level: 0,
|
||||
value: 39440,
|
||||
total: true
|
||||
},
|
||||
{ label: 'i. For investments (Investment Depreciation Reserve)', level: 1, value: 3750 },
|
||||
{ label: 'ii. BDDR, Spl BDDR', level: 1, value: 24341 },
|
||||
{ label: 'iii. Depreciation on fixed assets', level: 1, value: null },
|
||||
{ label: 'iv. For other impaired assets', level: 1, value: 11349 },
|
||||
{ label: 'v. For contingent / non-funded exposures', level: 1, value: null },
|
||||
|
||||
{ label: '13. Other Liabilities', level: 0, value: 5806, total: true },
|
||||
{ label: 'i. Bills payable', level: 1, value: 291 },
|
||||
{ label: 'ii. Unclaimed dividends', level: 1, value: null },
|
||||
{ label: 'iii.Provident fund payable to Regional Provident Fund Commissioner', level: 1, value: null },
|
||||
{ label: 'iv.Others', level: 1, value: 5515 },
|
||||
|
||||
{ label: '14. Total Outside Liabilities', level: 0, value: 712382, total: true },
|
||||
{ label: '15. Total Liabilities', level: 0, value: 810920, total: true },
|
||||
|
||||
{ label: '16. Contingent Liabilities', level: 0, value: 2638, total: true },
|
||||
{ label: 'i. Claims against the bank not acknowledged as debt', level: 1, value: null },
|
||||
{ label: 'ii. Liabilities for partly paid investments', level: 1, value: null },
|
||||
{ label: 'iii. Letter of Credit Documentary', level: 1, value: null },
|
||||
{ label: 'iv. Guarantees - Financial', level: 1, value: null },
|
||||
{ label: 'v. Guarantees- Others (Indemnity/ Tender/ Bid Bonds)', level: 1, value: null },
|
||||
{ label: 'vi. Sale and Repurchase Agreement /Asset sales with recourse', level: 1, value: null },
|
||||
{ label: 'vii. Liability on account of outstanding forward exchange contracts', level: 1, value: null },
|
||||
{ label: 'viii. Acceptances, endorsements and other obligations', level: 1, value: null },
|
||||
{ label: 'ix. Others', level: 1, value: 2638 }
|
||||
];
|
||||
|
||||
/** Sheet 2 — Assets & Liabilities :: Assets */
|
||||
export const RAL_ASSETS: RalLine[] = [
|
||||
{ label: '1. Cash in Hand', level: 0, value: 9996 },
|
||||
|
||||
{ label: '2. I. Due from Banks', level: 0, 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: 'a. Current', level: 2, value: 5980 },
|
||||
{ label: 'b. Savings', level: 2, value: null },
|
||||
{ label: 'c. Fixed', level: 2, value: 26288 },
|
||||
{ label: 'ii. RBI :', level: 2, value: 0, total: true },
|
||||
{ label: 'a. Current', level: 2, value: null },
|
||||
{ label: 'b. Others', level: 2, value: null },
|
||||
{ label: 'iii. SBI and other notified banks :', level: 2, value: 25332, total: true },
|
||||
{ label: 'a. Current', level: 2, value: 13667 },
|
||||
{ label: 'b. Savings', level: 2, value: null },
|
||||
{ label: 'c. Fixed', level: 2, value: 11665 },
|
||||
{ label: 'B. Loans and Advances to Banks (including bills)', level: 1, value: 0, total: true },
|
||||
{ label: 'i. Call and Short Notice Money to Cooperative Banks', level: 2, value: null },
|
||||
{ label: 'ii. Call and Short Notice Money to Other Banks', level: 2, value: null },
|
||||
{ label: 'iii. Loans and Advances to Scheduled Cooperative Banks', level: 2, value: null },
|
||||
{ label: 'iv.Loans and advances to Non scheduled Cooperative Banks', level: 2, value: null },
|
||||
{ label: 'v. Loans and Advances to Other Banks', level: 2, value: null },
|
||||
|
||||
{
|
||||
label: '2. II. Due from Other institutions (including post office / treasuries / others)',
|
||||
level: 0,
|
||||
value: 77353,
|
||||
total: true
|
||||
},
|
||||
{ label: 'a. Current', level: 1, value: 9954 },
|
||||
{ label: 'b. Fixed', level: 1, value: 67399 },
|
||||
{ label: 'c. Others', level: 1, value: null },
|
||||
|
||||
{ label: '3. Investments', level: 0, value: 234297, total: true },
|
||||
{
|
||||
label: 'A. SLR Investments in Government and Other Approved Central and State Govt Securities',
|
||||
level: 1,
|
||||
value: 211734,
|
||||
total: true
|
||||
},
|
||||
{ label: 'i. GOI T. Bills', level: 2, value: null },
|
||||
{ label: 'ii. GOI (dated) Securities', level: 2, value: 63839 },
|
||||
{ label: 'iii. State Government Securities', level: 2, value: 147895 },
|
||||
{
|
||||
label: 'iv. Other Trustee/approved securities including Land Development Bank debentures',
|
||||
level: 2,
|
||||
value: null
|
||||
},
|
||||
{ label: 'B. Non - SLR Investments', level: 1, value: 22563, total: true },
|
||||
{ label: 'B1. Other Debt Securities', level: 2, value: 0, total: true },
|
||||
{ label: 'i. Other State Government Securities', level: 2, value: null },
|
||||
{ label: 'ii. Bonds of PSUs / All India Financial Institutions', level: 2, value: null },
|
||||
{ label: 'B2. Equities / Units of Mutual Funds', level: 2, value: 63, total: true },
|
||||
{ label: 'i. Shares in Cooperative Institutions', level: 2, value: 63 },
|
||||
{ label: 'ii. Shares of All India Financial Institutions', level: 2, value: null },
|
||||
{ label: 'iii. Units of Mutual Funds (UTI,etc)', level: 2, value: null },
|
||||
{ label: 'B3. Investment in Subsidiaries / Equity Participations', level: 2, value: null },
|
||||
{ label: 'B4. Other Investments', level: 2, value: 22500 },
|
||||
|
||||
{ label: '4. Loans and Advances', level: 0, value: 391288, total: true },
|
||||
{ label: 'A. Gross Loans and Advances: (Other than banks)', level: 1, value: 391288, total: true },
|
||||
{ label: 'Total No of Borrowers', level: 1, value: 363, total: true, count: true },
|
||||
{ label: 'i. Short term loans', level: 2, value: 125082, total: true },
|
||||
{ label: 'No of Borrowers', level: 2, value: 137, count: true },
|
||||
{ label: 'a. Secured', level: 2, value: 125082 },
|
||||
{ label: 'b. Unsecured', level: 2, value: null },
|
||||
{ label: 'ii. Cash credits and overdrafts', level: 2, value: 166614, total: true },
|
||||
{ label: 'No of Borrowers', level: 2, value: 140, count: true },
|
||||
{ label: 'a. Secured', level: 2, value: 166614 },
|
||||
{ label: 'b. Unsecured', level: 2, value: null },
|
||||
{ label: 'iii. Medium - term loans', level: 2, value: 70236, total: true },
|
||||
{ label: 'No of Borrowers', level: 2, value: 66, count: true },
|
||||
{ label: 'a. Secured', level: 2, value: 70070 },
|
||||
{ label: 'b. Unsecured', level: 2, value: 166 },
|
||||
{ label: 'iv. Long - term loans', level: 2, value: 29356, total: true },
|
||||
{ label: 'No of Borrowers', level: 2, value: 20, count: true },
|
||||
{ label: 'a. Secured', level: 2, value: 29356 },
|
||||
{ label: 'b. Unsecured', level: 2, value: null },
|
||||
{ label: 'v. Bills/hundies purchased/discounted', level: 2, value: 0, total: true },
|
||||
{ label: 'No of Borrowers', level: 2, value: null, count: true },
|
||||
{ label: 'a. Documentary', level: 2, value: null },
|
||||
{ label: 'b. Clean', level: 2, value: null },
|
||||
{ label: 'B. Netting items on Loans and Advances', level: 1, value: 0, total: true },
|
||||
{ label: 'i. Unrealised Interest in Suspense', level: 2, value: null },
|
||||
{ label: 'ii. Credit Recoveries in Suspense', level: 2, value: null },
|
||||
{ label: 'iii. Provisions for Credit Losses ( funded credits)', level: 2, value: null },
|
||||
{ label: 'C. Loans and Advances (Net)', level: 1, value: 391288, total: true },
|
||||
|
||||
{ label: '5. Interest Receivable on', level: 0, value: 33541, total: true },
|
||||
{ label: 'i. Investments', level: 1, value: 1445 },
|
||||
{ label: 'ii.Loans and Advances', level: 1, value: 32096 },
|
||||
{ label: 'Of 5 (ii) above, overdues', level: 1, value: 32096 },
|
||||
|
||||
{ label: '6. Bills Receivable being bills for collection as per contra', level: 0, value: null },
|
||||
{ label: '7. Premises (less depreciation)', level: 0, value: 3185 },
|
||||
{ label: '8. Furniture and Fixtures (less depreciation)', level: 0, value: 1763 },
|
||||
{ label: '9. Branch Adjustments **', level: 0, value: null },
|
||||
|
||||
{ label: '10. Other Assets', level: 0, value: 1897, total: true },
|
||||
{ label: 'i. Advance Tax Paid and TDS (net)', level: 1, value: null },
|
||||
{ label: 'ii. Amounts due from RBI', level: 1, value: null },
|
||||
{ label: 'iii. Sundry Debtors', level: 1, value: null },
|
||||
{ label: 'iv. Intangible Assets', level: 1, value: null },
|
||||
{ label: 'v. All Other', level: 1, value: 1897 },
|
||||
|
||||
{ label: '11. Non-banking Assets acquired in satisfaction of claims', level: 0, value: null },
|
||||
{ label: '12. Total Assets (1 to 11)', level: 0, value: 810920, total: true }
|
||||
];
|
||||
|
||||
/** Sheet 2 — Memo block */
|
||||
export const RAL_MEMO: RalLine[] = [
|
||||
{ label: '1.Capital Infusion during Apr to Date', level: 0, value: 0, total: true },
|
||||
{ label: '1.1 Tier 1 Capital Raised during April to Date', level: 1, value: null },
|
||||
{ label: '1.2 Tier 2 Capital Raised during April to Date', level: 1, value: null },
|
||||
{ label: '2. Capital Instruments due for redemption in next twelve months', level: 0, value: null }
|
||||
];
|
||||
|
||||
export const RAL_BALANCE_SHEET_NOTES: string[] = [
|
||||
'Note : * To be furnished as per the books of the bank.',
|
||||
'Note : ** Branch adjustments: Net position to be shown as an item of liability / assets, as the case may be.',
|
||||
'Note: # Capital raised by way of RCPS / RNCPS / PNCPS to be reported under this item'
|
||||
];
|
||||
|
||||
/** Sheet 3 — Annexure 1 :: 1. Break-up of Other Funds and Reserves */
|
||||
export const RAL_OTHER_FUNDS: RalLine[] = [
|
||||
{ label: 'i. Gratuity Fund', level: 1, value: null },
|
||||
{ label: 'ii. Staff Welfare Fund', level: 1, value: 1310 },
|
||||
{ label: 'iii. Silver Jubilee Fund', level: 1, value: null },
|
||||
{ label: 'iv. Development Fund', level: 1, value: 36 },
|
||||
{ label: 'v. Charity Fund', level: 1, value: 1718 },
|
||||
{ label: 'vi. Any Other Fund/ Disclosed reserves', level: 1, value: 8940 },
|
||||
{
|
||||
label:
|
||||
'vii. Undisclosed Reserves created out of PAT and included in B/S under heads like other liabilities and provisions',
|
||||
level: 1,
|
||||
value: null
|
||||
},
|
||||
{ label: 'Total', level: 0, value: 12004, total: true }
|
||||
];
|
||||
|
||||
/** Sheet 3 — Annexure 1 :: 2. Top 20 deposits from other banks / institutions */
|
||||
export const RAL_TOP_20_DEPOSITS: RalTopDeposit[] = Array.from(
|
||||
{ length: 20 },
|
||||
(_, i): RalTopDeposit => ({
|
||||
srNo: i + 1,
|
||||
institution: '',
|
||||
amount: null,
|
||||
rateOfInterest: null,
|
||||
dateOfReceipt: '',
|
||||
days: null,
|
||||
months: null,
|
||||
years: null
|
||||
})
|
||||
);
|
||||
|
||||
/** Column captions shared by the two age-wise analyses in Annexure 1. */
|
||||
export const RAL_AGEING_HEADERS: string[] = [
|
||||
'Time period',
|
||||
'No. of debit entries',
|
||||
'Amt. of debit entries',
|
||||
'No. of credit entries',
|
||||
'Amt. of credit entries'
|
||||
];
|
||||
|
||||
/** Sheet 3 — Annexure 1 :: 3. Age-wise Inter Branch Adjustment Account */
|
||||
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: '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 }
|
||||
];
|
||||
|
||||
/** Sheet 3 — Annexure 1 :: 4. Age-wise Inter Bank Reconciliation Account */
|
||||
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: '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 }
|
||||
];
|
||||
|
||||
/** Sheet 3 — Annexure 1 :: 5. Unsecured guarantees issued by the bank */
|
||||
export const RAL_UNSECURED_GUARANTEES: RalUnsecuredGuarantee[] = [
|
||||
{ srNo: 1, borrower: '', beneficiary: '', amountOutstanding: null }];
|
||||
export const RAL_UNSECURED_GUARANTEES_TOTAL = 0;
|
||||
|
||||
/** Sheet 4 — Annexure 2 :: Sundry Debtors ageing buckets */
|
||||
export const RAL_SUNDRY_DEBTORS: { title: string; subtitle: string; rows: RalSundryDebtor[] }[] = [
|
||||
{
|
||||
title: 'Sundry Debtors Account -1',
|
||||
subtitle: 'Long Outstanding Large Amounts - Outstanding for over 6 months but less than 1 year*',
|
||||
rows: [{ srNo: 1, dueFromName: '', amountDue: null, provisionMade: null }]
|
||||
},
|
||||
{
|
||||
title: 'Sundry Debtors Account -2',
|
||||
subtitle: 'Long Outstanding Large Amounts - Outstanding for over 1 year but less than 3 years*',
|
||||
rows: [{ srNo: 1, dueFromName: '', amountDue: null, provisionMade: null }]
|
||||
},
|
||||
{
|
||||
title: 'Sundry Debtors Account -3',
|
||||
subtitle: 'Long Outstanding Large Amounts - Outstanding for over 3 years*',
|
||||
rows: [{ srNo: 1, dueFromName: '', amountDue: null, provisionMade: null }]
|
||||
}
|
||||
];
|
||||
|
||||
export const RAL_SUNDRY_DEBTORS_NOTE =
|
||||
'* Large Amounts: Rs. 2 lakhs for banks having total assets of Rs. 100 lakhs and over Rs. 50,000 for other banks';
|
||||
|
||||
/** Sheet 4 — Annexure 2 :: Details of Depositors & Borrowers */
|
||||
export const RAL_DEPOSITORS_BORROWERS: RalLine[] = [
|
||||
{ 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: '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: '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: '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: '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: '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: '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: '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 }
|
||||
];
|
||||
|
||||
/** Sheet 4 — Annexure 2 :: Complaints Received from public */
|
||||
export const RAL_COMPLAINTS: RalLine[] = [
|
||||
{ label: 'a) No. Of complaints outstanding at the beginning of the quarter', level: 1, value: 0, count: true },
|
||||
{ label: 'b) No. Of complaints received during the quarter', level: 1, value: 0, count: true },
|
||||
{ label: 'c) No. of complaints addressed during the quarter', level: 1, value: 0, count: true },
|
||||
{ label: 'd) No. of complaints outstanding at the end of the quarter', level: 1, value: 0, count: true },
|
||||
{ label: 'e) No. of complaints outstanding for over 2 quarters', level: 1, value: 0, count: true }
|
||||
];
|
||||
|
||||
/** Sheet 5 — Signatory. Shared across returns so OSS 1 and OSS 2 always agree. */
|
||||
export const RAL_SIGNATORIES: RalSignatory[] = OSS_SIGNATORIES;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user