Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4281a596af | ||
|
|
9f9c9bfab6 | ||
|
|
513e863bba | ||
|
|
13333088d7 | ||
|
|
89bb76455e | ||
|
|
7e69468995 | ||
|
|
63ab9643ce | ||
|
|
8255c8398d | ||
|
|
14c5f5070d | ||
|
|
fcf6665772 | ||
|
|
f72abb0921 | ||
|
|
a1a50ab8a3 | ||
|
|
858c277ae3 | ||
|
|
a5124ca3e8 | ||
|
|
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 | ||
|
|
43e3c546a7 | ||
|
|
b68b25f789 | ||
|
|
1dc378c2dd |
+1
-1
@@ -70,7 +70,7 @@
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "15mb",
|
||||
"maximumError": "16mb"
|
||||
"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);
|
||||
}
|
||||
@@ -211,6 +211,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||||
|
||||
|
||||
+99
-79
@@ -1,90 +1,110 @@
|
||||
<!--
|
||||
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="fm-dialog">
|
||||
<div class="fm-dialog__header" style="display: flex; align-items: center; gap: 8px;">
|
||||
<div class="fm-dialog__header" style="display: flex; align-items: center; gap: 8px">
|
||||
<mat-icon class="fm-dialog__icon">people</mat-icon>
|
||||
<span style="font-weight: 500; font-size: 18px;">CHILDREN</span>
|
||||
<span style="font-weight: 500; font-size: 18px">CHILDREN</span>
|
||||
</div>
|
||||
|
||||
<mat-dialog-content>
|
||||
<form [formGroup]="childrenForm">
|
||||
<div formArrayName="children" style="margin-top: 12px; background: #fff; padding: 12px;">
|
||||
<div formArrayName="children" style="margin-top: 12px; background: #fff; padding: 12px">
|
||||
@for (child of children.controls; track $index) {
|
||||
<div [formGroupName]="$index"
|
||||
style="position: relative; padding: 24px 16px 16px; margin-top: 16px; margin-bottom: 24px; border: 1px solid #e0e0e0; border-radius: 8px; background: #fff;">
|
||||
<div class="fm-grid">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Child Full Name*</mat-label>
|
||||
<input matInput formControlName="fullName" required placeholder="Child Full Name*" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Gender*</mat-label>
|
||||
<mat-select formControlName="genderId" required>
|
||||
@for (g of genderIdOptions; track g.id) {
|
||||
<mat-option [value]="g.id">{{ g.name }}</mat-option>
|
||||
<div
|
||||
[formGroupName]="$index"
|
||||
style="
|
||||
position: relative;
|
||||
padding: 24px 16px 16px;
|
||||
margin-top: 16px;
|
||||
margin-bottom: 24px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
"
|
||||
>
|
||||
<div class="fm-grid">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Child Full Name*</mat-label>
|
||||
<input matInput formControlName="fullName" required placeholder="Child Full Name*" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Gender*</mat-label>
|
||||
<mat-select formControlName="genderId" required>
|
||||
@for (g of genderIdOptions; track g.id) {
|
||||
<mat-option [value]="g.id">{{ g.name }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" (click)="childDobPicker.open()">
|
||||
<mat-label>Date of Birth</mat-label>
|
||||
<input matInput [max]="maxDate" [matDatepicker]="childDobPicker" formControlName="dateOfBirth" />
|
||||
<mat-datepicker-toggle matSuffix [for]="childDobPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #childDobPicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Nationality</mat-label>
|
||||
<mat-select formControlName="nationality">
|
||||
<mat-option value="INDIAN">Indian</mat-option>
|
||||
<mat-option value="NRI">NRI</mat-option>
|
||||
<mat-option value="FOREIGN_NATIONAL">Foreign National</mat-option>
|
||||
<mat-option value="OTHER">Other</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Aadhaar Number (Optional)</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="aadhaarNumber"
|
||||
maxlength="12"
|
||||
placeholder="12-digit Aadhaar"
|
||||
(keypress)="$event.charCode >= 48 && $event.charCode <= 57 ? true : $event.preventDefault()"
|
||||
/>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>PAN Number (Optional)</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="panNumber"
|
||||
maxlength="10"
|
||||
placeholder="ABCDE1234F"
|
||||
style="text-transform: uppercase"
|
||||
(input)="
|
||||
$any(children.at($index))
|
||||
.get('panNumber')
|
||||
?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })
|
||||
"
|
||||
/>
|
||||
@if (
|
||||
$any(children.at($index)).get('panNumber')?.hasError('pattern') &&
|
||||
$any(children.at($index)).get('panNumber')?.touched
|
||||
) {
|
||||
<mat-error>Invalid PAN format</mat-error>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" (click)="childDobPicker.open()">
|
||||
<mat-label>Date of Birth</mat-label>
|
||||
<input matInput [max]="maxDate" [matDatepicker]="childDobPicker" formControlName="dateOfBirth" />
|
||||
<mat-datepicker-toggle matSuffix [for]="childDobPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #childDobPicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Nationality</mat-label>
|
||||
<mat-select formControlName="nationality">
|
||||
<mat-option value="INDIAN">Indian</mat-option>
|
||||
<mat-option value="NRI">NRI</mat-option>
|
||||
<mat-option value="FOREIGN_NATIONAL">Foreign National</mat-option>
|
||||
<mat-option value="OTHER">Other</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Aadhaar Number (Optional)</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="aadhaarNumber"
|
||||
maxlength="12"
|
||||
placeholder="12-digit Aadhaar"
|
||||
(keypress)="$event.charCode >= 48 && $event.charCode <= 57 ? true : $event.preventDefault()"
|
||||
/>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>PAN Number (Optional)</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="panNumber"
|
||||
maxlength="10"
|
||||
placeholder="ABCDE1234F"
|
||||
style="text-transform: uppercase"
|
||||
(input)="
|
||||
$any(children.at($index)).get('panNumber')
|
||||
?.setValue($any($event.target).value.toUpperCase(), { emitEvent: false })
|
||||
"
|
||||
/>
|
||||
@if (
|
||||
$any(children.at($index)).get('panNumber')?.hasError('pattern') && $any(children.at($index)).get('panNumber')?.touched
|
||||
) {
|
||||
<mat-error>Invalid PAN format</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@if ($index < children.controls.length - 1) { <div style="border-bottom: 1px dashed #ccc; margin-bottom: 16px;">
|
||||
@if ($index < children.controls.length - 1) {
|
||||
<div style="border-bottom: 1px dashed #ccc; margin-bottom: 16px"></div>
|
||||
}
|
||||
}
|
||||
<div style="border-bottom: 1px dashed #ccc; margin-bottom: 16px"></div>
|
||||
<button mat-stroked-button type="button" color="primary" (click)="addChild()">
|
||||
<mat-icon>add</mat-icon> Add Another Child
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
<div style="border-bottom: 1px dashed #ccc; margin-bottom: 16px;"></div>
|
||||
<button mat-stroked-button type="button" color="primary" (click)="addChild()">
|
||||
<mat-icon>add</mat-icon> Add Another Child
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</mat-dialog-content>
|
||||
</form>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-stroked-button mat-dialog-close type="button">Cancel</button>
|
||||
<button mat-flat-button color="primary" type="button" [disabled]="childrenForm.invalid" (click)="submit()">
|
||||
Add Member
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
</div>
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-stroked-button mat-dialog-close type="button">Cancel</button>
|
||||
<button mat-flat-button color="primary" type="button" [disabled]="childrenForm.invalid" (click)="submit()">
|
||||
Add Member
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
</div>
|
||||
|
||||
+48
-22
@@ -1,4 +1,12 @@
|
||||
import { Component, Inject, OnInit } from '@angular/core';
|
||||
/**
|
||||
* 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 { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { UntypedFormBuilder, UntypedFormGroup, UntypedFormArray, Validators } from '@angular/forms';
|
||||
|
||||
@@ -13,7 +21,7 @@ import { ReactiveFormsModule } from '@angular/forms';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'cbs-client-family-member-child-dialog',
|
||||
selector: 'mifosx-client-family-member-child-dialog',
|
||||
templateUrl: './client-family-member-child-dialog.component.html',
|
||||
styleUrls: ['../client-family-member-dialog/client-family-member-dialog.component.scss'],
|
||||
standalone: true,
|
||||
@@ -30,18 +38,17 @@ import { CommonModule } from '@angular/common';
|
||||
]
|
||||
})
|
||||
export class ClientFamilyMemberChildDialogComponent implements OnInit {
|
||||
dialogRef = inject(MatDialogRef<ClientFamilyMemberChildDialogComponent>);
|
||||
data = inject(MAT_DIALOG_DATA);
|
||||
private formBuilder = inject(UntypedFormBuilder);
|
||||
|
||||
childrenForm: UntypedFormGroup;
|
||||
genderIdOptions: any[];
|
||||
maxDate: Date = new Date();
|
||||
|
||||
constructor(
|
||||
public dialogRef: MatDialogRef<ClientFamilyMemberChildDialogComponent>,
|
||||
@Inject(MAT_DIALOG_DATA) public data: any,
|
||||
private formBuilder: UntypedFormBuilder
|
||||
) {
|
||||
this.genderIdOptions = data.genderIdOptions;
|
||||
|
||||
constructor() {
|
||||
this.genderIdOptions = this.data.genderIdOptions;
|
||||
|
||||
this.childrenForm = this.formBuilder.group({
|
||||
children: this.formBuilder.array([])
|
||||
});
|
||||
@@ -50,15 +57,26 @@ export class ClientFamilyMemberChildDialogComponent implements OnInit {
|
||||
ngOnInit() {
|
||||
if (this.data.children && this.data.children.length > 0) {
|
||||
this.data.children.forEach((child: any) => {
|
||||
this.children.push(this.formBuilder.group({
|
||||
id: [child.id],
|
||||
fullName: [child.fullName, Validators.required],
|
||||
genderId: [child.genderId, Validators.required],
|
||||
dateOfBirth: [child.dateOfBirth ? new Date(child.dateOfBirth) : ''],
|
||||
nationality: [child.nationality || 'INDIAN'],
|
||||
aadhaarNumber: [child.aadhaarNumber || ''],
|
||||
panNumber: [child.panNumber || '', Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)]
|
||||
}));
|
||||
this.children.push(
|
||||
this.formBuilder.group({
|
||||
id: [child.id],
|
||||
fullName: [
|
||||
child.fullName,
|
||||
Validators.required
|
||||
],
|
||||
genderId: [
|
||||
child.genderId,
|
||||
Validators.required
|
||||
],
|
||||
dateOfBirth: [child.dateOfBirth ? new Date(child.dateOfBirth) : ''],
|
||||
nationality: [child.nationality || 'INDIAN'],
|
||||
aadhaarNumber: [child.aadhaarNumber || ''],
|
||||
panNumber: [
|
||||
child.panNumber || '',
|
||||
Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)
|
||||
]
|
||||
})
|
||||
);
|
||||
});
|
||||
} else {
|
||||
this.addChild();
|
||||
@@ -72,12 +90,21 @@ export class ClientFamilyMemberChildDialogComponent implements OnInit {
|
||||
createChildFormGroup(): UntypedFormGroup {
|
||||
return this.formBuilder.group({
|
||||
id: [''],
|
||||
fullName: ['', Validators.required],
|
||||
genderId: ['', Validators.required],
|
||||
fullName: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
genderId: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
dateOfBirth: [''],
|
||||
nationality: ['INDIAN'],
|
||||
aadhaarNumber: [''],
|
||||
panNumber: ['', Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)]
|
||||
panNumber: [
|
||||
'',
|
||||
Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
@@ -94,5 +121,4 @@ export class ClientFamilyMemberChildDialogComponent implements OnInit {
|
||||
this.dialogRef.close(this.children.value);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+10
-4
@@ -144,7 +144,7 @@
|
||||
<span class="fm-readonly-label">Children</span>
|
||||
<div class="fm-children-list">
|
||||
@for (child of familyMemberForm.get('children')?.value; track $index) {
|
||||
<div class="fm-child-item" style="font-size: 13px; color: #555; margin-top: 4px;">
|
||||
<div class="fm-child-item" style="font-size: 13px; color: #555; margin-top: 4px">
|
||||
<span>{{ child.fullName }}</span>
|
||||
@if (child.dateOfBirth) {
|
||||
<span> · DOB: {{ child.dateOfBirth | date: 'dd MMM yyyy' }}</span>
|
||||
@@ -465,11 +465,17 @@
|
||||
|
||||
<!-- Children Section -->
|
||||
@if (isSpouse) {
|
||||
<div class="fm-section-label" style="margin-top: 16px;">Children</div>
|
||||
<div class="fm-checkbox-row" style="display: flex; align-items: center; justify-content: space-between;">
|
||||
<div class="fm-section-label" style="margin-top: 16px">Children</div>
|
||||
<div class="fm-checkbox-row" style="display: flex; align-items: center; justify-content: space-between">
|
||||
<mat-checkbox formControlName="hasChildren">Does this family member have a child/children?</mat-checkbox>
|
||||
@if (familyMemberForm.get('hasChildren')?.value) {
|
||||
<button mat-stroked-button type="button" color="primary" (click)="openChildrenDialog()" style="margin-left: 16px;">
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
color="primary"
|
||||
(click)="openChildrenDialog()"
|
||||
style="margin-left: 16px"
|
||||
>
|
||||
<mat-icon>edit</mat-icon> Edit Children Details ({{ children.length }})
|
||||
</button>
|
||||
}
|
||||
|
||||
+54
-26
@@ -58,7 +58,7 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
|
||||
private dialog = inject(MatDialog);
|
||||
|
||||
maxDate = new Date();
|
||||
|
||||
|
||||
hasChildrenChecked = false;
|
||||
familyMemberForm: UntypedFormGroup;
|
||||
|
||||
@@ -206,12 +206,21 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
|
||||
this.children.push(
|
||||
this.formBuilder.group({
|
||||
id: [child.id],
|
||||
fullName: [child.fullName, Validators.required],
|
||||
genderId: [child.genderId, Validators.required],
|
||||
fullName: [
|
||||
child.fullName,
|
||||
Validators.required
|
||||
],
|
||||
genderId: [
|
||||
child.genderId,
|
||||
Validators.required
|
||||
],
|
||||
dateOfBirth: [child.dateOfBirth ? new Date(child.dateOfBirth) : ''],
|
||||
nationality: [child.nationality || 'INDIAN'],
|
||||
aadhaarNumber: [child.aadhaarNumber || ''],
|
||||
panNumber: [child.panNumber || '', Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)]
|
||||
panNumber: [
|
||||
child.panNumber || '',
|
||||
Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)
|
||||
]
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -249,7 +258,7 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
|
||||
children: this.formBuilder.array([])
|
||||
});
|
||||
|
||||
this.familyMemberForm.get('hasChildren')?.valueChanges.subscribe(value => {
|
||||
this.familyMemberForm.get('hasChildren')?.valueChanges.subscribe((value) => {
|
||||
if (value && !this.hasChildrenChecked) {
|
||||
this.openChildrenDialog();
|
||||
} else if (!value) {
|
||||
@@ -264,9 +273,10 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
|
||||
}
|
||||
|
||||
async openChildrenDialog() {
|
||||
// dynamically import the dialog to avoid circular dependency issues if any
|
||||
const { ClientFamilyMemberChildDialogComponent } = await import('../client-family-member-child-dialog/client-family-member-child-dialog.component');
|
||||
|
||||
// Dynamically import the dialog to avoid a circular dependency between the two dialogs.
|
||||
const { ClientFamilyMemberChildDialogComponent } =
|
||||
await import('../client-family-member-child-dialog/client-family-member-child-dialog.component');
|
||||
|
||||
const dialogRef = this.dialog.open(ClientFamilyMemberChildDialogComponent, {
|
||||
data: {
|
||||
children: this.children.value,
|
||||
@@ -278,24 +288,33 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
|
||||
dialogRef.afterClosed().subscribe((result: any[]) => {
|
||||
if (result) {
|
||||
this.children.clear();
|
||||
result.forEach(child => {
|
||||
this.children.push(this.formBuilder.group({
|
||||
id: [child.id],
|
||||
fullName: [child.fullName, Validators.required],
|
||||
genderId: [child.genderId, Validators.required],
|
||||
dateOfBirth: [child.dateOfBirth],
|
||||
nationality: [child.nationality || 'INDIAN'],
|
||||
aadhaarNumber: [child.aadhaarNumber || ''],
|
||||
panNumber: [child.panNumber || '', Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)]
|
||||
}));
|
||||
result.forEach((child) => {
|
||||
this.children.push(
|
||||
this.formBuilder.group({
|
||||
id: [child.id],
|
||||
fullName: [
|
||||
child.fullName,
|
||||
Validators.required
|
||||
],
|
||||
genderId: [
|
||||
child.genderId,
|
||||
Validators.required
|
||||
],
|
||||
dateOfBirth: [child.dateOfBirth],
|
||||
nationality: [child.nationality || 'INDIAN'],
|
||||
aadhaarNumber: [child.aadhaarNumber || ''],
|
||||
panNumber: [
|
||||
child.panNumber || '',
|
||||
Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)
|
||||
]
|
||||
})
|
||||
);
|
||||
});
|
||||
this.hasChildrenChecked = true;
|
||||
this.familyMemberForm.patchValue({ hasChildren: true }, { emitEvent: false });
|
||||
} else {
|
||||
if (this.children.length === 0) {
|
||||
this.hasChildrenChecked = false;
|
||||
this.familyMemberForm.patchValue({ hasChildren: false }, { emitEvent: false });
|
||||
}
|
||||
} else if (this.children.length === 0) {
|
||||
this.hasChildrenChecked = false;
|
||||
this.familyMemberForm.patchValue({ hasChildren: false }, { emitEvent: false });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -303,12 +322,21 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
|
||||
createChildFormGroup(): UntypedFormGroup {
|
||||
return this.formBuilder.group({
|
||||
id: [''],
|
||||
fullName: ['', Validators.required],
|
||||
genderId: ['', Validators.required],
|
||||
fullName: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
genderId: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
dateOfBirth: [''],
|
||||
nationality: ['INDIAN'],
|
||||
aadhaarNumber: [''],
|
||||
panNumber: ['', Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)]
|
||||
panNumber: [
|
||||
'',
|
||||
Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+45
-40
@@ -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;
|
||||
@@ -960,9 +969,36 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
this.linkIncomeToTurnover();
|
||||
}
|
||||
|
||||
/** Guards the Monthly/Annual and Income/Turnover auto-sync subscriptions below — set this to
|
||||
* true while programmatically writing both fields of a pair to avoid a feedback loop. */
|
||||
private suppressMonthlyAnnualLink = false;
|
||||
/**
|
||||
* Auto-calculates turnover based on income (doubling monthly income for monthly turnover,
|
||||
* and annual income for annual/yearly turnover).
|
||||
*/
|
||||
private linkIncomeToTurnover(): void {
|
||||
const monthlyIncome = this.createClientForm.get('monthlyIncome');
|
||||
const monthlyTurnover = this.createClientForm.get('monthlyTurnover');
|
||||
const annualIncome = this.createClientForm.get('annualIncome');
|
||||
const annualTurnover = this.createClientForm.get('annualTurnover');
|
||||
|
||||
if (monthlyIncome && monthlyTurnover) {
|
||||
monthlyIncome.valueChanges.subscribe((value) => {
|
||||
if (this.suppressMonthlyAnnualLink) {
|
||||
return;
|
||||
}
|
||||
const num = parseFloat(value);
|
||||
monthlyTurnover.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 2) : '', { emitEvent: true });
|
||||
});
|
||||
}
|
||||
|
||||
if (annualIncome && annualTurnover) {
|
||||
annualIncome.valueChanges.subscribe((value) => {
|
||||
if (this.suppressMonthlyAnnualLink) {
|
||||
return;
|
||||
}
|
||||
const num = parseFloat(value);
|
||||
annualTurnover.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 2) : '', { emitEvent: true });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps a Monthly/Annual pair of amount fields in sync at a 12x ratio — typing into either
|
||||
@@ -998,37 +1034,6 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-calculates turnover based on income (doubling monthly income for monthly turnover,
|
||||
* and annual income for annual/yearly turnover).
|
||||
*/
|
||||
private linkIncomeToTurnover(): void {
|
||||
const monthlyIncome = this.createClientForm.get('monthlyIncome');
|
||||
const monthlyTurnover = this.createClientForm.get('monthlyTurnover');
|
||||
const annualIncome = this.createClientForm.get('annualIncome');
|
||||
const annualTurnover = this.createClientForm.get('annualTurnover');
|
||||
|
||||
if (monthlyIncome && monthlyTurnover) {
|
||||
monthlyIncome.valueChanges.subscribe((value) => {
|
||||
if (this.suppressMonthlyAnnualLink) {
|
||||
return;
|
||||
}
|
||||
const num = parseFloat(value);
|
||||
monthlyTurnover.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 2) : '', { emitEvent: true });
|
||||
});
|
||||
}
|
||||
|
||||
if (annualIncome && annualTurnover) {
|
||||
annualIncome.valueChanges.subscribe((value) => {
|
||||
if (this.suppressMonthlyAnnualLink) {
|
||||
return;
|
||||
}
|
||||
const num = parseFloat(value);
|
||||
annualTurnover.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 2) : '', { emitEvent: true });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets select dropdown options.
|
||||
*/
|
||||
@@ -2263,26 +2268,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -725,16 +725,36 @@
|
||||
}
|
||||
|
||||
<!-- KYC Exception Flag -->
|
||||
<div class="exception-flag-box" style="margin-top: 20px; padding: 16px; border: 1px solid #f59e0b; background: #fffbeb; border-radius: 8px; clear: both;">
|
||||
<h5 style="margin: 0 0 12px 0; font-size: 14px; font-weight: 600; color: #b45309; display: flex; align-items: center; gap: 8px;">
|
||||
<div
|
||||
class="exception-flag-box"
|
||||
style="
|
||||
margin-top: 20px;
|
||||
padding: 16px;
|
||||
border: 1px solid #f59e0b;
|
||||
background: #fffbeb;
|
||||
border-radius: 8px;
|
||||
clear: both;
|
||||
"
|
||||
>
|
||||
<h5
|
||||
style="
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #b45309;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
"
|
||||
>
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
KYC Exception Flag
|
||||
</h5>
|
||||
<div style="display: flex; align-items: center; gap: 20px; flex-wrap: wrap;">
|
||||
<div style="display: flex; align-items: center; gap: 20px; flex-wrap: wrap">
|
||||
<mat-checkbox formControlName="isException">Mark as KYC Exception</mat-checkbox>
|
||||
|
||||
|
||||
@if (kycForm.get('isException')?.value) {
|
||||
<mat-form-field appearance="outline" style="flex: 1; min-width: 280px; margin-bottom: -1.25em;">
|
||||
<mat-form-field appearance="outline" style="flex: 1; min-width: 280px; margin-bottom: -1.25em">
|
||||
<mat-label>Exception Reasons</mat-label>
|
||||
<mat-select formControlName="exceptionReason" multiple>
|
||||
@if (!isNonIndividual) {
|
||||
@@ -1437,7 +1457,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ═══ SECTION: Photograph & Signature ═══ -->
|
||||
<div class="section-block">
|
||||
<h4 class="section-title">
|
||||
|
||||
@@ -1874,7 +1874,9 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
||||
// or blank PAN/Aadhaar is an exception by definition, so the officer cannot forget to flag
|
||||
// it. See deriveKycExceptionFields() for the raise / narrow / close rules.
|
||||
...deriveKycExceptionFields({
|
||||
panVerified: this.isNonIndividual ? this.entPanVerifyStatus === 'VERIFIED' : this.panVerifyStatus === 'VERIFIED',
|
||||
panVerified: this.isNonIndividual
|
||||
? this.entPanVerifyStatus === 'VERIFIED'
|
||||
: this.panVerifyStatus === 'VERIFIED',
|
||||
aadhaarVerified: this.aadhaarVerifyStatus === 'VERIFIED',
|
||||
// Non-individual clients submit a dummy masked Aadhaar, so checking it would saddle every
|
||||
// company with a permanent exception.
|
||||
|
||||
+1
-28
@@ -36,34 +36,7 @@
|
||||
<strong>Status:</strong>
|
||||
<span class="status-badge status-{{ dispatch.status | lowercase }}">{{ dispatch.status }}</span>
|
||||
</p>
|
||||
|
||||
<!-- Action buttons: only shown for DISPATCHED / IN_TRANSIT, only for users with UPDATE_WELCOMEKIT permission -->
|
||||
<ng-container *mifosxHasPermission="'UPDATE_WELCOMEKIT'">
|
||||
<div
|
||||
*ngIf="canUpdateStatus(dispatch.status)"
|
||||
style="margin-top: 12px; display: flex; gap: 8px; flex-wrap: wrap;"
|
||||
>
|
||||
<button
|
||||
mat-stroked-button
|
||||
color="primary"
|
||||
type="button"
|
||||
[disabled]="isLoading"
|
||||
(click)="updateStatus(dispatch, 'DELIVERED')"
|
||||
>
|
||||
✅ Mark as Delivered
|
||||
</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
color="warn"
|
||||
type="button"
|
||||
[disabled]="isLoading"
|
||||
(click)="updateStatus(dispatch, 'RETURNED')"
|
||||
>
|
||||
↩ Mark as Returned
|
||||
</button>
|
||||
</div>
|
||||
</ng-container>
|
||||
</div><!-- end dispatch-tracking-panel -->
|
||||
</div>
|
||||
|
||||
<hr *ngIf="activeDispatches.length > 0 && welcomeKitsData?.canDispatchNew" />
|
||||
|
||||
|
||||
-24
@@ -165,30 +165,6 @@ export class WelcomeKitDialogComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
canUpdateStatus(status: string): boolean {
|
||||
return status === 'DISPATCHED' || status === 'IN_TRANSIT';
|
||||
}
|
||||
|
||||
updateStatus(dispatch: any, newStatus: string) {
|
||||
const label = newStatus === 'DELIVERED' ? 'Delivered' : 'Returned';
|
||||
const confirmed = confirm(`Are you sure you want to mark this as ${label}? This action cannot be undone from the UI.`);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isLoading = true;
|
||||
this.clientsService.updateWelcomeKitStatus(dispatch.id, newStatus).subscribe({
|
||||
next: () => {
|
||||
this.snackBar.open(`Welcome Kit marked as ${label} successfully`, 'Close', { duration: 3000 });
|
||||
this.fetchData();
|
||||
},
|
||||
error: () => {
|
||||
this.isLoading = false;
|
||||
this.snackBar.open(`Failed to mark Welcome Kit as ${label}`, 'Close', { duration: 3000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getDisplayAddress(dispatch: any): string {
|
||||
const address = dispatch.deliveryAddress || '';
|
||||
const name = this.welcomeKitsData?.clientName || '';
|
||||
|
||||
@@ -43,13 +43,26 @@
|
||||
<!-- KYC Summary (read-only when not in edit mode) -->
|
||||
@if (!isEditMode && kycData) {
|
||||
@if (kycData.isException && !kycData.exceptionResolved) {
|
||||
<div class="kyc-exception-banner" style="background: #fef3c7; border: 1px solid #f59e0b; border-radius: 8px; padding: 16px; margin-bottom: 20px; display: flex; align-items: flex-start; gap: 12px;">
|
||||
<mat-icon style="color: #d97706; margin-top: 2px;">warning</mat-icon>
|
||||
<div
|
||||
class="kyc-exception-banner"
|
||||
style="
|
||||
background: #fef3c7;
|
||||
border: 1px solid #f59e0b;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
"
|
||||
>
|
||||
<mat-icon style="color: #d97706; margin-top: 2px">warning</mat-icon>
|
||||
<div>
|
||||
<strong style="color: #92400e; font-size: 14px;">KYC Exception Active</strong>
|
||||
<p style="color: #b45309; font-size: 13px; margin: 4px 0 0 0;">
|
||||
Account is open under a temporary KYC Exception. Reason: <strong>{{ kycData.exceptionReason || 'Other' }}</strong>.
|
||||
Please contact the customer and complete verification as soon as possible.
|
||||
<strong style="color: #92400e; font-size: 14px">KYC Exception Active</strong>
|
||||
<p style="color: #b45309; font-size: 13px; margin: 4px 0 0 0">
|
||||
Account is open under a temporary KYC Exception. Reason:
|
||||
<strong>{{ kycData.exceptionReason || 'Other' }}</strong
|
||||
>. Please contact the customer and complete verification as soon as possible.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -754,18 +767,23 @@
|
||||
</mat-form-field>
|
||||
|
||||
<!-- KYC Exception Flag -->
|
||||
<div class="full-span exception-controls-row" style="display: flex; align-items: center; gap: 20px; margin-bottom: 12px; flex-wrap: wrap;">
|
||||
<mat-checkbox formControlName="isException" style="margin-top: 8px;">Mark as KYC Exception</mat-checkbox>
|
||||
|
||||
<div
|
||||
class="full-span exception-controls-row"
|
||||
style="display: flex; align-items: center; gap: 20px; margin-bottom: 12px; flex-wrap: wrap"
|
||||
>
|
||||
<mat-checkbox formControlName="isException" style="margin-top: 8px">Mark as KYC Exception</mat-checkbox>
|
||||
|
||||
@if (kycForm.get('isException')?.value) {
|
||||
<mat-form-field appearance="outline" style="flex: 1; min-width: 250px;">
|
||||
<mat-form-field appearance="outline" style="flex: 1; min-width: 250px">
|
||||
<mat-label>Exception Reasons</mat-label>
|
||||
<mat-select formControlName="exceptionReason" multiple>
|
||||
@if (!isNonIndividual) {
|
||||
<mat-option value="PAN Card Verification Failed">PAN Card Verification Failed</mat-option>
|
||||
<mat-option value="Aadhaar Verification Failed">Aadhaar Verification Failed</mat-option>
|
||||
} @else {
|
||||
<mat-option value="Enterprise PAN Verification Failed">Enterprise PAN Verification Failed</mat-option>
|
||||
<mat-option value="Enterprise PAN Verification Failed"
|
||||
>Enterprise PAN Verification Failed</mat-option
|
||||
>
|
||||
<mat-option value="GSTIN Verification Failed">GSTIN Verification Failed</mat-option>
|
||||
<mat-option value="CIN Verification Failed">CIN Verification Failed</mat-option>
|
||||
}
|
||||
@@ -779,7 +797,6 @@
|
||||
<mat-label>{{ 'labels.inputs.Remarks' | translate }}</mat-label>
|
||||
<textarea matInput formControlName="remarks" rows="3"></textarea>
|
||||
</mat-form-field>
|
||||
|
||||
</div>
|
||||
|
||||
@if (errorMessage) {
|
||||
|
||||
@@ -297,7 +297,8 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
error: (err: any) => {
|
||||
this.verifying[field] = false;
|
||||
this.verificationStatus[field] = 'FAILED';
|
||||
this.verificationError[field] = err?.error?.defaultUserMessage || `Failed to update ${field.toUpperCase()} number before verification.`;
|
||||
this.verificationError[field] =
|
||||
err?.error?.defaultUserMessage || `Failed to update ${field.toUpperCase()} number before verification.`;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -991,7 +991,10 @@ export class ClientsService {
|
||||
* Resolves with HTTP 200 even when delivery fails — inspect `deliveryStatus` / `delivered` on the
|
||||
* response rather than assuming success, since the log row is written either way.
|
||||
*/
|
||||
saveKycContactLog(clientId: string | number, data: { channel: string; notes: string; officerName?: string }): Observable<any> {
|
||||
saveKycContactLog(
|
||||
clientId: string | number,
|
||||
data: { channel: string; notes: string; officerName?: string }
|
||||
): Observable<any> {
|
||||
return this.http.post(`/kyc/exceptions/${clientId}/contact-log`, data);
|
||||
}
|
||||
|
||||
|
||||
@@ -442,7 +442,9 @@ export class CreateClientComponent {
|
||||
});
|
||||
} else {
|
||||
const { kycType, ...updatePayload } = this.clientKycStep.kycData;
|
||||
this.clientsService.updateClientKyc(clientId, this.clientKycStep.kycId, updatePayload).subscribe({ error: () => {} });
|
||||
this.clientsService
|
||||
.updateClientKyc(clientId, this.clientKycStep.kycId, updatePayload)
|
||||
.subscribe({ error: () => {} });
|
||||
}
|
||||
}
|
||||
// Upload photo & signature
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
<mat-icon>phone_iphone</mat-icon>
|
||||
<span>Mobile Number</span>
|
||||
</div>
|
||||
<div class="contact-verify-panel__exception-row" style="margin-bottom: 12px;">
|
||||
<div class="contact-verify-panel__exception-row" style="margin-bottom: 12px">
|
||||
<mat-checkbox
|
||||
[checked]="oldMobileNotAccessible"
|
||||
(change)="onOldMobileNotAccessibleToggle($event.checked)"
|
||||
@@ -103,13 +103,27 @@
|
||||
(stateChange)="onOldMobileOtpState($event)"
|
||||
></mifosx-client-contact-otp-block>
|
||||
} @else {
|
||||
<div class="fir-upload-box" style="padding: 12px; border: 1px dashed #cbd5e1; border-radius: 8px; background: #f8fafc;">
|
||||
<div style="font-weight: 500; font-size: 13px; margin-bottom: 6px; color: #475569;">
|
||||
<div
|
||||
class="fir-upload-box"
|
||||
style="padding: 12px; border: 1px dashed #cbd5e1; border-radius: 8px; background: #f8fafc"
|
||||
>
|
||||
<div style="font-weight: 500; font-size: 13px; margin-bottom: 6px; color: #475569">
|
||||
Upload Police Complaint / FIR Copy for Lost Mobile
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 12px;">
|
||||
<input type="file" (change)="onFirFileSelected($event)" accept=".pdf,.jpg,.jpeg,.png" #firFileInput hidden />
|
||||
<button mat-stroked-button type="button" (click)="firFileInput.click()" [disabled]="uploadingFir">
|
||||
<div style="display: flex; align-items: center; gap: 12px">
|
||||
<input
|
||||
type="file"
|
||||
(change)="onFirFileSelected($event)"
|
||||
accept=".pdf,.jpg,.jpeg,.png"
|
||||
#firFileInput
|
||||
hidden
|
||||
/>
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
(click)="firFileInput.click()"
|
||||
[disabled]="uploadingFir"
|
||||
>
|
||||
<mat-icon>upload_file</mat-icon>
|
||||
{{ firFileName ? firFileName : 'Select FIR Document' }}
|
||||
</button>
|
||||
@@ -117,8 +131,18 @@
|
||||
<mat-progress-spinner mode="indeterminate" diameter="20"></mat-progress-spinner>
|
||||
}
|
||||
@if (firDocumentId && !uploadingFir) {
|
||||
<span style="color: #16a34a; font-size: 12px; font-weight: 500; display: flex; align-items: center; gap: 4px;">
|
||||
<mat-icon style="font-size: 16px; width: 16px; height: 16px;">check_circle</mat-icon> FIR Uploaded Successfully
|
||||
<span
|
||||
style="
|
||||
color: #16a34a;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
"
|
||||
>
|
||||
<mat-icon style="font-size: 16px; width: 16px; height: 16px">check_circle</mat-icon> FIR
|
||||
Uploaded Successfully
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -224,13 +224,12 @@ export class EditClientComponent {
|
||||
return this.detectSensitiveFieldChanges(this.clientDataAndTemplate);
|
||||
}
|
||||
|
||||
/** Whether every OTP block required by the fields that actually changed has been verified (or FIR uploaded for lost mobile). */
|
||||
/** Whether every OTP block required by the fields that actually changed has been verified
|
||||
* (or the FIR exception has been uploaded in place of old-mobile OTP verification). */
|
||||
get contactVerificationComplete(): boolean {
|
||||
const changes = this.contactChanges;
|
||||
if (changes.mobileChanged) {
|
||||
const isOldSatisfied = this.oldMobileNotAccessible
|
||||
? (this.firDocumentId != null)
|
||||
: (this.oldMobileVerified);
|
||||
const isOldSatisfied = this.oldMobileNotAccessible ? this.firDocumentId != null : this.oldMobileVerified;
|
||||
if (!isOldSatisfied || !this.newMobileVerified) {
|
||||
return false;
|
||||
}
|
||||
@@ -241,6 +240,7 @@ export class EditClientComponent {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Toggle the "old mobile not accessible" exception — clears any in-progress old-mobile OTP. */
|
||||
onOldMobileNotAccessibleToggle(checked?: boolean): void {
|
||||
if (checked !== undefined) {
|
||||
this.oldMobileNotAccessible = checked;
|
||||
@@ -251,6 +251,7 @@ export class EditClientComponent {
|
||||
}
|
||||
}
|
||||
|
||||
/** Upload the FIR / police-complaint copy that substitutes for old-mobile OTP verification. */
|
||||
onFirFileSelected(event: any): void {
|
||||
const file: File = event.target?.files?.[0];
|
||||
if (!file) return;
|
||||
@@ -707,10 +708,21 @@ export class EditClientComponent {
|
||||
const mobileControl = form.get('mobileNo');
|
||||
const emailControl = form.get('emailAddress');
|
||||
|
||||
// Trimmed so browser autofill adding invisible leading/trailing whitespace never reads as a change
|
||||
// Trimmed so browser autofill adding invisible leading/trailing whitespace never reads as
|
||||
// a "change" — the masked preview shown in the Verify Contact Change panel only reveals the
|
||||
// first couple of characters, so a whitespace-only difference is otherwise invisible to the
|
||||
// user and just looks like the panel appeared for no reason. These trimmed-but-original-case
|
||||
// values are what get returned/sent as the actual OTP destination and saved value — only the
|
||||
// *comparison* (below) is further normalized for mobile/email, so we never silently rewrite the
|
||||
// customer's saved value or the OTP destination.
|
||||
const newMobile = (mobileControl?.value || '').toString().trim();
|
||||
const newEmail = (emailControl?.value || '').toString().trim();
|
||||
const newDobRaw = form.get('dateOfBirth')?.value;
|
||||
// Local calendar date, NOT `.toISOString()` — that converts to UTC first, which silently
|
||||
// shifts the date by a day in any positive-UTC-offset timezone (e.g. India, UTC+5:30:
|
||||
// midnight local on the 15th becomes 18:30 UTC on the 14th). That made this comparison
|
||||
// report a DOB "change" — falsely triggering the Re-KYC dialog — even when the user never
|
||||
// touched the date field, purely because the Date object round-tripped through the picker.
|
||||
const newDob = newDobRaw instanceof Date ? this.toLocalDateString(newDobRaw) : newDobRaw || '';
|
||||
|
||||
const oldMobile = (originalData.mobileNo || '').toString().trim();
|
||||
@@ -735,10 +747,10 @@ export class EditClientComponent {
|
||||
).trim();
|
||||
|
||||
const newName = buildName({
|
||||
firstname: form.get('firstname')?.value || '',
|
||||
middlename: form.get('middlename')?.value || '',
|
||||
lastname: form.get('lastname')?.value || '',
|
||||
fullname: form.get('fullname')?.value || ''
|
||||
firstname: form?.get('firstname')?.value || '',
|
||||
middlename: form?.get('middlename')?.value || '',
|
||||
lastname: form?.get('lastname')?.value || '',
|
||||
fullname: form?.get('fullname')?.value || ''
|
||||
});
|
||||
const oldName = buildName({
|
||||
firstname: originalData.firstname || '',
|
||||
@@ -752,9 +764,15 @@ export class EditClientComponent {
|
||||
const newMobileNorm = normPhone(newMobile);
|
||||
const oldMobileNorm = normPhone(oldMobile);
|
||||
|
||||
// Mobile/Email are considered changed ONLY when the control has been edited (dirty) AND normalized values differ
|
||||
const mobileChanged = !!oldMobileNorm && !!newMobileNorm && !!mobileControl?.dirty && (newMobileNorm !== oldMobileNorm);
|
||||
const emailChanged = !!oldEmail && !!newEmail && !!emailControl?.dirty && (newEmail.toLowerCase() !== oldEmail.toLowerCase());
|
||||
// Mobile/Email are considered changed ONLY when the control has been edited (dirty) AND
|
||||
// normalized values differ — otherwise a re-render/reformat of the control by the framework
|
||||
// (never actually touched by the user) would falsely surface the dual-OTP verification panel.
|
||||
const mobileChanged =
|
||||
!!oldMobileNorm && !!newMobileNorm && !!mobileControl?.dirty && newMobileNorm !== oldMobileNorm;
|
||||
// Case-insensitive: emails are not case-sensitive in practice, and comparing raw case
|
||||
// would flag a change (surfacing the dual-OTP panel) even when nothing meaningful changed.
|
||||
const emailChanged =
|
||||
!!oldEmail && !!newEmail && !!emailControl?.dirty && newEmail.toLowerCase() !== oldEmail.toLowerCase();
|
||||
|
||||
return {
|
||||
nameChanged: newName !== oldName,
|
||||
|
||||
@@ -384,14 +384,6 @@ export class AuthenticationService {
|
||||
return JSON.parse(this.storage.getItem(this.credentialsStorageKey));
|
||||
}
|
||||
|
||||
/** Returns true when the logged-in user is at Head Office rather than a branch login. */
|
||||
isHeadOfficeUser(): boolean {
|
||||
const creds = this.getCredentials();
|
||||
if (!creds) return false;
|
||||
if (creds.officeId === 1) return true;
|
||||
return (creds.officeName || '').toLowerCase().includes('head office');
|
||||
}
|
||||
|
||||
/** Returns true when the logged-in user has the "Clerk" role. */
|
||||
isClerkUser(): boolean {
|
||||
const creds = this.getCredentials();
|
||||
@@ -448,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.
|
||||
*
|
||||
|
||||
@@ -374,6 +374,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>
|
||||
@@ -386,6 +390,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>
|
||||
@@ -462,6 +470,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"
|
||||
@@ -516,6 +528,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>
|
||||
@@ -690,6 +711,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
|
||||
>
|
||||
@@ -738,8 +760,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
|
||||
@@ -845,50 +870,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
|
||||
>
|
||||
@@ -898,28 +945,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>
|
||||
@@ -1192,7 +1258,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()">
|
||||
@@ -1208,13 +1274,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">
|
||||
@@ -1245,39 +1311,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>
|
||||
@@ -1310,27 +1433,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>
|
||||
@@ -1339,19 +1462,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>
|
||||
@@ -1360,15 +1483,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>
|
||||
@@ -1381,7 +1504,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>
|
||||
@@ -1390,11 +1513,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>
|
||||
@@ -1506,6 +1629,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>
|
||||
@@ -1718,28 +1844,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 -->
|
||||
@@ -1870,6 +2009,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
+574
-179
@@ -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,77 +195,105 @@
|
||||
<!-- CTR -->
|
||||
@if (activeTab === 'ctr') {
|
||||
<div class="fiu-section">
|
||||
<!-- LIVE: cash-related AML alerts awaiting AML Officer review -->
|
||||
<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>
|
||||
<!-- 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>
|
||||
<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">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Client</th>
|
||||
<th>Type</th>
|
||||
<th>Rule</th>
|
||||
<th class="ta-r">Amount</th>
|
||||
<th>Raised On</th>
|
||||
<th>Status</th>
|
||||
<th>Notes</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (alert of amlAlerts; track alert.id) {
|
||||
<tr>
|
||||
<td>{{ alert.id }}</td>
|
||||
<td>{{ alert.clientDisplayName }}</td>
|
||||
<td>{{ alert.transactionType }}</td>
|
||||
<td>{{ alert.ruleCode }} — {{ alert.ruleDescription }}</td>
|
||||
<td class="ta-r mono">₹{{ alert.amount | number: '1.0-0' }}</td>
|
||||
<td>{{ alert.raisedOn | date: 'medium' }}</td>
|
||||
<td>
|
||||
<span [class]="amlStatusClass(alert.status)">{{ alert.status }}</span>
|
||||
</td>
|
||||
<td>
|
||||
@if (alert.status === 'RAISED' || alert.status === 'UNDER_REVIEW') {
|
||||
<input
|
||||
type="text"
|
||||
class="fiu-inp"
|
||||
placeholder="Review notes"
|
||||
[(ngModel)]="amlReviewNotes[alert.id]"
|
||||
/>
|
||||
} @else {
|
||||
<span class="muted">{{ alert.reviewNotes }}</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (alert.status === 'RAISED' || alert.status === 'UNDER_REVIEW') {
|
||||
<div class="fiu-action-group" *mifosxHasPermission="'REVIEW_AML_ALERT'">
|
||||
<button class="fiu-btn fiu-btn-g" (click)="whitelistAlert(alert)">Whitelist</button>
|
||||
<button class="fiu-btn fiu-btn-xml" (click)="escalateAlert(alert)">Escalate</button>
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="9" class="fiu-empty">No live alerts.</td>
|
||||
</tr>
|
||||
|
||||
<!-- 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>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</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>
|
||||
|
||||
<!-- LIVE: monthly CTR reports (mechanical, threshold-based) -->
|
||||
@@ -248,100 +342,94 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- FINnet export template (separate concern — TC1/TC2 file-format generation) -->
|
||||
<div class="fiu-form-meta">
|
||||
<div class="fiu-form-title">
|
||||
<h3>FINnet TC2 Export Template</h3>
|
||||
<p>
|
||||
All cash transactions ≥ ₹{{ 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: suspicious AML alerts awaiting AML Officer review (Structuring, PEP, etc.) -->
|
||||
<div class="fiu-form-meta">
|
||||
<div class="fiu-form-title">
|
||||
<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">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Client</th>
|
||||
<th>Type</th>
|
||||
<th>Rule</th>
|
||||
<th class="ta-r">Amount</th>
|
||||
<th>Raised On</th>
|
||||
<th>Status</th>
|
||||
<th class="ta-c">Routes To</th>
|
||||
<th>Notes</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (alert of amlSuspiciousAlerts; track alert.id) {
|
||||
<tr>
|
||||
<td>{{ alert.id }}</td>
|
||||
<td>{{ alert.clientDisplayName }}</td>
|
||||
<td>{{ alert.transactionType }}</td>
|
||||
<td>{{ alert.ruleCode }} — {{ alert.ruleDescription }}</td>
|
||||
<td class="ta-r mono">₹{{ alert.amount | number: '1.0-0' }}</td>
|
||||
<td>{{ alert.raisedOn | date: 'medium' }}</td>
|
||||
<td>
|
||||
<span [class]="amlStatusClass(alert.status)">{{ alert.status }}</span>
|
||||
</td>
|
||||
<td class="ta-c">
|
||||
<span [class]="alertRouteClass(alert.status, false)">{{ alertRouteLabel(alert.status, false) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
@if (alert.status === 'RAISED' || alert.status === 'UNDER_REVIEW') {
|
||||
<input
|
||||
type="text"
|
||||
class="fiu-inp"
|
||||
placeholder="Review notes"
|
||||
[(ngModel)]="amlReviewNotes[alert.id]"
|
||||
/>
|
||||
} @else {
|
||||
<span class="muted">{{ alert.reviewNotes }}</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (alert.status === 'RAISED' || alert.status === 'UNDER_REVIEW') {
|
||||
<div class="fiu-action-group" *mifosxHasPermission="'REVIEW_AML_ALERT'">
|
||||
<button class="fiu-btn fiu-btn-g" (click)="whitelistAlert(alert)">Whitelist</button>
|
||||
<button class="fiu-btn fiu-btn-xml" (click)="escalateAlert(alert)">Escalate</button>
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="10" class="fiu-empty">No 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>
|
||||
|
||||
<hr style="border: 0; border-top: 1px solid var(--f-g200); margin: 8px 0" />
|
||||
|
||||
<!-- LIVE: STR cases with full Principal Officer actions -->
|
||||
<div class="fiu-form-meta">
|
||||
<div class="fiu-form-title">
|
||||
@@ -407,6 +495,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 +515,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 +638,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 +660,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 +697,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 +711,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 +1018,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 +1036,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 +1071,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,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);
|
||||
}
|
||||
}
|
||||
@@ -771,27 +771,39 @@
|
||||
|
||||
<div class="km-card km-mt">
|
||||
<!-- Tab Toggle Header -->
|
||||
<div class="km-ch" style="border-bottom: 1px solid var(--km-g200); padding-bottom: 0; display: flex; flex-direction: column; align-items: flex-start; gap: 12px;">
|
||||
<div style="display: flex; gap: 8px; align-items: center;">
|
||||
<div class="km-ci" style="background: #fef3c7; color: #d97706;"><fa-icon icon="exclamation-triangle"></fa-icon></div>
|
||||
<div
|
||||
class="km-ch"
|
||||
style="
|
||||
border-bottom: 1px solid var(--km-g200);
|
||||
padding-bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
"
|
||||
>
|
||||
<div style="display: flex; gap: 8px; align-items: center">
|
||||
<div class="km-ci" style="background: #fef3c7; color: #d97706">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
</div>
|
||||
<div class="km-ct">Exceptions Register</div>
|
||||
</div>
|
||||
|
||||
<div class="km-inner-nav" style="width: 100%; border: none; margin: 10px 0 0 0; padding: 0; gap: 8px;">
|
||||
<button
|
||||
class="km-itab"
|
||||
[class.active]="activeExceptionSubTab === 'pending'"
|
||||
|
||||
<div class="km-inner-nav" style="width: 100%; border: none; margin: 10px 0 0 0; padding: 0; gap: 8px">
|
||||
<button
|
||||
class="km-itab"
|
||||
[class.active]="activeExceptionSubTab === 'pending'"
|
||||
(click)="activeExceptionSubTab = 'pending'"
|
||||
style="padding: 8px 16px; border-radius: 4px; font-weight: 600; font-size: 13px;"
|
||||
style="padding: 8px 16px; border-radius: 4px; font-weight: 600; font-size: 13px"
|
||||
>
|
||||
<fa-icon icon="clock"></fa-icon>
|
||||
Pending Exceptions ({{ pendingExceptions.length }})
|
||||
</button>
|
||||
<button
|
||||
class="km-itab"
|
||||
[class.active]="activeExceptionSubTab === 'resolved'"
|
||||
<button
|
||||
class="km-itab"
|
||||
[class.active]="activeExceptionSubTab === 'resolved'"
|
||||
(click)="activeExceptionSubTab = 'resolved'"
|
||||
style="padding: 8px 16px; border-radius: 4px; font-weight: 600; font-size: 13px;"
|
||||
style="padding: 8px 16px; border-radius: 4px; font-weight: 600; font-size: 13px"
|
||||
>
|
||||
<fa-icon icon="check-circle"></fa-icon>
|
||||
Resolved Exceptions ({{ resolvedExceptions.length }})
|
||||
@@ -802,10 +814,12 @@
|
||||
<!-- PENDING EXCEPTIONS TAB -->
|
||||
@if (activeExceptionSubTab === 'pending') {
|
||||
@if (pendingExceptions.length === 0) {
|
||||
<div class="km-empty" style="padding: 40px; text-align: center;">
|
||||
<div class="km-empty" style="padding: 40px; text-align: center">
|
||||
<div class="km-empty-i">🎉</div>
|
||||
<div style="font-weight: 500; font-size: 16px; margin-top: 12px;">All exceptions resolved!</div>
|
||||
<div class="km-empty-s" style="color: var(--km-g500); font-size: 13px; margin-top: 4px;">There are no active client KYC exceptions on record.</div>
|
||||
<div style="font-weight: 500; font-size: 16px; margin-top: 12px">All exceptions resolved!</div>
|
||||
<div class="km-empty-s" style="color: var(--km-g500); font-size: 13px; margin-top: 4px">
|
||||
There are no active client KYC exceptions on record.
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<table class="km-tbl">
|
||||
@@ -824,12 +838,18 @@
|
||||
@for (c of pendingExceptions; track c.id) {
|
||||
<tr>
|
||||
<td>
|
||||
<div class="km-an" [routerLink]="['/clients', c.id, 'kyc']" style="cursor: pointer; color: var(--km-blue); text-decoration: underline;">{{ c.name }}</div>
|
||||
<div
|
||||
class="km-an"
|
||||
[routerLink]="['/clients', c.id, 'kyc']"
|
||||
style="cursor: pointer; color: var(--km-blue); text-decoration: underline"
|
||||
>
|
||||
{{ c.name }}
|
||||
</div>
|
||||
<div class="km-ai">{{ c.account }}</div>
|
||||
</td>
|
||||
<td>{{ c.contact.branch }}</td>
|
||||
<td>
|
||||
<div style="font-weight: 500; color: #b45309;">{{ c.exceptionReason }}</div>
|
||||
<div style="font-weight: 500; color: #b45309">{{ c.exceptionReason }}</div>
|
||||
<div class="km-small">Opened: {{ c.exceptionDate }}</div>
|
||||
</td>
|
||||
<td>
|
||||
@@ -838,20 +858,25 @@
|
||||
<td>
|
||||
@if (c.contactHistory && c.contactHistory.length > 0) {
|
||||
<span class="km-del d-ok">Contacted</span>
|
||||
<div class="km-small" style="margin-top: 2px;">Last: {{ c.contactHistory[c.contactHistory.length - 1].date }}</div>
|
||||
<div class="km-small" style="margin-top: 2px">
|
||||
Last: {{ c.contactHistory[c.contactHistory.length - 1].date }}
|
||||
</div>
|
||||
} @else {
|
||||
<span class="km-del d-fa">Not Contacted</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (c.contactHistory && c.contactHistory.length > 0) {
|
||||
<div class="contact-logs-list" style="max-height: 80px; overflow-y: auto; font-size: 11px;">
|
||||
<div class="contact-logs-list" style="max-height: 80px; overflow-y: auto; font-size: 11px">
|
||||
@for (log of c.contactHistory; track $index) {
|
||||
<div style="border-bottom: 1px dashed var(--km-g300); padding: 2px 0;">
|
||||
<strong>{{ log.date }} ({{ log.action }}):</strong> {{ log.notes }} <em>— {{ log.officer }}</em>
|
||||
<div style="border-bottom: 1px dashed var(--km-g300); padding: 2px 0">
|
||||
<strong>{{ log.date }} ({{ log.action }}):</strong> {{ log.notes }}
|
||||
<em>— {{ log.officer }}</em>
|
||||
@if (log.platformSent) {
|
||||
@if (log.delivered) {
|
||||
<span class="km-sent-tag km-sent-ok" [title]="log.deliveryMessage">Sent {{ log.destinationMasked }}</span>
|
||||
<span class="km-sent-tag km-sent-ok" [title]="log.deliveryMessage"
|
||||
>Sent {{ log.destinationMasked }}</span
|
||||
>
|
||||
} @else {
|
||||
<span class="km-sent-tag km-sent-fail" [title]="log.deliveryMessage">Not sent</span>
|
||||
}
|
||||
@@ -860,12 +885,16 @@
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<span style="color: var(--km-g500); font-size: 11px;">No logs recorded</span>
|
||||
<span style="color: var(--km-g500); font-size: 11px">No logs recorded</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<button class="km-btn km-btn-ot" style="border-color: var(--km-blue); color: var(--km-blue);" [routerLink]="['/clients', c.id, 'kyc']">
|
||||
<div style="display: flex; gap: 8px">
|
||||
<button
|
||||
class="km-btn km-btn-ot"
|
||||
style="border-color: var(--km-blue); color: var(--km-blue)"
|
||||
[routerLink]="['/clients', c.id, 'kyc']"
|
||||
>
|
||||
<fa-icon icon="user-check"></fa-icon> Do KYC
|
||||
</button>
|
||||
<button class="km-btn km-btn-ot" (click)="openContactDialog(c)">
|
||||
@@ -883,10 +912,14 @@
|
||||
<!-- RESOLVED EXCEPTIONS TAB -->
|
||||
@if (activeExceptionSubTab === 'resolved') {
|
||||
@if (resolvedExceptions.length === 0) {
|
||||
<div class="km-empty" style="padding: 40px; text-align: center;">
|
||||
<div class="km-empty-i" style="font-size: 32px; filter: grayscale(1);">📋</div>
|
||||
<div style="font-weight: 500; font-size: 16px; margin-top: 12px; color: var(--km-g500);">No resolved exceptions</div>
|
||||
<div class="km-empty-s" style="color: var(--km-g500); font-size: 13px; margin-top: 4px;">Resolved KYC exceptions will appear here once verification is completed.</div>
|
||||
<div class="km-empty" style="padding: 40px; text-align: center">
|
||||
<div class="km-empty-i" style="font-size: 32px; filter: grayscale(1)">📋</div>
|
||||
<div style="font-weight: 500; font-size: 16px; margin-top: 12px; color: var(--km-g500)">
|
||||
No resolved exceptions
|
||||
</div>
|
||||
<div class="km-empty-s" style="color: var(--km-g500); font-size: 13px; margin-top: 4px">
|
||||
Resolved KYC exceptions will appear here once verification is completed.
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<table class="km-tbl">
|
||||
@@ -904,12 +937,20 @@
|
||||
@for (c of resolvedExceptions; track c.id) {
|
||||
<tr>
|
||||
<td>
|
||||
<div class="km-an" [routerLink]="['/clients', c.id, 'kyc']" style="cursor: pointer; color: var(--km-blue); text-decoration: underline;">{{ c.name }}</div>
|
||||
<div
|
||||
class="km-an"
|
||||
[routerLink]="['/clients', c.id, 'kyc']"
|
||||
style="cursor: pointer; color: var(--km-blue); text-decoration: underline"
|
||||
>
|
||||
{{ c.name }}
|
||||
</div>
|
||||
<div class="km-ai">{{ c.account }}</div>
|
||||
</td>
|
||||
<td>{{ c.contact.branch }}</td>
|
||||
<td>
|
||||
<div style="font-weight: 500; color: var(--km-g500); text-decoration: line-through;">{{ c.exceptionReason }}</div>
|
||||
<div style="font-weight: 500; color: var(--km-g500); text-decoration: line-through">
|
||||
{{ c.exceptionReason }}
|
||||
</div>
|
||||
<div class="km-small">Resolved On: {{ c.exceptionResolvedOn }}</div>
|
||||
</td>
|
||||
<td>
|
||||
@@ -917,13 +958,16 @@
|
||||
</td>
|
||||
<td>
|
||||
@if (c.contactHistory && c.contactHistory.length > 0) {
|
||||
<div class="contact-logs-list" style="max-height: 80px; overflow-y: auto; font-size: 11px;">
|
||||
<div class="contact-logs-list" style="max-height: 80px; overflow-y: auto; font-size: 11px">
|
||||
@for (log of c.contactHistory; track $index) {
|
||||
<div style="border-bottom: 1px dashed var(--km-g300); padding: 2px 0;">
|
||||
<strong>{{ log.date }} ({{ log.action }}):</strong> {{ log.notes }} <em>— {{ log.officer }}</em>
|
||||
<div style="border-bottom: 1px dashed var(--km-g300); padding: 2px 0">
|
||||
<strong>{{ log.date }} ({{ log.action }}):</strong> {{ log.notes }}
|
||||
<em>— {{ log.officer }}</em>
|
||||
@if (log.platformSent) {
|
||||
@if (log.delivered) {
|
||||
<span class="km-sent-tag km-sent-ok" [title]="log.deliveryMessage">Sent {{ log.destinationMasked }}</span>
|
||||
<span class="km-sent-tag km-sent-ok" [title]="log.deliveryMessage"
|
||||
>Sent {{ log.destinationMasked }}</span
|
||||
>
|
||||
} @else {
|
||||
<span class="km-sent-tag km-sent-fail" [title]="log.deliveryMessage">Not sent</span>
|
||||
}
|
||||
@@ -932,11 +976,23 @@
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<span style="color: var(--km-g500); font-size: 11px;">No logs recorded</span>
|
||||
<span style="color: var(--km-g500); font-size: 11px">No logs recorded</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<span style="display: inline-flex; align-items: center; gap: 4px; background: #e6f4ea; color: #137333; padding: 4px 8px; border-radius: 4px; font-weight: 500; font-size: 12px;">
|
||||
<span
|
||||
style="
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: #e6f4ea;
|
||||
color: #137333;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
"
|
||||
>
|
||||
<fa-icon icon="check-circle"></fa-icon> Exception Resolved
|
||||
</span>
|
||||
</td>
|
||||
@@ -1421,7 +1477,9 @@
|
||||
<label class="hv-label">Contact Channel<span class="hv-req">*</span></label>
|
||||
<select class="hv-input" [(ngModel)]="contactChannel">
|
||||
@for (ch of contactChannels; track ch.value) {
|
||||
<option [value]="ch.value">{{ ch.label }}{{ ch.sends ? ' — sends a notice now' : ' — record only' }}</option>
|
||||
<option [value]="ch.value">
|
||||
{{ ch.label }}{{ ch.sends ? ' — sends a notice now' : ' — record only' }}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
@if (selectedChannelSends) {
|
||||
@@ -1430,14 +1488,12 @@
|
||||
A KYC notice will be sent to the customer when you save.
|
||||
</div>
|
||||
} @else {
|
||||
<div class="hv-hint">
|
||||
Records an action you already took. Nothing is sent to the customer.
|
||||
</div>
|
||||
<div class="hv-hint">Records an action you already took. Nothing is sent to the customer.</div>
|
||||
}
|
||||
@if (selectedChannelUnavailable) {
|
||||
<div class="hv-hint hv-hint-error">
|
||||
This customer has no {{ selectedChannel?.requires === 'email' ? 'email address' : 'mobile number' }} on file —
|
||||
choose a different channel.
|
||||
This customer has no {{ selectedChannel?.requires === 'email' ? 'email address' : 'mobile number' }} on
|
||||
file — choose a different channel.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -1470,11 +1526,7 @@
|
||||
|
||||
<div class="hv-footer">
|
||||
<button class="km-btn" [disabled]="contactSaving" (click)="closeContactDialog()">Cancel</button>
|
||||
<button
|
||||
class="km-btn km-btn-t"
|
||||
[disabled]="contactSaveDisabled"
|
||||
(click)="saveContactLog()"
|
||||
>
|
||||
<button class="km-btn km-btn-t" [disabled]="contactSaveDisabled" (click)="saveContactLog()">
|
||||
{{ contactSaveLabel }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -1486,8 +1538,8 @@
|
||||
@if (resolveDialogOpen && current) {
|
||||
<div class="km-overlay" (click)="closeResolveDialog()">
|
||||
<div class="km-modal hv-modal" (click)="$event.stopPropagation()">
|
||||
<div class="km-mh" style="border-bottom: 2px solid var(--km-green);">
|
||||
<div class="km-mh-av" style="background: var(--km-green);">{{ current.av }}</div>
|
||||
<div class="km-mh" style="border-bottom: 2px solid var(--km-green)">
|
||||
<div class="km-mh-av" style="background: var(--km-green)">{{ current.av }}</div>
|
||||
<div>
|
||||
<div class="km-mh-name">Resolve KYC Exception</div>
|
||||
<div class="km-mh-meta">{{ current.name }} · {{ current.account }}</div>
|
||||
@@ -1496,25 +1548,52 @@
|
||||
</div>
|
||||
|
||||
<div class="km-mbody hv-body">
|
||||
<div style="background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 6px; padding: 12px; margin-bottom: 16px; font-size: 13px; color: #166534;">
|
||||
<div
|
||||
style="
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 13px;
|
||||
color: #166534;
|
||||
"
|
||||
>
|
||||
<strong>Pending Reason:</strong> {{ current.exceptionReason }}
|
||||
@if (current.exceptionRemarks) {
|
||||
<div style="margin-top: 4px; font-style: italic;">"{{ current.exceptionRemarks }}"</div>
|
||||
<div style="margin-top: 4px; font-style: italic">"{{ current.exceptionRemarks }}"</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (showPanResolution) {
|
||||
<div style="border: 1px solid var(--km-g300); border-radius: 6px; padding: 12px; margin-bottom: 16px; background: #fafafa;">
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
|
||||
<div
|
||||
style="
|
||||
border: 1px solid var(--km-g300);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
margin-bottom: 16px;
|
||||
background: #fafafa;
|
||||
"
|
||||
>
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px">
|
||||
<input type="checkbox" id="resolvePanChk" [(ngModel)]="resolvePanActive" />
|
||||
<label for="resolvePanChk" style="font-weight: 600; cursor: pointer; color: var(--km-g800);">Resolve PAN Card Exception</label>
|
||||
<label for="resolvePanChk" style="font-weight: 600; cursor: pointer; color: var(--km-g800)"
|
||||
>Resolve PAN Card Exception</label
|
||||
>
|
||||
</div>
|
||||
@if (resolvePanActive) {
|
||||
<div class="hv-row" style="margin-left: 20px;">
|
||||
<div class="hv-row" style="margin-left: 20px">
|
||||
<label class="hv-label">Verified PAN Number<span class="hv-req">*</span></label>
|
||||
<input type="text" class="hv-input" [(ngModel)]="resolvePanNumber" placeholder="e.g. ABCDE1234F" maxlength="10" style="text-transform: uppercase;" />
|
||||
<input
|
||||
type="text"
|
||||
class="hv-input"
|
||||
[(ngModel)]="resolvePanNumber"
|
||||
placeholder="e.g. ABCDE1234F"
|
||||
maxlength="10"
|
||||
style="text-transform: uppercase"
|
||||
/>
|
||||
@if (resolvePanNumber && resolvePanNumber.length !== 10) {
|
||||
<div style="color: red; font-size: 11px; margin-top: 4px;">PAN must be exactly 10 characters.</div>
|
||||
<div style="color: red; font-size: 11px; margin-top: 4px">PAN must be exactly 10 characters.</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -1522,17 +1601,33 @@
|
||||
}
|
||||
|
||||
@if (showAadhaarResolution) {
|
||||
<div style="border: 1px solid var(--km-g300); border-radius: 6px; padding: 12px; margin-bottom: 16px; background: #fafafa;">
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
|
||||
<div
|
||||
style="
|
||||
border: 1px solid var(--km-g300);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
margin-bottom: 16px;
|
||||
background: #fafafa;
|
||||
"
|
||||
>
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px">
|
||||
<input type="checkbox" id="resolveAadhChk" [(ngModel)]="resolveAadhaarActive" />
|
||||
<label for="resolveAadhChk" style="font-weight: 600; cursor: pointer; color: var(--km-g800);">Resolve Aadhaar Card Exception</label>
|
||||
<label for="resolveAadhChk" style="font-weight: 600; cursor: pointer; color: var(--km-g800)"
|
||||
>Resolve Aadhaar Card Exception</label
|
||||
>
|
||||
</div>
|
||||
@if (resolveAadhaarActive) {
|
||||
<div class="hv-row" style="margin-left: 20px;">
|
||||
<div class="hv-row" style="margin-left: 20px">
|
||||
<label class="hv-label">Verified Aadhaar Number<span class="hv-req">*</span></label>
|
||||
<input type="text" class="hv-input" [(ngModel)]="resolveAadhaarNumber" placeholder="e.g. 597855025299" maxlength="12" />
|
||||
<input
|
||||
type="text"
|
||||
class="hv-input"
|
||||
[(ngModel)]="resolveAadhaarNumber"
|
||||
placeholder="e.g. 597855025299"
|
||||
maxlength="12"
|
||||
/>
|
||||
@if (resolveAadhaarNumber && resolveAadhaarNumber.length !== 12) {
|
||||
<div style="color: red; font-size: 11px; margin-top: 4px;">Aadhaar must be exactly 12 digits.</div>
|
||||
<div style="color: red; font-size: 11px; margin-top: 4px">Aadhaar must be exactly 12 digits.</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -1540,13 +1635,23 @@
|
||||
}
|
||||
|
||||
@if (showGeneralResolution) {
|
||||
<div style="border: 1px solid var(--km-g300); border-radius: 6px; padding: 12px; margin-bottom: 16px; background: #fafafa;">
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
|
||||
<div
|
||||
style="
|
||||
border: 1px solid var(--km-g300);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
margin-bottom: 16px;
|
||||
background: #fafafa;
|
||||
"
|
||||
>
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px">
|
||||
<input type="checkbox" id="resolveGenChk" [(ngModel)]="resolveGeneralActive" />
|
||||
<label for="resolveGenChk" style="font-weight: 600; cursor: pointer; color: var(--km-g800);">Resolve General Exception</label>
|
||||
<label for="resolveGenChk" style="font-weight: 600; cursor: pointer; color: var(--km-g800)"
|
||||
>Resolve General Exception</label
|
||||
>
|
||||
</div>
|
||||
@if (resolveGeneralActive) {
|
||||
<div style="margin-left: 20px;">
|
||||
<div style="margin-left: 20px">
|
||||
<div class="hv-row">
|
||||
<label class="hv-label">Verification Document Type<span class="hv-req">*</span></label>
|
||||
<select class="hv-input" [(ngModel)]="resolveGeneralDocumentType">
|
||||
@@ -1555,7 +1660,12 @@
|
||||
</div>
|
||||
<div class="hv-row">
|
||||
<label class="hv-label">Document Identification Number<span class="hv-req">*</span></label>
|
||||
<input type="text" class="hv-input" [(ngModel)]="resolveGeneralDocumentNumber" placeholder="Enter verified document number" />
|
||||
<input
|
||||
type="text"
|
||||
class="hv-input"
|
||||
[(ngModel)]="resolveGeneralDocumentNumber"
|
||||
placeholder="Enter verified document number"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -1575,11 +1685,7 @@
|
||||
|
||||
<div class="hv-footer">
|
||||
<button class="km-btn" (click)="closeResolveDialog()">Cancel</button>
|
||||
<button
|
||||
class="km-btn km-btn-g"
|
||||
[disabled]="!isResolveValid"
|
||||
(click)="saveResolveKyc()"
|
||||
>
|
||||
<button class="km-btn km-btn-g" [disabled]="!isResolveValid" (click)="saveResolveKyc()">
|
||||
Complete KYC & Resolve Exception
|
||||
</button>
|
||||
</div>
|
||||
@@ -1590,4 +1696,3 @@
|
||||
<!-- TOAST -->
|
||||
<div class="km-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1717,9 +1717,11 @@
|
||||
&::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 2px;
|
||||
|
||||
@@ -642,7 +642,12 @@ export class KycManagementComponent implements OnInit {
|
||||
private mapException(item: any, resolved: boolean): KmClient {
|
||||
return {
|
||||
id: item.clientId?.toString(),
|
||||
av: (item.clientDisplayName || 'Client').split(' ').map((n: string) => n[0]).join('').substring(0, 2).toUpperCase(),
|
||||
av: (item.clientDisplayName || 'Client')
|
||||
.split(' ')
|
||||
.map((n: string) => n[0])
|
||||
.join('')
|
||||
.substring(0, 2)
|
||||
.toUpperCase(),
|
||||
name: item.clientDisplayName,
|
||||
meta: `${item.clientAccountNo || `CLI-${item.clientId}`} · Savings A/c · ${item.clientOfficeName || 'Bhopal Main'}`,
|
||||
account: item.clientAccountNo || `CLI-${item.clientId}`,
|
||||
@@ -710,17 +715,16 @@ export class KycManagementComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
get notYetContactedCount(): number {
|
||||
return this.exceptionRows.filter(c => !c.contactHistory || c.contactHistory.length === 0).length;
|
||||
return this.exceptionRows.filter((c) => !c.contactHistory || c.contactHistory.length === 0).length;
|
||||
}
|
||||
|
||||
get panMismatchesCount(): number {
|
||||
return this.exceptionRows.filter(c => c.exceptionReason?.includes('PAN')).length;
|
||||
return this.exceptionRows.filter((c) => c.exceptionReason?.includes('PAN')).length;
|
||||
}
|
||||
|
||||
get aadhaarFailuresCount(): number {
|
||||
return this.exceptionRows.filter(c => c.exceptionReason?.includes('Aadhaar')).length;
|
||||
return this.exceptionRows.filter((c) => c.exceptionReason?.includes('Aadhaar')).length;
|
||||
}
|
||||
|
||||
showSection(section: 'alerts' | 'notif' | 'audit' | 'status' | 'exceptions'): void {
|
||||
@@ -743,7 +747,7 @@ export class KycManagementComponent implements OnInit {
|
||||
|
||||
/** The channel currently picked in the dialog. */
|
||||
get selectedChannel(): { value: string; label: string; sends: boolean; requires?: 'mobile' | 'email' } | undefined {
|
||||
return this.contactChannels.find(c => c.value === this.contactChannel);
|
||||
return this.contactChannels.find((c) => c.value === this.contactChannel);
|
||||
}
|
||||
|
||||
/** True when saving will transmit a notice to the customer, not merely record one. */
|
||||
@@ -787,7 +791,10 @@ export class KycManagementComponent implements OnInit {
|
||||
.subscribe({
|
||||
next: (log: any) => {
|
||||
this.contactSaving = false;
|
||||
client.contactHistory = [...(client.contactHistory || []), this.mapContactLog(log)];
|
||||
client.contactHistory = [
|
||||
...(client.contactHistory || []),
|
||||
this.mapContactLog(log)
|
||||
];
|
||||
|
||||
// The request succeeding does not mean the customer was reached — a gateway failure comes
|
||||
// back as HTTP 200 with delivered=false, so report what actually happened.
|
||||
@@ -884,7 +891,20 @@ export class KycManagementComponent implements OnInit {
|
||||
|
||||
const today = new Date();
|
||||
const day = today.getDate().toString().padStart(2, '0');
|
||||
const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
||||
const months = [
|
||||
'January',
|
||||
'February',
|
||||
'March',
|
||||
'April',
|
||||
'May',
|
||||
'June',
|
||||
'July',
|
||||
'August',
|
||||
'September',
|
||||
'October',
|
||||
'November',
|
||||
'December'
|
||||
];
|
||||
const monthStr = months[today.getMonth()];
|
||||
const year = today.getFullYear();
|
||||
const dateStr = `${day} ${monthStr} ${year}`;
|
||||
@@ -939,7 +959,7 @@ export class KycManagementComponent implements OnInit {
|
||||
|
||||
this.clientsService.updateClientKyc(clientId, kycId, resolvedKycData).subscribe({
|
||||
next: () => {
|
||||
const msg = isFullyResolved
|
||||
const msg = isFullyResolved
|
||||
? `KYC Exception fully resolved and completed for ${this.current?.name}`
|
||||
: `KYC Exception partially resolved for ${this.current?.name}`;
|
||||
this.toast(msg);
|
||||
|
||||
@@ -1069,10 +1069,12 @@ export function buildForm15ghPdf(r: Form15ghRecord, kind: Form15Kind): jsPDF {
|
||||
y = paintPara(
|
||||
c,
|
||||
y,
|
||||
`Declaration under Section ${kind === '15H' ? '197A(1C)' : '197A(1) / 197A(1A)'
|
||||
} of the Income-tax Act, 1961, for non-deduction of tax at source on certain incomes — ${kind === '15H'
|
||||
? 'applicable to resident individuals aged 60 years or above'
|
||||
: 'applicable to resident individuals (other than companies / firms) below 60 years'
|
||||
`Declaration under Section ${
|
||||
kind === '15H' ? '197A(1C)' : '197A(1) / 197A(1A)'
|
||||
} of the Income-tax Act, 1961, for non-deduction of tax at source on certain incomes — ${
|
||||
kind === '15H'
|
||||
? 'applicable to resident individuals aged 60 years or above'
|
||||
: 'applicable to resident individuals (other than companies / firms) below 60 years'
|
||||
}.`
|
||||
);
|
||||
|
||||
|
||||
+522
-522
File diff suppressed because it is too large
Load Diff
@@ -52,304 +52,306 @@
|
||||
|
||||
@if (viewMode === 'demo') {
|
||||
<!-- KPI strip -->
|
||||
<div class="cert-kpis">
|
||||
<div class="cert-kpi cert-kpi--blue">
|
||||
<fa-icon icon="user" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">{{ countGeneralBand() | number }}</div>
|
||||
<div class="cert-kpi-l">General band (under 60)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi cert-kpi--amber">
|
||||
<fa-icon icon="user-tie" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">{{ countSeniorBand() | number }}</div>
|
||||
<div class="cert-kpi-l">Senior citizen band (60+)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi cert-kpi--green">
|
||||
<fa-icon icon="check-circle" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">{{ countFiled() | number }}</div>
|
||||
<div class="cert-kpi-l">Filed this FY</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi cert-kpi--teal">
|
||||
<fa-icon icon="coins" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">₹{{ totalInterest() | number: '1.0-0' }}</div>
|
||||
<div class="cert-kpi-l">Projected Interest</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi cert-kpi--green">
|
||||
<fa-icon icon="shield-alt" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">₹{{ totalTdsSaved() | number: '1.0-0' }}</div>
|
||||
<div class="cert-kpi-l">TDS Saved</div>
|
||||
</div>
|
||||
</div>
|
||||
@if (countIneligible() > 0) {
|
||||
<div class="cert-kpi cert-kpi--amber">
|
||||
<fa-icon icon="exclamation-triangle" class="cert-kpi-ic"></fa-icon>
|
||||
<div class="cert-kpis">
|
||||
<div class="cert-kpi cert-kpi--blue">
|
||||
<fa-icon icon="user" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">{{ countIneligible() | number }}</div>
|
||||
<div class="cert-kpi-l">Not Eligible</div>
|
||||
<div class="cert-kpi-n">{{ countGeneralBand() | number }}</div>
|
||||
<div class="cert-kpi-l">General band (under 60)</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="cert-filters">
|
||||
<div class="cert-filter">
|
||||
<label>Customer / Customer ID / PAN</label>
|
||||
<div class="cert-input-ic">
|
||||
<fa-icon icon="search"></fa-icon>
|
||||
<input type="text" [(ngModel)]="search" placeholder="e.g. CUST556677 or customer name" />
|
||||
<div class="cert-kpi cert-kpi--amber">
|
||||
<fa-icon icon="user-tie" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">{{ countSeniorBand() | number }}</div>
|
||||
<div class="cert-kpi-l">Senior citizen band (60+)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi cert-kpi--green">
|
||||
<fa-icon icon="check-circle" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">{{ countFiled() | number }}</div>
|
||||
<div class="cert-kpi-l">Filed this FY</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi cert-kpi--teal">
|
||||
<fa-icon icon="coins" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">₹{{ totalInterest() | number: '1.0-0' }}</div>
|
||||
<div class="cert-kpi-l">Projected Interest</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi cert-kpi--green">
|
||||
<fa-icon icon="shield-alt" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">₹{{ totalTdsSaved() | number: '1.0-0' }}</div>
|
||||
<div class="cert-kpi-l">TDS Saved</div>
|
||||
</div>
|
||||
</div>
|
||||
@if (countIneligible() > 0) {
|
||||
<div class="cert-kpi cert-kpi--amber">
|
||||
<fa-icon icon="exclamation-triangle" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">{{ countIneligible() | number }}</div>
|
||||
<div class="cert-kpi-l">Not Eligible</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="cert-filter">
|
||||
<label>Threshold Band</label>
|
||||
<select [(ngModel)]="bandFilter">
|
||||
<option value="">All bands</option>
|
||||
<option value="GENERAL">General (below 60)</option>
|
||||
<option value="SENIOR">Senior citizen (60+)</option>
|
||||
</select>
|
||||
<!-- Filters -->
|
||||
<div class="cert-filters">
|
||||
<div class="cert-filter">
|
||||
<label>Customer / Customer ID / PAN</label>
|
||||
<div class="cert-input-ic">
|
||||
<fa-icon icon="search"></fa-icon>
|
||||
<input type="text" [(ngModel)]="search" placeholder="e.g. CUST556677 or customer name" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cert-filter">
|
||||
<label>Threshold Band</label>
|
||||
<select [(ngModel)]="bandFilter">
|
||||
<option value="">All bands</option>
|
||||
<option value="GENERAL">General (below 60)</option>
|
||||
<option value="SENIOR">Senior citizen (60+)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="cert-filter">
|
||||
<label>Status</label>
|
||||
<select [(ngModel)]="statusFilter">
|
||||
<option value="">All statuses</option>
|
||||
<option value="FILED">Filed</option>
|
||||
<option value="PENDING">Pending</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="cert-filter">
|
||||
<label>Eligibility</label>
|
||||
<select [(ngModel)]="ineligibleOnly">
|
||||
<option [ngValue]="false">All customers</option>
|
||||
<option [ngValue]="true">Not eligible only</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@if (search || bandFilter || statusFilter || ineligibleOnly) {
|
||||
<button type="button" class="cert-clear" (click)="clearFilters()">
|
||||
<fa-icon icon="times"></fa-icon> Clear
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="cert-filter">
|
||||
<label>Status</label>
|
||||
<select [(ngModel)]="statusFilter">
|
||||
<option value="">All statuses</option>
|
||||
<option value="FILED">Filed</option>
|
||||
<option value="PENDING">Pending</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="cert-filter">
|
||||
<label>Eligibility</label>
|
||||
<select [(ngModel)]="ineligibleOnly">
|
||||
<option [ngValue]="false">All customers</option>
|
||||
<option [ngValue]="true">Not eligible only</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@if (search || bandFilter || statusFilter || ineligibleOnly) {
|
||||
<button type="button" class="cert-clear" (click)="clearFilters()"><fa-icon icon="times"></fa-icon> Clear</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="cert-card">
|
||||
<div class="cert-table-wrap">
|
||||
<table class="cert-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Customer · PAN</th>
|
||||
<th>Form · Band</th>
|
||||
<th>Age</th>
|
||||
<th>Branch</th>
|
||||
<th class="num">Est. Interest</th>
|
||||
<th class="num">Threshold</th>
|
||||
<th class="num">TDS Payable</th>
|
||||
<th class="num">TDS Saved</th>
|
||||
<th>Settlement</th>
|
||||
<th>Status</th>
|
||||
<th class="col-actions">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of filteredRows; track row.record.customerId) {
|
||||
<tr [class.cert-row--warn]="!row.eligibility.eligible">
|
||||
<td>
|
||||
<div class="cert-cust-name">{{ row.record.customerName }}</div>
|
||||
<div class="mono small muted">{{ row.record.customerId }} · PAN {{ row.record.customerPan }}</div>
|
||||
<div class="small muted">DOB {{ row.record.dob | date: 'd MMM y' }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
class="cert-pill"
|
||||
[class.cert-pill--blue]="row.band === 'GENERAL'"
|
||||
[class.cert-pill--amber]="row.band === 'SENIOR'"
|
||||
>
|
||||
{{ row.form === '121' ? 'Form 121' : 'Form ' + row.form }}
|
||||
</span>
|
||||
<div class="small muted">{{ row.band === 'SENIOR' ? 'Senior citizen' : 'General' }}</div>
|
||||
</td>
|
||||
<td class="small">{{ row.age }} yrs</td>
|
||||
<td class="small">{{ row.record.branch }}</td>
|
||||
<td class="num">
|
||||
<strong class="cert-interest">₹{{ row.record.estimatedInterest | number: '1.0-0' }}</strong>
|
||||
</td>
|
||||
<td class="num small muted">₹{{ row.schedule.threshold | number: '1.0-0' }}</td>
|
||||
<td class="num">
|
||||
@if (row.schedule.totalTds > 0) {
|
||||
<strong>₹{{ row.schedule.totalTds | number: '1.0-0' }}</strong>
|
||||
<div class="small muted">@ {{ row.schedule.rateApplied * 100 | number: '1.0-0' }}%</div>
|
||||
} @else {
|
||||
<span class="small muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td class="num">
|
||||
@if (row.tdsSaved > 0) {
|
||||
<strong class="cert-interest">₹{{ row.tdsSaved | number: '1.0-0' }}</strong>
|
||||
} @else {
|
||||
<span class="small muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td class="small">
|
||||
<span class="cert-pill" [class.cert-pill--blue]="row.mode === 'QUARTERLY'">
|
||||
{{ row.mode === 'QUARTERLY' ? 'Quarterly' : 'Year end' }}
|
||||
</span>
|
||||
<div class="small muted">product-driven</div>
|
||||
</td>
|
||||
<td>
|
||||
@if (row.record.status === 'FILED') {
|
||||
<span class="cert-pill cert-pill--green"> <fa-icon icon="check-circle"></fa-icon> Filed </span>
|
||||
@if (row.record.filedOn) {
|
||||
<div class="mono small muted">{{ row.record.filedOn | date: 'd MMM y' }}</div>
|
||||
}
|
||||
} @else {
|
||||
<span class="cert-pill cert-pill--amber"> <fa-icon icon="hourglass-half"></fa-icon> Pending </span>
|
||||
}
|
||||
@if (!row.eligibility.eligible) {
|
||||
<div class="cert-pill cert-pill--amber">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon> Not eligible
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
<td class="col-actions">
|
||||
<div class="cert-row-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="cert-icon-btn"
|
||||
(click)="toggleExpanded(row)"
|
||||
[matTooltip]="isExpanded(row) ? 'Hide quarter-wise' : 'Show quarter-wise'"
|
||||
>
|
||||
<fa-icon [icon]="isExpanded(row) ? 'chevron-up' : 'chevron-down'"></fa-icon>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="cert-icon-btn primary"
|
||||
(click)="viewCertificate(row)"
|
||||
matTooltip="View / Print declaration"
|
||||
>
|
||||
<fa-icon icon="eye"></fa-icon>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="cert-icon-btn"
|
||||
(click)="downloadCertificate(row)"
|
||||
matTooltip="Download PDF"
|
||||
>
|
||||
<fa-icon icon="download"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<!-- Table -->
|
||||
<div class="cert-card">
|
||||
<div class="cert-table-wrap">
|
||||
<table class="cert-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Customer · PAN</th>
|
||||
<th>Form · Band</th>
|
||||
<th>Age</th>
|
||||
<th>Branch</th>
|
||||
<th class="num">Est. Interest</th>
|
||||
<th class="num">Threshold</th>
|
||||
<th class="num">TDS Payable</th>
|
||||
<th class="num">TDS Saved</th>
|
||||
<th>Settlement</th>
|
||||
<th>Status</th>
|
||||
<th class="col-actions">Actions</th>
|
||||
</tr>
|
||||
|
||||
@if (isExpanded(row)) {
|
||||
<tr class="cert-row--detail">
|
||||
<td colspan="11">
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of filteredRows; track row.record.customerId) {
|
||||
<tr [class.cert-row--warn]="!row.eligibility.eligible">
|
||||
<td>
|
||||
<div class="cert-cust-name">{{ row.record.customerName }}</div>
|
||||
<div class="mono small muted">{{ row.record.customerId }} · PAN {{ row.record.customerPan }}</div>
|
||||
<div class="small muted">DOB {{ row.record.dob | date: 'd MMM y' }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
class="cert-pill"
|
||||
[class.cert-pill--blue]="row.band === 'GENERAL'"
|
||||
[class.cert-pill--amber]="row.band === 'SENIOR'"
|
||||
>
|
||||
{{ row.form === '121' ? 'Form 121' : 'Form ' + row.form }}
|
||||
</span>
|
||||
<div class="small muted">{{ row.band === 'SENIOR' ? 'Senior citizen' : 'General' }}</div>
|
||||
</td>
|
||||
<td class="small">{{ row.age }} yrs</td>
|
||||
<td class="small">{{ row.record.branch }}</td>
|
||||
<td class="num">
|
||||
<strong class="cert-interest">₹{{ row.record.estimatedInterest | number: '1.0-0' }}</strong>
|
||||
</td>
|
||||
<td class="num small muted">₹{{ row.schedule.threshold | number: '1.0-0' }}</td>
|
||||
<td class="num">
|
||||
@if (row.schedule.totalTds > 0) {
|
||||
<strong>₹{{ row.schedule.totalTds | number: '1.0-0' }}</strong>
|
||||
<div class="small muted">@ {{ row.schedule.rateApplied * 100 | number: '1.0-0' }}%</div>
|
||||
} @else {
|
||||
<span class="small muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td class="num">
|
||||
@if (row.tdsSaved > 0) {
|
||||
<strong class="cert-interest">₹{{ row.tdsSaved | number: '1.0-0' }}</strong>
|
||||
} @else {
|
||||
<span class="small muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td class="small">
|
||||
<span class="cert-pill" [class.cert-pill--blue]="row.mode === 'QUARTERLY'">
|
||||
{{ row.mode === 'QUARTERLY' ? 'Quarterly' : 'Year end' }}
|
||||
</span>
|
||||
<div class="small muted">product-driven</div>
|
||||
</td>
|
||||
<td>
|
||||
@if (row.record.status === 'FILED') {
|
||||
<span class="cert-pill cert-pill--green"> <fa-icon icon="check-circle"></fa-icon> Filed </span>
|
||||
@if (row.record.filedOn) {
|
||||
<div class="mono small muted">{{ row.record.filedOn | date: 'd MMM y' }}</div>
|
||||
}
|
||||
} @else {
|
||||
<span class="cert-pill cert-pill--amber"> <fa-icon icon="hourglass-half"></fa-icon> Pending </span>
|
||||
}
|
||||
@if (!row.eligibility.eligible) {
|
||||
<div class="cert-note cert-note--warn">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
<div>
|
||||
<strong>Declaration cannot be accepted</strong>
|
||||
<ul>
|
||||
@for (reason of row.eligibility.reasons; track reason) {
|
||||
<li>{{ reason }}</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@for (note of row.eligibility.unassessed; track note) {
|
||||
<div class="cert-note">
|
||||
<fa-icon icon="info-circle"></fa-icon>
|
||||
<div>{{ note }}</div>
|
||||
</div>
|
||||
}
|
||||
@for (note of row.schedule.notes; track note) {
|
||||
<div class="cert-note">
|
||||
<fa-icon icon="info-circle"></fa-icon>
|
||||
<div>{{ note }}</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<table class="cert-table cert-table--inner">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Quarter</th>
|
||||
<th class="num">Interest</th>
|
||||
<th class="num">Cumulative</th>
|
||||
<th class="num">Liability to date</th>
|
||||
<th class="num">TDS this quarter</th>
|
||||
<th>Return due</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (line of row.schedule.lines; track line.quarter) {
|
||||
<tr [class.cert-row--warn]="line.thresholdCrossedHere">
|
||||
<td class="small">
|
||||
{{ line.label }}
|
||||
@if (line.thresholdCrossedHere) {
|
||||
<span class="cert-pill cert-pill--amber">threshold crossed</span>
|
||||
}
|
||||
@if (line.declarationActive) {
|
||||
<span class="cert-pill cert-pill--green">Form 121 active</span>
|
||||
}
|
||||
</td>
|
||||
<td class="num">₹{{ line.interest | number: '1.0-0' }}</td>
|
||||
<td class="num">₹{{ line.cumInterest | number: '1.0-0' }}</td>
|
||||
<td class="num">₹{{ line.cumLiability | number: '1.0-0' }}</td>
|
||||
<td class="num">
|
||||
@if (line.tds !== 0) {
|
||||
<strong>₹{{ line.tds | number: '1.0-0' }}</strong>
|
||||
} @else {
|
||||
<span class="small muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td class="small muted">{{ line.dueDate }}</td>
|
||||
</tr>
|
||||
}
|
||||
<tr>
|
||||
<td class="small"><strong>Total</strong></td>
|
||||
<td class="num">
|
||||
<strong>₹{{ row.schedule.totalInterest | number: '1.0-0' }}</strong>
|
||||
</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td class="num">
|
||||
<strong>₹{{ row.schedule.totalTds | number: '1.0-0' }}</strong>
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@if (row.schedule.shieldedInterest > 0) {
|
||||
<div class="small muted">
|
||||
₹{{ row.schedule.shieldedInterest | number: '1.0-0' }} paid without deduction on the strength of
|
||||
the declaration — reportable in the quarterly {{ statute.statement }} return.
|
||||
<div class="cert-pill cert-pill--amber">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon> Not eligible
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
<td class="col-actions">
|
||||
<div class="cert-row-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="cert-icon-btn"
|
||||
(click)="toggleExpanded(row)"
|
||||
[matTooltip]="isExpanded(row) ? 'Hide quarter-wise' : 'Show quarter-wise'"
|
||||
>
|
||||
<fa-icon [icon]="isExpanded(row) ? 'chevron-up' : 'chevron-down'"></fa-icon>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="cert-icon-btn primary"
|
||||
(click)="viewCertificate(row)"
|
||||
matTooltip="View / Print declaration"
|
||||
>
|
||||
<fa-icon icon="eye"></fa-icon>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="cert-icon-btn"
|
||||
(click)="downloadCertificate(row)"
|
||||
matTooltip="Download PDF"
|
||||
>
|
||||
<fa-icon icon="download"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@if (isExpanded(row)) {
|
||||
<tr class="cert-row--detail">
|
||||
<td colspan="11">
|
||||
@if (!row.eligibility.eligible) {
|
||||
<div class="cert-note cert-note--warn">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
<div>
|
||||
<strong>Declaration cannot be accepted</strong>
|
||||
<ul>
|
||||
@for (reason of row.eligibility.reasons; track reason) {
|
||||
<li>{{ reason }}</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@for (note of row.eligibility.unassessed; track note) {
|
||||
<div class="cert-note">
|
||||
<fa-icon icon="info-circle"></fa-icon>
|
||||
<div>{{ note }}</div>
|
||||
</div>
|
||||
}
|
||||
@for (note of row.schedule.notes; track note) {
|
||||
<div class="cert-note">
|
||||
<fa-icon icon="info-circle"></fa-icon>
|
||||
<div>{{ note }}</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<table class="cert-table cert-table--inner">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Quarter</th>
|
||||
<th class="num">Interest</th>
|
||||
<th class="num">Cumulative</th>
|
||||
<th class="num">Liability to date</th>
|
||||
<th class="num">TDS this quarter</th>
|
||||
<th>Return due</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (line of row.schedule.lines; track line.quarter) {
|
||||
<tr [class.cert-row--warn]="line.thresholdCrossedHere">
|
||||
<td class="small">
|
||||
{{ line.label }}
|
||||
@if (line.thresholdCrossedHere) {
|
||||
<span class="cert-pill cert-pill--amber">threshold crossed</span>
|
||||
}
|
||||
@if (line.declarationActive) {
|
||||
<span class="cert-pill cert-pill--green">Form 121 active</span>
|
||||
}
|
||||
</td>
|
||||
<td class="num">₹{{ line.interest | number: '1.0-0' }}</td>
|
||||
<td class="num">₹{{ line.cumInterest | number: '1.0-0' }}</td>
|
||||
<td class="num">₹{{ line.cumLiability | number: '1.0-0' }}</td>
|
||||
<td class="num">
|
||||
@if (line.tds !== 0) {
|
||||
<strong>₹{{ line.tds | number: '1.0-0' }}</strong>
|
||||
} @else {
|
||||
<span class="small muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td class="small muted">{{ line.dueDate }}</td>
|
||||
</tr>
|
||||
}
|
||||
<tr>
|
||||
<td class="small"><strong>Total</strong></td>
|
||||
<td class="num">
|
||||
<strong>₹{{ row.schedule.totalInterest | number: '1.0-0' }}</strong>
|
||||
</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td class="num">
|
||||
<strong>₹{{ row.schedule.totalTds | number: '1.0-0' }}</strong>
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@if (row.schedule.shieldedInterest > 0) {
|
||||
<div class="small muted">
|
||||
₹{{ row.schedule.shieldedInterest | number: '1.0-0' }} paid without deduction on the strength of
|
||||
the declaration — reportable in the quarterly {{ statute.statement }} return.
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
@if (filteredRows.length === 0) {
|
||||
<tr>
|
||||
<td colspan="11" class="cert-empty">
|
||||
<fa-icon icon="info-circle"></fa-icon>
|
||||
No declarations match the current filters.
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
@if (filteredRows.length === 0) {
|
||||
<tr>
|
||||
<td colspan="11" class="cert-empty">
|
||||
<fa-icon icon="info-circle"></fa-icon>
|
||||
No declarations match the current filters.
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -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';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user