Compare commits

..
2 Commits
Author SHA1 Message Date
nitesh b609f19e9f Merge branch 'dev' of https://git.lincpay.tech/CBSNexora/cbsnexora into dev
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
2026-08-06 11:45:46 +05:30
niteshandClaude Sonnet 5 7f836f6f71 feat(shares): add Share Account Closure maker-checker workflow
Clerk submits a closure request only once a share account holds zero
shares; manager validates (shares still zero, account active, no active
loan/guarantee, KYC/AML standing, maker!=checker) and approves, which
executes the real Fineract "close" command. The direct native "Close"
action is removed from the account menu — it silently force-redeemed any
remaining shares, bypassing the withdrawal approval flow entirely — and
replaced with an "Eligible for Closure" banner. When a withdrawal approval
brings a share account's holding to zero, a closure request is now
auto-submitted (still requires manager approval) so the clerk doesn't have
to separately open "New Share Closure Request".

Also fixes an unrelated build break introduced by a merged commit: a
missing `suppressMonthlyAnnualLink` property declaration, and two files
(branch-scope.service.ts, gl-reports-data.ts) that existed on `demo` but
were never brought into `dev` alongside the new TDS report components
that depend on them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 11:43:56 +05:30
211 changed files with 16687 additions and 49091 deletions
+1 -1
View File
@@ -70,7 +70,7 @@
{
"type": "initial",
"maximumWarning": "15mb",
"maximumError": "17mb"
"maximumError": "16mb"
},
{
"type": "anyComponentStyle",
@@ -1,70 +0,0 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<h2 class="mk-title" [class.mk-title--str]="isEscalation">
<fa-icon [icon]="isEscalation ? 'exclamation-triangle' : 'check-circle'"></fa-icon>
{{ isEscalation ? 'Mark as Suspicious' : 'Mark as Whitelisted' }}
</h2>
<mat-dialog-content>
<div class="mk-entry">
<div>
<label>Journal entry</label><span class="mono">#{{ data.journalEntryId }}</span>
</div>
<div>
<label>Transaction</label><span class="mono">{{ data.transactionId }}</span>
</div>
<div>
<label>Date</label><span>{{ data.transactionDate }}</span>
</div>
<div>
<label>Amount</label><span class="mono">&#8377;{{ data.amount | number: '1.2-2' }}</span>
</div>
<div class="wide">
<label>GL account</label><span>{{ data.glAccountName }}</span>
</div>
</div>
@if (isEscalation) {
<p class="mk-warn">
<fa-icon icon="exclamation-triangle"></fa-icon>
This raises an AML alert against the customer behind this entry and escalates it. An STR case opens for the
Principal Officer with the seven working day FIU-IND clock running. It cannot be undone from this screen.
</p>
} @else {
<p class="mk-note">
Records the entry as reviewed and cleared. No report is filed and the customer's risk rating is untouched.
</p>
}
<label class="mk-field">
<span>Reviewer notes {{ isEscalation ? '(required)' : '(optional)' }}</span>
<textarea
rows="4"
[(ngModel)]="notes"
[placeholder]="
isEscalation
? 'Why is this entry suspicious? What did you check?'
: 'What satisfied you that this entry is in order?'
"
></textarea>
</label>
</mat-dialog-content>
<mat-dialog-actions class="mk-actions">
<button class="mk-btn" (click)="dialogRef.close()">Cancel</button>
<button
class="mk-btn"
[class.mk-btn--str]="isEscalation"
[class.mk-btn--ok]="!isEscalation"
[disabled]="!canSubmit"
(click)="confirm()"
>
{{ isEscalation ? 'Escalate to STR' : 'Whitelist entry' }}
</button>
</mat-dialog-actions>
@@ -1,165 +0,0 @@
/**
* 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;
}
}
}
@@ -1,63 +0,0 @@
/**
* 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,14 +535,6 @@
</div>
</div>
@if (markError) {
<div class="gl-mark-error">
<fa-icon icon="exclamation-triangle"></fa-icon>
<span>{{ markError }}</span>
<button class="gl-mark-error__close" (click)="markError = ''">&times;</button>
</div>
}
<div class="gl-table-shell">
<mat-table [dataSource]="dataSource" matSort class="gl-mat-table">
<!-- #ID -->
@@ -700,50 +692,6 @@
</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,17 +1328,6 @@
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;
@@ -1567,112 +1556,6 @@
}
}
// ── 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,20 +41,9 @@ 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,
@@ -98,8 +87,6 @@ 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);
@@ -142,7 +129,6 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView
'manualEntry',
'createdByUserName',
'submittedOnDate',
'amlMark',
'actions'
];
dayWiseColumns = [
@@ -171,14 +157,6 @@ 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;
@@ -234,103 +212,6 @@ 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() {
-22
View File
@@ -34,28 +34,6 @@ 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[]> {
-10
View File
@@ -64,12 +64,7 @@ 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';
@@ -176,12 +171,7 @@ export function HttpLoaderFactory(http: HttpClient) {
DeafModule,
DicgcModule,
FiuModule,
GlReportsModule,
SftModule,
InvestmentModule,
NpaModule,
ReconciliationModule,
LoanOriginationModule,
DigitalModule,
LetterMgmtModule,
AccountsModule,
@@ -1,292 +0,0 @@
<!--
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>
@@ -1,138 +0,0 @@
/**
* 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;
}
-88
View File
@@ -1,88 +0,0 @@
/**
* 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);
}
+32 -11
View File
@@ -62,6 +62,38 @@ const routes: Routes = [
),
data: { title: 'My Disbursement Requests', breadcrumb: 'My Disbursement Requests' }
},
{
path: 'share-withdrawal-request',
loadComponent: () =>
import('../clerk/share-withdrawal-request/share-withdrawal-request.component').then(
(m) => m.ShareWithdrawalRequestComponent
),
data: { title: 'New Share Withdrawal Request', breadcrumb: 'New Share Withdrawal Request', mode: 'create' }
},
{
path: 'share-withdrawal-requests',
loadComponent: () =>
import('../clerk/my-share-withdrawal-requests/my-share-withdrawal-requests.component').then(
(m) => m.MyShareWithdrawalRequestsComponent
),
data: { title: 'My Share Requests', breadcrumb: 'My Share Requests' }
},
{
path: 'share-closure-request',
loadComponent: () =>
import('../clerk/share-closure-request/share-closure-request.component').then(
(m) => m.ShareClosureRequestComponent
),
data: { title: 'New Share Closure Request', breadcrumb: 'New Share Closure Request', mode: 'create' }
},
{
path: 'share-closure-requests',
loadComponent: () =>
import('../clerk/my-share-closure-requests/my-share-closure-requests.component').then(
(m) => m.MyShareClosureRequestsComponent
),
data: { title: 'My Share Closure Requests', breadcrumb: 'My Share Closure Requests' }
},
{
path: 'account-facilities',
loadComponent: () =>
@@ -179,17 +211,6 @@ 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'
}
}
]
}
@@ -0,0 +1,53 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
/** HTTP service for the Share Closure maker-checker workflow — mirrors
* ClerkShareWithdrawalService, adapted for closing an already-zero-share account. */
@Injectable({ providedIn: 'root' })
export class ClerkShareClosureService {
private http = inject(HttpClient);
submit(data: any): Observable<any> {
return this.http.post('/clerk/share-closure-requests', data);
}
getMyRequests(status?: string, offset = 0, limit = 20, fromDate?: string, toDate?: string): Observable<any> {
let params = new HttpParams().set('offset', String(offset)).set('limit', String(limit));
if (status) params = params.set('status', status);
if (fromDate) params = params.set('fromDate', fromDate);
if (toDate) params = params.set('toDate', toDate);
return this.http.get('/clerk/share-closure-requests/my', { params });
}
getPendingRequests(status = 'PENDING', offset = 0, limit = 20, fromDate?: string, toDate?: string): Observable<any> {
let params = new HttpParams().set('status', status).set('offset', String(offset)).set('limit', String(limit));
if (fromDate) params = params.set('fromDate', fromDate);
if (toDate) params = params.set('toDate', toDate);
return this.http.get('/clerk/share-closure-requests', { params });
}
getById(id: number): Observable<any> {
return this.http.get(`/clerk/share-closure-requests/${id}`);
}
validate(id: number): Observable<any> {
return this.http.post(`/clerk/share-closure-requests/${id}/validate`, {});
}
approve(id: number): Observable<any> {
return this.http.post(`/clerk/share-closure-requests/${id}/approve`, {});
}
reject(id: number, reason: string): Observable<any> {
return this.http.post(`/clerk/share-closure-requests/${id}/reject`, { reason });
}
}
@@ -0,0 +1,53 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
/** HTTP service for the Share Withdrawal (redeem shares) maker-checker workflow — mirrors
* ClerkWithdrawalService (cash withdrawal), adapted for shares. */
@Injectable({ providedIn: 'root' })
export class ClerkShareWithdrawalService {
private http = inject(HttpClient);
submit(data: any): Observable<any> {
return this.http.post('/clerk/share-withdrawal-requests', data);
}
getMyRequests(status?: string, offset = 0, limit = 20, fromDate?: string, toDate?: string): Observable<any> {
let params = new HttpParams().set('offset', String(offset)).set('limit', String(limit));
if (status) params = params.set('status', status);
if (fromDate) params = params.set('fromDate', fromDate);
if (toDate) params = params.set('toDate', toDate);
return this.http.get('/clerk/share-withdrawal-requests/my', { params });
}
getPendingRequests(status = 'PENDING', offset = 0, limit = 20, fromDate?: string, toDate?: string): Observable<any> {
let params = new HttpParams().set('status', status).set('offset', String(offset)).set('limit', String(limit));
if (fromDate) params = params.set('fromDate', fromDate);
if (toDate) params = params.set('toDate', toDate);
return this.http.get('/clerk/share-withdrawal-requests', { params });
}
getById(id: number): Observable<any> {
return this.http.get(`/clerk/share-withdrawal-requests/${id}`);
}
validate(id: number): Observable<any> {
return this.http.post(`/clerk/share-withdrawal-requests/${id}/validate`, {});
}
approve(id: number): Observable<any> {
return this.http.post(`/clerk/share-withdrawal-requests/${id}/approve`, {});
}
reject(id: number, reason: string): Observable<any> {
return this.http.post(`/clerk/share-withdrawal-requests/${id}/reject`, { reason });
}
}
@@ -146,7 +146,7 @@
[class.ws-f__ctl--req]="!row.paymentTypeId"
>
<option [ngValue]="null">— Select —</option>
@for (pt of paymentTypes; track pt.id) {
@for (pt of paymentTypesForRow(row); track pt.id) {
<option [ngValue]="pt.id">{{ pt.name }}</option>
}
</select>
@@ -231,8 +231,11 @@
<span class="ws-caption">Payment instrument</span>
<div class="ws-group__fields">
<div class="ws-f ws-f--amt">
<label class="ws-f__lbl" title="Transaction Amount">
AMOUNT
<label
class="ws-f__lbl"
[title]="row.acTyp === 'SHR' ? 'Number of Shares' : 'Transaction Amount'"
>
{{ row.acTyp === 'SHR' ? 'SHARE NO.' : 'AMOUNT' }}
@if (!isClerk && activeWithdrawalRequest) {
<span class="ws-amt-locked-badge" title="Amount locked — approved withdrawal">🔒</span>
}
@@ -242,20 +245,48 @@
[class.ws-amt-wrap--cash]="isCashPayment && selectedRow === row"
[class.ws-amt-wrap--locked]="!isClerk && !!activeWithdrawalRequest"
>
<span class="ws-amt-prefix">₹</span>
@if (row.acTyp !== 'SHR') {
<span class="ws-amt-prefix">₹</span>
}
<input
class="ws-f__ctl ws-amt-input"
type="number"
[value]="row.amount ?? ''"
(input)="row.amount = +$any($event.target).value"
(input)="row.amount = +$any($event.target).value; syncShareLinkedAmounts()"
(click)="$event.stopPropagation()"
placeholder="0.00"
[placeholder]="row.acTyp === 'SHR' ? '0' : '0.00'"
[readonly]="!isClerk && !!activeWithdrawalRequest"
[class.ws-amt-input--locked]="!isClerk && !!activeWithdrawalRequest"
/>
</div>
@if (row.acTyp === 'SHR' && rowSharePrices[row.acNo]) {
<span class="ws-f__hint"
>= ₹{{ (row.amount ?? 0) * rowSharePrices[row.acNo] | number: '1.2-2' }} @ ₹{{
rowSharePrices[row.acNo] | number: '1.2-2'
}}/share</span
>
}
</div>
@if (row.typ !== 'Withdrawal Slip') {
@if (row.acTyp === 'SHR' && row.typ === 'Enter Bank Transfer') {
<div class="ws-f ws-f--cheqno">
<label class="ws-f__lbl" title="Board Resolution Number">RESOLUTION NO.</label>
<input
class="ws-f__ctl"
[(ngModel)]="row.resolutionNumber"
(click)="$event.stopPropagation()"
placeholder="e.g. BR-2026-014"
/>
</div>
<div class="ws-f ws-f--cheqdt">
<label class="ws-f__lbl" title="Date of Meeting">MEETING DATE</label>
<input
class="ws-f__ctl"
type="date"
[(ngModel)]="row.meetingDate"
(click)="$event.stopPropagation()"
/>
</div>
} @else if (row.typ !== 'Withdrawal Slip') {
<div class="ws-f ws-f--cheqno">
<label class="ws-f__lbl" title="Cheque Number">CHEQUE NO.</label>
<input
@@ -712,6 +743,32 @@
'—'
}}</span>
</div>
@if (selectedRow?.acTyp === 'SHR') {
<div class="te-field">
<span class="te-label">No. of Shares Held</span
><span class="te-value te-mono">{{ accountDetails.summary?.totalApprovedShares ?? 0 }}</span>
</div>
<div class="te-field">
<span class="te-label">Market Price / Share</span
><span class="te-value te-mono"
>{{ currencySymbol }}{{ accountDetails.currentMarketPrice | number: '1.2-2' }}</span
>
</div>
<div class="te-field">
<span class="te-label">Resolution No.</span
><span class="te-value te-mono">{{ accountDetails.resolutionNumber || '—' }}</span>
</div>
<div class="te-field">
<span class="te-label">Linked Savings A/C No.</span
><span class="te-value te-mono">{{ accountDetails.savingsAccountNumber || '—' }}</span>
</div>
@if (accountDetails.linkedLoanAccountNo) {
<div class="te-field">
<span class="te-label">Linked Loan A/C No.</span
><span class="te-value te-mono">{{ accountDetails.linkedLoanAccountNo }}</span>
</div>
}
}
</div>
<div class="te-panel__header te-panel__header--mt">
@@ -724,7 +781,13 @@
<div class="te-panel__body">
<div class="te-balance-row">
<span class="te-bal-num">1.</span>
<span class="te-bal-label">{{ accountDetails?.glCode ? 'GL Balance' : 'Current Balance' }}</span>
<span class="te-bal-label">{{
selectedRow?.acTyp === 'SHR'
? 'Share Balance'
: accountDetails?.glCode
? 'GL Balance'
: 'Current Balance'
}}</span>
<span class="te-bal-value te-bal-value--cr"
>{{ currencySymbol }}{{ currentBalance | number: '1.2-2' }}</span
>
@@ -1281,7 +1344,7 @@
[class.ws-f__ctl--req]="!row.paymentTypeId"
>
<option [ngValue]="null">— Select —</option>
@for (pt of paymentTypes; track pt.id) {
@for (pt of paymentTypesForRow(row); track pt.id) {
<option [ngValue]="pt.id">{{ pt.name }}</option>
}
</select>
@@ -1360,7 +1423,7 @@
<div class="ws-group__fields">
<div class="ws-f ws-f--amt">
<label class="ws-f__lbl">
AMOUNT
{{ row.acTyp === 'SHR' ? 'SHARE NO.' : 'AMOUNT' }}
@if (!isClerk && activeWithdrawalRequest) {
<span class="ws-amt-locked-badge" title="Amount locked — approved withdrawal">🔒</span>
}
@@ -1370,27 +1433,51 @@
[class.ws-amt-wrap--cash]="isCashPayment && selectedRow === row"
[class.ws-amt-wrap--locked]="!isClerk && !!activeWithdrawalRequest"
>
<span class="ws-amt-prefix">₹</span>
@if (row.acTyp !== 'SHR') {
<span class="ws-amt-prefix">₹</span>
}
<input
class="ws-f__ctl ws-amt-input"
type="number"
[value]="row.amount ?? ''"
(input)="row.amount = +$any($event.target).value"
(input)="row.amount = +$any($event.target).value; syncShareLinkedAmounts()"
(click)="$event.stopPropagation()"
placeholder="0.00"
[placeholder]="row.acTyp === 'SHR' ? '0' : '0.00'"
[readonly]="!isClerk && !!activeWithdrawalRequest"
[class.ws-amt-input--locked]="!isClerk && !!activeWithdrawalRequest"
/>
</div>
@if (row.acTyp === 'SHR' && rowSharePrices[row.acNo]) {
<span class="ws-f__hint"
>= ₹{{ (row.amount ?? 0) * rowSharePrices[row.acNo] | number: '1.2-2' }} @ ₹{{
rowSharePrices[row.acNo] | number: '1.2-2'
}}/share</span
>
}
</div>
<div class="ws-f ws-f--cheqno">
<label class="ws-f__lbl">CHEQUE NO.</label>
<input class="ws-f__ctl ws-f__ctl--mono" [(ngModel)]="row.chequeNo" placeholder="—" />
</div>
<div class="ws-f ws-f--cheqdt">
<label class="ws-f__lbl">CHEQ. DATE</label>
<input class="ws-f__ctl" [(ngModel)]="row.cheqDate" placeholder="dd-mm-yyyy" />
</div>
@if (row.acTyp === 'SHR' && row.typ === 'Enter Bank Transfer') {
<div class="ws-f ws-f--cheqno">
<label class="ws-f__lbl">RESOLUTION NO.</label>
<input
class="ws-f__ctl"
[(ngModel)]="row.resolutionNumber"
placeholder="e.g. BR-2026-014"
/>
</div>
<div class="ws-f ws-f--cheqdt">
<label class="ws-f__lbl">MEETING DATE</label>
<input class="ws-f__ctl" type="date" [(ngModel)]="row.meetingDate" />
</div>
} @else {
<div class="ws-f ws-f--cheqno">
<label class="ws-f__lbl">CHEQUE NO.</label>
<input class="ws-f__ctl ws-f__ctl--mono" [(ngModel)]="row.chequeNo" placeholder="—" />
</div>
<div class="ws-f ws-f--cheqdt">
<label class="ws-f__lbl">CHEQ. DATE</label>
<input class="ws-f__ctl" [(ngModel)]="row.cheqDate" placeholder="dd-mm-yyyy" />
</div>
}
<div class="ws-f ws-f--tok">
<label class="ws-f__lbl">TOK. NO.</label>
<input
@@ -961,6 +961,14 @@ input.ws-amt-input--locked {
flex-shrink: 0;
}
.ws-f__hint {
display: block;
margin-top: 2px;
font-size: 10px;
color: $ws-muted;
font-weight: 500;
}
.ws-amt-input {
flex: 1;
height: 100%;
@@ -21,6 +21,7 @@ import { AuthenticationService } from '../../core/authentication/authentication.
import { ClerkUiStateService, ClerkSavedAccount } from '../clerk-ui-state.service';
import { ClerkWithdrawalService } from '../clerk-withdrawal.service';
import { ClerkDisbursementService } from '../clerk-disbursement.service';
import { ClerkShareWithdrawalService } from '../clerk-share-withdrawal.service';
import { CashierDepositService } from '../cashier-deposit.service';
import { SettingsService } from '../../settings/settings.service';
import {
@@ -47,6 +48,10 @@ interface TxnRow {
routingCode: string;
/** DD issuance charges/commission — only used when TYPE=DD, TXN TP=Cash, AC.TP=GL */
ddCharges: number | null;
/** Only used when this row is AC.TP=SHR inside an Enter Bank Transfer to its linked savings
* account — governs the share redemption (see submitShareRedemptionTransfer()). */
resolutionNumber?: string;
meetingDate?: string;
}
interface Denomination {
@@ -753,6 +758,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
private clerkUiState = inject(ClerkUiStateService);
private clerkWithdrawalService = inject(ClerkWithdrawalService);
private clerkDisbursementService = inject(ClerkDisbursementService);
private clerkShareWithdrawalService = inject(ClerkShareWithdrawalService);
private cashierDepositService = inject(CashierDepositService);
readonly router = inject(Router);
private settingsService = inject(SettingsService);
@@ -769,9 +775,38 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
loadingAccount = false;
accountError = '';
/** Share redemption settlement is always a book-transfer to the linked savings account — never a
* cash handover. A Share (SHR) row's own TXN TP. never offers a cash payment type, and neither
* does its paired CR (savings) row in "Enter Bank Transfer" mode — that CR row's payment type is
* the one that actually governs how the settlement credit posts (see submitShareRedemptionTransfer()).
* Every other account type/mode keeps the full, unfiltered list. */
paymentTypesForRow(row: TxnRow): any[] {
if (row.acTyp === 'SHR') {
return this.paymentTypes.filter((pt: any) => !pt.isCashPayment);
}
const isShareSettlementCredit =
row.typ === 'Enter Bank Transfer' &&
row.dbCr === 'Cr' &&
this.rows.some((r) => r.typ === 'Enter Bank Transfer' && r.dbCr === 'Dr' && r.acTyp === 'SHR');
if (isShareSettlementCredit) {
return this.paymentTypes.filter((pt: any) => !pt.isCashPayment);
}
return this.paymentTypes;
}
// Per-row account id/name map: acNo → Fineract id/name (used for multi-row disbursement)
rowAccountIds: Record<string, number> = {};
rowAccountNames: Record<string, string> = {};
// Per-row Share account market price, keyed by acNo — lets a Share row's "SHARE NO." field
// auto-fill the settlement amount on any other row added alongside it (calculator convenience
// only; a Share row still can never itself be saved from this screen — see accountTypeConfig).
rowSharePrices: Record<string, number> = {};
// Per-row client id, keyed by acNo — needed to submit a Share Withdrawal Request from a row
// that isn't necessarily the currently "selected" one.
rowClientIds: Record<string, number> = {};
// Per-row linked savings account number, keyed by the Share account's own acNo — used to make
// sure a Share-to-linked-account Bank Transfer actually credits the right account.
rowLinkedSavingsAcNo: Record<string, string> = {};
// Full account + client data
accountDetails: any = null;
@@ -1205,6 +1240,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
};
this.rows.push(mirror);
this.selectedRow = mirror;
if (active.acTyp === 'SHR') {
this.syncShareLinkedAmounts();
}
return;
}
const row: TxnRow = {
@@ -1499,6 +1537,182 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
});
}
/** One-step Share redemption from Bank Transfer mode: DR row is the Share account (its
* "SHARE NO." field), CR row must be that account's own linked savings account. Submits a
* governed Share Withdrawal Request (same maker-checker table/flow as the dedicated screen) —
* approval both redeems the shares and settles the linked account automatically. */
private submitShareRedemptionTransfer(shareRow: TxnRow, savingsRow: TxnRow): void {
// Belt-and-braces: the TXN TP. dropdown already hides cash payment types for the CR (savings)
// row (see paymentTypesForRow()), but re-check here too in case its payment type was set before
// that filter applied — settlement is always a book-transfer to the linked savings account, and
// it's the CR row's payment type that actually governs how that credit posts, never the DR
// (Share) row's, which has no real payment-type concept.
const settlementPaymentType = this.paymentTypes.find((p: any) => p.id === savingsRow.paymentTypeId);
if (!savingsRow.paymentTypeId) {
this.showAlert('error', 'Settlement Payment Type Required', [
'Select a Payment Type on the CR (savings account) row — it governs how the settlement credit posts.'
]);
return;
}
if (settlementPaymentType?.isCashPayment) {
this.showAlert('error', 'Cash Not Allowed for Share Transactions', [
'Share redemption settlement is always a book-transfer to the linked savings account, never cash.',
'Select a non-cash Payment Type on the CR row (e.g. Transfer or Share Redemption Transfer).'
]);
return;
}
const shareAccountId = this.rowAccountIds[shareRow.acNo];
if (!shareAccountId) {
this.showAlert('error', 'Share Account Not Loaded', [`Please load account details for: ${shareRow.acNo}`]);
return;
}
if (!this.rowAccountIds[savingsRow.acNo]) {
this.showAlert('error', 'Savings Account Not Loaded', [`Please load account details for: ${savingsRow.acNo}`]);
return;
}
const linkedAcNo = this.rowLinkedSavingsAcNo[shareRow.acNo];
const enteredAcNo = savingsRow.acNo?.trim();
const matchesLinked =
!!linkedAcNo &&
(linkedAcNo === enteredAcNo || linkedAcNo.endsWith(enteredAcNo) || savingsRow.ecsAcNo === linkedAcNo);
if (linkedAcNo && !matchesLinked) {
this.showAlert('error', 'Wrong Credit Account', [
`The CR account must be this Share account's own linked savings account: ${linkedAcNo}`,
`You entered: ${enteredAcNo}`
]);
return;
}
if (!linkedAcNo) {
this.showAlert('error', 'No Linked Savings Account', [
'This Share account has no linked savings account configured — settlement has nowhere to go.'
]);
return;
}
const shares = Number(shareRow.amount) || 0;
if (shares <= 0) {
this.showAlert('error', 'Share No. Required', ['Enter the number of shares to withdraw (greater than zero).']);
return;
}
if (!shareRow.resolutionNumber?.trim()) {
this.showAlert('error', 'Board Resolution Number Required', [
'A share withdrawal must be authorised by a Board Resolution.'
]);
return;
}
if (!shareRow.meetingDate) {
this.showAlert('error', 'Date of Meeting Required', ['Enter the Board meeting date that passed the resolution.']);
return;
}
const today = new Date();
const requestedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
// Full snapshot of the CR (settlement) row exactly as the clerk filled it in — saved verbatim
// for audit purposes. Business logic still only uses settlementPaymentTypeId above; the rest of
// these fields are not re-read by the backend, they are the audit record of what was on screen.
const settlementRowSnapshot = {
branch: savingsRow.br,
accountType: savingsRow.acTyp,
accountNo: savingsRow.acNo,
ecsAccountNo: savingsRow.ecsAcNo || null,
dbCr: savingsRow.dbCr,
amount: savingsRow.amount ?? null,
chequeNo: savingsRow.chequeNo || null,
chequeDate: savingsRow.cheqDate || null,
tokenNo: savingsRow.tokNo || null,
description: savingsRow.description || null,
bankName: savingsRow.bankName || null,
routingCode: savingsRow.routingCode || null,
paymentTypeId: savingsRow.paymentTypeId,
paymentTypeName: settlementPaymentType?.name ?? null
};
const payload = {
shareAccountId,
accountNo: shareRow.acNo,
clientId: this.rowClientIds[shareRow.acNo] ?? null,
clientName: this.rowAccountNames[shareRow.acNo] ?? null,
requestedShares: shares,
requestedDate,
resolutionNumber: shareRow.resolutionNumber.trim(),
meetingDate: shareRow.meetingDate,
description: shareRow.description || null,
settlementPaymentTypeId: savingsRow.paymentTypeId,
settlementRowSnapshot
};
const sharePrice = this.rowSharePrices[shareRow.acNo] ?? 0;
const confirmLines = [
`Share Account: ${shareRow.acNo} · Client: ${this.rowAccountNames[shareRow.acNo] ?? '—'}`,
`Shares to Withdraw: ${shares} @ ₹${sharePrice.toLocaleString('en-IN', { minimumFractionDigits: 2 })} = ₹${(shares * sharePrice).toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
`Board Resolution No.: ${payload.resolutionNumber}`,
`Date of Meeting: ${payload.meetingDate}`,
`Settlement Credit To: ${linkedAcNo}`,
`Settlement Payment Type: ${settlementPaymentType?.name ?? '—'}`,
...(settlementRowSnapshot.ecsAccountNo ? [`ECS A/C No.: ${settlementRowSnapshot.ecsAccountNo}`] : []),
...(settlementRowSnapshot.chequeNo ? [`Cheque No.: ${settlementRowSnapshot.chequeNo}`] : []),
...(settlementRowSnapshot.chequeDate ? [`Cheque Date: ${settlementRowSnapshot.chequeDate}`] : []),
...(settlementRowSnapshot.tokenNo ? [`Token No.: ${settlementRowSnapshot.tokenNo}`] : []),
...(settlementRowSnapshot.description ? [`CR Row Remarks: ${settlementRowSnapshot.description}`] : []),
'',
'Validation checks:',
` ✓ Share account and linked savings account both loaded`,
` ✓ CR account matches this Share account's linked savings account`,
` ✓ Settlement Payment Type is non-cash`,
` ✓ Board Resolution Number and Date of Meeting provided`,
'',
'Pending Manager approval — redemption and settlement both post together on approval.'
];
this.showConfirmAlert('info', 'Confirm Share Withdrawal Request', confirmLines, 'Submit for Approval', () =>
this.doSubmitShareRedemptionTransfer(payload, shareRow, shares, linkedAcNo)
);
}
private doSubmitShareRedemptionTransfer(
payload: Record<string, unknown>,
shareRow: TxnRow,
shares: number,
linkedAcNo: string
): void {
this.saving = true;
this.clerkShareWithdrawalService
.submit(payload)
.pipe(
catchError((err: any) => {
const msg =
err?.error?.errors?.[0]?.defaultUserMessage ??
err?.error?.developerMessage ??
err?.error?.defaultUserMessage ??
'Share withdrawal submission failed. Please try again.';
this.showAlert('error', 'Submission Failed', [msg]);
this.saving = false;
return of(null);
})
)
.subscribe((res: any) => {
this.saving = false;
if (res) {
this.showAlert('success', 'Share Withdrawal Submitted', [
`${shares} share(s) from ${shareRow.acNo} → settlement to ${linkedAcNo} on approval.`,
'Pending Manager approval. Track status in "My Share Requests".'
]);
this.rows = [];
this.rowAccountIds = {};
this.rowAccountNames = {};
this.rowSharePrices = {};
this.rowClientIds = {};
this.rowLinkedSavingsAcNo = {};
this.addRow();
this.selectedRow = this.rows[0];
this.accountDetails = null;
this.clientDetails = null;
}
});
}
/** Cashier enters a token number → look up the APPROVED withdrawal request for auto-fill. */
onTokNoBlur(row: TxnRow): void {
if (this.isClerk || !row.tokNo?.trim()) return;
@@ -1535,6 +1749,25 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
});
}
/** When exactly one row on screen is a Share account with a known market price, auto-fills every
* OTHER row's amount as (shares entered on that row) × (its market price) — a calculator
* convenience for filling the paired settlement entry. The Share row itself still can never be
* saved from this screen (see the SHR guard in confirmSubmit()) — this only helps compute the
* correct amount to carry over into the separate manual Deposit Slip entry. */
syncShareLinkedAmounts(): void {
const shareRows = this.rows.filter((r) => r.acTyp?.toUpperCase() === 'SHR' && this.rowSharePrices[r.acNo]);
if (shareRows.length !== 1) return;
const shareRow = shareRows[0];
const price = this.rowSharePrices[shareRow.acNo] ?? 0;
const shares = Number(shareRow.amount) || 0;
const total = Math.round(shares * price * 100) / 100;
this.rows.forEach((r) => {
if (r !== shareRow) {
r.amount = total;
}
});
}
onAcNoBlur(row: TxnRow, index: number): void {
if (!row.acNo.trim()) return;
this.selectedRow = row;
@@ -1633,12 +1866,14 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
if (token !== this.searchToken) return; // stale result — newer search started
const allItems: any[] = res?.pageItems ?? (Array.isArray(res) ? res : []);
const f = cfg.matchField;
// Exact match first, then suffix/partial match (backend may return by last-N-digits)
// Exact match first, then suffix/partial match. Some account types (e.g. Share — its
// list endpoint only accepts offset/limit, no accountNo filter at all) always return the
// FULL unfiltered list rather than a server-side-narrowed one, so a partial match can't be
// gated on "exactly one row came back" — it never would be for those types.
const exactMatch = allItems.find((x: any) => x[f] === acNo);
const partialMatch =
!exactMatch && allItems.length === 1
? allItems.find((x: any) => x[f]?.endsWith(acNo) || x[f]?.includes(acNo))
: null;
const partialMatch = !exactMatch
? (allItems.find((x: any) => x[f]?.endsWith(acNo)) ?? allItems.find((x: any) => x[f]?.includes(acNo)))
: null;
const match = exactMatch ?? partialMatch ?? null;
if (match?.id) {
const clientIdFromSearch = acTyp !== 'GL' && match.clientId != null ? String(match.clientId) : null;
@@ -1659,6 +1894,17 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
siAccountType: number;
txnBase: string;
txnSearchSupported: boolean;
/** True when this account type has no separate REST transactions sub-resource at all (e.g.
* Share accounts — Fineract returns transactions embedded on the account detail response
* itself via ?associations=all, never at /accounts/share/{id}/transactions). Skips the
* "today's transactions" HTTP call entirely instead of hitting a 404. */
txnFetchUnsupported?: boolean;
/** True when this account type has no standing-instruction "from account type" mapping on
* the backend at all (PortfolioAccountType only defines LOAN/SAVINGS/CURRENT — Share isn't
* a valid standing-instruction source account). Skips that HTTP call entirely instead of
* sending an fromAccountType value the backend's query-builder silently can't match,
* which otherwise produces a malformed "WHERE <empty>" SQL statement server-side. */
siFetchUnsupported?: boolean;
maxLimit: number;
skipGlobalSearch: boolean;
listQueryParam: string;
@@ -1693,6 +1939,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
siAccountType: 4,
txnBase: '/accounts/share',
txnSearchSupported: false,
txnFetchUnsupported: true,
siFetchUnsupported: true,
maxLimit: 500,
skipGlobalSearch: true,
listQueryParam: 'accountNo',
@@ -1805,6 +2053,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
siAccountType: number;
txnBase: string;
txnSearchSupported: boolean;
txnFetchUnsupported?: boolean;
siFetchUnsupported?: boolean;
detailParams?: string;
},
token: number,
@@ -1837,13 +2087,29 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.rowAccountIds[this.selectedRow.acNo] = acc.id;
this.rowAccountNames[this.selectedRow.acNo] =
acc.name ?? acc.displayName ?? acc.glAccountName ?? acc.accountNo ?? '';
this.rowClientIds[this.selectedRow.acNo] = acc.clientId ?? null;
}
if (this.selectedRow) {
this.selectedRow.ecsAcNo = acc.glCode ?? acc.savingsAccountNumber ?? acc.accountNo ?? '';
// Share accounts: ECS field is the share account's own number — acc.savingsAccountNumber
// is a different, unrelated account (the linked settlement savings account) and must
// never be shown here.
this.selectedRow.ecsAcNo =
acTyp?.toUpperCase() === 'SHR'
? (acc.accountNo ?? '')
: (acc.glCode ?? acc.savingsAccountNumber ?? acc.accountNo ?? '');
}
if (this.isClerk) this.clerkUiState.setFacilities(this.facilities, acNo, acTyp);
this.computeSameDayNeftRtgs(acc.transactions);
this.computeSameDayChequeClearing(acc.transactions);
if (acTyp?.toUpperCase() === 'SHR' && this.selectedRow?.acNo) {
// Keyed by the row's own typed acNo (e.g. "0011"), not the resolved full account number
// (e.g. "000000011") — rowAccountIds/rowAccountNames above use the same key, and every
// lookup elsewhere (syncShareLinkedAmounts, submitShareRedemptionTransfer, the template's
// rowSharePrices[row.acNo]) reads via row.acNo, so this must match or every lookup misses.
this.rowSharePrices[this.selectedRow.acNo] = Number(acc.currentMarketPrice) || 0;
this.rowLinkedSavingsAcNo[this.selectedRow.acNo] = acc.savingsAccountNumber ?? '';
this.syncShareLinkedAmounts();
}
return true;
};
@@ -1913,18 +2179,22 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
documents: this.http.get<any[]>(`/clients/${clientId}/documents`).pipe(catchError(() => of([]))),
notes: this.http.get<any>(`/savings/${accountId}/notes`).pipe(catchError(() => of([]))),
linkedAcc: this.http.get<any>(`/clients/${clientId}/accounts`).pipe(catchError(() => of(null))),
si: this.http
.get<any>(`/standinginstructions?fromAccountType=${cfg.siAccountType}&fromAccountId=${accountId}&limit=10`)
.pipe(catchError(() => of({ pageItems: [] }))),
todayTxns: cfg.txnSearchSupported
? this.http
.get<any>(
`${cfg.txnBase}/${accountId}/transactions/search?fromDate=${todayStr}&toDate=${todayStr}&limit=20`
)
.pipe(catchError(() => of({ pageItems: [] })))
si: cfg.siFetchUnsupported
? of({ pageItems: [] })
: this.http
.get<any>(`${cfg.txnBase}/${accountId}/transactions?limit=50`)
.pipe(catchError(() => of({ pageItems: [] })))
.get<any>(`/standinginstructions?fromAccountType=${cfg.siAccountType}&fromAccountId=${accountId}&limit=10`)
.pipe(catchError(() => of({ pageItems: [] }))),
todayTxns: cfg.txnFetchUnsupported
? of({ pageItems: [] })
: cfg.txnSearchSupported
? this.http
.get<any>(
`${cfg.txnBase}/${accountId}/transactions/search?fromDate=${todayStr}&toDate=${todayStr}&limit=20`
)
.pipe(catchError(() => of({ pageItems: [] })))
: this.http
.get<any>(`${cfg.txnBase}/${accountId}/transactions?limit=50`)
.pipe(catchError(() => of({ pageItems: [] })))
});
if (clientIdHint) {
@@ -2089,6 +2359,11 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
// ── Balance getters ────────────────────────────────────────────────────────
get currentBalance(): number {
if (this.selectedRow?.acTyp === 'SHR') {
const shares = Number(this.accountDetails?.summary?.totalApprovedShares ?? 0);
const unitPrice = Number(this.accountDetails?.currentMarketPrice ?? 0);
return shares * unitPrice;
}
return this.accountDetails?.summary?.accountBalance ?? this.accountDetails?.organizationRunningBalance ?? 0;
}
@@ -2502,6 +2777,23 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
// Share redemption via linked-account transfer: DR=Share account, CR=its linked savings
// account. A Share account can never be debited by a plain ledger transfer (no such Fineract
// command exists) — so this exact one-DR/one-CR shape is routed to submitShareRedemptionTransfer()
// instead, which submits a governed Share Withdrawal Request through the same maker-checker
// table as the dedicated screen; approval both redeems the shares and settles the linked account.
const shareDrRows = drRows.filter((r) => r.acTyp === 'SHR');
if (shareDrRows.length > 0) {
if (drRows.length !== 1 || crRows.length !== 1) {
this.showAlert('error', 'Unsupported Share Transfer', [
'A Share redemption transfer supports exactly one DR row (the Share account) and one CR row (its linked savings account).'
]);
return;
}
this.submitShareRedemptionTransfer(shareDrRows[0], crRows[0]);
return;
}
// Bank transfer: only savings (SD) or current (CA/CB) accounts allowed
const allowedTypes = new Set([
'SD',
@@ -3004,6 +3296,24 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
const accountId = this.accountDetails.id;
const cfg = this.accountTypeConfig(row.acTyp);
// Share accounts must never go through this generic Deposit/Withdrawal Slip flow — both
// buildDepositPayload() and buildWithdrawalPayload() hardcode accountType: 'SAVINGS' and send
// savingsAccountId: this.accountDetails.id, which for a Share account is the SHARE account's
// own numeric id, not a real savings account id. Approving that request would call
// savingsAccountWithdrawal/Deposit against whatever savings account happens to share that same
// numeric id — a completely unrelated account. Share purchase/redemption must go through the
// dedicated Buy Shares / Share Withdrawal Request screens, which carry the correct governance
// (resolution number, meeting date, minimum-tenure and linked-loan checks).
if (row.acTyp === 'SHR') {
this.showAlert('error', 'Not Supported for Share Accounts', [
isDeposit
? 'Share purchases must be made from the Buy Shares screen on the Share Account itself.'
: 'Share withdrawal (redeem shares) must be submitted from "New Share Withdrawal Request" in the Clerk Dashboard.',
'This generic Deposit/Withdrawal Slip does not carry the Board Resolution Number or the governance checks required for shares.'
]);
return;
}
// GL withdrawal → direct journal entry (no maker-checker)
// GL deposit → falls through to maker-checker block below
if (row.acTyp === 'GL' && !isDeposit) {
@@ -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|CBSNEXORA 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|BHARAT URBAN 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|CBSNEXORA 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|BHARAT URBAN 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|CBSNEXORA 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|BHARAT URBAN 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||||
+77
View File
@@ -0,0 +1,77 @@
/**
* 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/.
*/
/* Small shared layout/status-pill classes for the Share Withdrawal maker-checker screens —
* self-contained (this branch has no separate "investment" module to borrow them from). */
.inv-form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
}
.inv-form-field {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 11px;
font-weight: 600;
color: #64748b;
input,
select,
textarea {
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;
}
textarea {
height: auto;
padding: 8px 10px;
resize: vertical;
}
}
.inv-form-actions {
display: flex;
gap: 10px;
padding: 12px 16px 16px;
}
.inv-badge {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
background: #f1f5f9;
color: #475569;
&--active {
background: #fef3c7;
color: #92400e;
}
&--matured {
background: #dcfce7;
color: #166534;
}
&--written_off {
background: #fee2e2;
color: #991b1b;
}
}
@@ -0,0 +1,69 @@
<!--
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">CLERK · SHARE CLOSURE</div>
<h1 class="pg-title">My Share Closure Requests</h1>
<p class="pg-sub">Share account closure requests you have submitted, and their approval status.</p>
</div>
<div class="pg-toolbar">
<button type="button" class="pg-btn pg-btn--primary" (click)="newRequest()">New Share Closure Request</button>
</div>
</header>
<div class="pg-card">
<div class="inv-form-grid" style="padding: 12px 16px">
<label class="inv-form-field">
<span>Status</span>
<select [(ngModel)]="filterStatus" (change)="onFilterChange()">
@for (opt of statusOptions; track opt.value) {
<option [value]="opt.value">{{ opt.label }}</option>
}
</select>
</label>
</div>
<table class="pg-table">
<thead>
<tr>
<th>Account No</th>
<th>Client</th>
<th>Closed Date</th>
<th>Submitted At</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@if (loading) {
<tr>
<td colspan="5" class="pg-empty">Loading…</td>
</tr>
} @else if (loaded && requests.length === 0) {
<tr>
<td colspan="5" class="pg-empty">No share closure requests found.</td>
</tr>
} @else {
@for (r of requests; track r.id) {
<tr>
<td>
<span class="pg-chip">{{ r.account_no }}</span>
</td>
<td>{{ r.client_name }}</td>
<td>{{ formatDateTime(r.closed_date) }}</td>
<td>{{ formatDateTime(r.submitted_at) }}</td>
<td>
<span [class]="statusClass(r.status)">{{ statusLabel(r.status) }}</span>
</td>
</tr>
}
}
</tbody>
</table>
</div>
</div>
@@ -0,0 +1,112 @@
/**
* 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 { Router, RouterModule } from '@angular/router';
import { catchError } from 'rxjs/operators';
import { of } from 'rxjs';
import { ClerkShareClosureService } from '../../cashier/clerk-share-closure.service';
/** Clerk Dashboard "My Share Closure Requests" — lists the share account closure requests this
* clerk has submitted and their approval status. Mirrors MyShareWithdrawalRequestsComponent. */
@Component({
selector: 'mifosx-my-share-closure-requests',
templateUrl: './my-share-closure-requests.component.html',
styleUrls: [
'../../cashier/cashier-shared.scss',
'../clerk-shared.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
RouterModule
]
})
export class MyShareClosureRequestsComponent implements OnInit {
private service = inject(ClerkShareClosureService);
private router = inject(Router);
requests: any[] = [];
totalRecords = 0;
loading = false;
loaded = false;
filterStatus = '';
get statusOptions(): { value: string; label: string }[] {
return [
{ value: '', label: 'All' },
{ value: 'PENDING', label: 'Pending' },
{ value: 'APPROVED', label: 'Approved' },
{ value: 'REJECTED', label: 'Rejected' }
];
}
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.service
.getMyRequests(this.filterStatus || undefined, 0, 50)
.pipe(catchError(() => of({ pageItems: [], totalFilteredRecords: 0 })))
.subscribe((res: any) => {
this.requests = res?.pageItems ?? [];
this.totalRecords = Number(res?.totalFilteredRecords ?? 0);
this.loading = false;
this.loaded = true;
});
}
onFilterChange(): void {
this.load();
}
newRequest(): void {
this.router.navigate(['/clerk/share-closure-request']);
}
statusClass(status: string): string {
const map: Record<string, string> = {
PENDING: 'inv-badge--active',
APPROVED: 'inv-badge--matured',
REJECTED: 'inv-badge--written_off'
};
return `inv-badge ${map[status] ?? ''}`;
}
statusLabel(status: string): string {
const map: Record<string, string> = {
PENDING: 'Pending Approval',
APPROVED: 'Approved',
REJECTED: 'Rejected'
};
return map[status] ?? status;
}
formatDateTime(val: any): string {
if (!val) return '—';
try {
const d = new Date(val);
return d.toLocaleString('en-IN', {
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
} catch {
return String(val);
}
}
}
@@ -0,0 +1,73 @@
<!--
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">CLERK · SHARE WITHDRAWAL</div>
<h1 class="pg-title">My Share Requests</h1>
<p class="pg-sub">Share withdrawal requests you have submitted, and their approval status.</p>
</div>
<div class="pg-toolbar">
<button type="button" class="pg-btn pg-btn--primary" (click)="newRequest()">New Share Withdrawal</button>
</div>
</header>
<div class="pg-card">
<div class="inv-form-grid" style="padding: 12px 16px">
<label class="inv-form-field">
<span>Status</span>
<select [(ngModel)]="filterStatus" (change)="onFilterChange()">
@for (opt of statusOptions; track opt.value) {
<option [value]="opt.value">{{ opt.label }}</option>
}
</select>
</label>
</div>
<table class="pg-table">
<thead>
<tr>
<th>Account No</th>
<th>Client</th>
<th class="num">Shares</th>
<th>Resolution No.</th>
<th>Meeting Date</th>
<th>Submitted At</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@if (loading) {
<tr>
<td colspan="7" class="pg-empty">Loading…</td>
</tr>
} @else if (loaded && requests.length === 0) {
<tr>
<td colspan="7" class="pg-empty">No share withdrawal requests found.</td>
</tr>
} @else {
@for (r of requests; track r.id) {
<tr>
<td>
<span class="pg-chip">{{ r.account_no }}</span>
</td>
<td>{{ r.client_name }}</td>
<td class="num">{{ r.requested_shares }}</td>
<td>{{ r.resolution_number }}</td>
<td>{{ formatDateTime(r.meeting_date) }}</td>
<td>{{ formatDateTime(r.submitted_at) }}</td>
<td>
<span [class]="statusClass(r.status)">{{ statusLabel(r.status) }}</span>
</td>
</tr>
}
}
</tbody>
</table>
</div>
</div>
@@ -0,0 +1,112 @@
/**
* 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 { Router, RouterModule } from '@angular/router';
import { catchError } from 'rxjs/operators';
import { of } from 'rxjs';
import { ClerkShareWithdrawalService } from '../../cashier/clerk-share-withdrawal.service';
/** Clerk Dashboard "My Share Requests" — lists the share withdrawal requests this clerk has
* submitted and their approval status. Mirrors MyRequestsComponent/MyDisbursementRequestsComponent. */
@Component({
selector: 'mifosx-my-share-withdrawal-requests',
templateUrl: './my-share-withdrawal-requests.component.html',
styleUrls: [
'../../cashier/cashier-shared.scss',
'../clerk-shared.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
RouterModule
]
})
export class MyShareWithdrawalRequestsComponent implements OnInit {
private service = inject(ClerkShareWithdrawalService);
private router = inject(Router);
requests: any[] = [];
totalRecords = 0;
loading = false;
loaded = false;
filterStatus = '';
get statusOptions(): { value: string; label: string }[] {
return [
{ value: '', label: 'All' },
{ value: 'PENDING', label: 'Pending' },
{ value: 'APPROVED', label: 'Approved' },
{ value: 'REJECTED', label: 'Rejected' }
];
}
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.service
.getMyRequests(this.filterStatus || undefined, 0, 50)
.pipe(catchError(() => of({ pageItems: [], totalFilteredRecords: 0 })))
.subscribe((res: any) => {
this.requests = res?.pageItems ?? [];
this.totalRecords = Number(res?.totalFilteredRecords ?? 0);
this.loading = false;
this.loaded = true;
});
}
onFilterChange(): void {
this.load();
}
newRequest(): void {
this.router.navigate(['/clerk/share-withdrawal-request']);
}
statusClass(status: string): string {
const map: Record<string, string> = {
PENDING: 'inv-badge--active',
APPROVED: 'inv-badge--matured',
REJECTED: 'inv-badge--written_off'
};
return `inv-badge ${map[status] ?? ''}`;
}
statusLabel(status: string): string {
const map: Record<string, string> = {
PENDING: 'Pending Approval',
APPROVED: 'Approved',
REJECTED: 'Rejected'
};
return map[status] ?? status;
}
formatDateTime(val: any): string {
if (!val) return '—';
try {
const d = new Date(val);
return d.toLocaleString('en-IN', {
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
} catch {
return String(val);
}
}
}
@@ -0,0 +1,355 @@
<!--
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">{{ isReview ? 'CHECKER · APPROVAL' : 'CLERK · SHARE CLOSURE' }}</div>
<h1 class="pg-title">{{ pageTitle }}</h1>
<p class="pg-sub">{{ pageSubtitle }}</p>
</div>
<div class="pg-toolbar">
<button type="button" class="pg-btn" (click)="goBack()">
<mat-icon>arrow_back</mat-icon>
{{ isReview ? 'Back to Requests' : 'My Share Closure Requests' }}
</button>
</div>
</header>
<div class="pg-card">
<div class="pos-section-label">Share Account</div>
<div class="inv-form-grid" style="padding: 16px">
@if (!isReview) {
<label class="inv-form-field" style="grid-column: span 2">
<span>* Search Account No. / Client Name (only zero-share accounts are eligible)</span>
<mifosx-search-select
[options]="accountOptions"
[(ngModel)]="selectedAccountId"
(ngModelChange)="onAccountSelected($event)"
placeholder="Search share account no. or client name…"
></mifosx-search-select>
</label>
}
@if (account) {
<label class="inv-form-field">
<span>Account No</span>
<input type="text" [value]="account.accountNo" readonly />
</label>
<label class="inv-form-field">
<span>Client</span>
<input type="text" [value]="account.clientName" readonly />
</label>
<label class="inv-form-field">
<span>Shares Held</span>
<input type="text" [value]="heldShares" readonly />
</label>
}
@if (loadingAccount) {
<div class="pg-empty">Loading account…</div>
}
@if (account && heldShares !== 0 && !isReview) {
<div class="pg-empty" style="grid-column: span 2; color: #b42318">
This account still holds {{ heldShares }} share(s) — all shares must be withdrawn first via the Share
Withdrawal workflow before it becomes eligible for closure.
</div>
}
</div>
</div>
@if (isReview && reviewRequest) {
<div class="pg-card">
<div class="pos-section-label">Client Compliance</div>
<div class="inv-form-grid" style="padding: 16px">
<div class="inv-form-field">
<span>KYC Status</span>
<span
class="swr-chip"
[class.swr-chip--ok]="reviewRequest.client_kyc_verified"
[class.swr-chip--warn]="!reviewRequest.client_kyc_verified"
>
{{ reviewRequest.client_kyc_status_label }}
</span>
</div>
<div class="inv-form-field">
<span>Risk Category</span>
<span
class="swr-chip"
[class.swr-chip--ok]="!reviewRequest.client_risk_high"
[class.swr-chip--warn]="reviewRequest.client_risk_high"
>
{{ reviewRequest.client_risk_category_label }}
</span>
</div>
<div class="inv-form-field">
<span>Open AML Alerts</span>
<span
class="swr-chip"
[class.swr-chip--ok]="!reviewRequest.client_open_aml_alerts"
[class.swr-chip--warn]="reviewRequest.client_open_aml_alerts > 0"
>
{{ reviewRequest.client_open_aml_alerts || 0 }}
</span>
</div>
</div>
</div>
}
@if (account) {
<div class="pg-card">
<div class="pos-section-label">Closure Details</div>
<div class="inv-form-grid" style="padding: 16px">
<label class="inv-form-field">
<span>* Closed Date</span>
<input type="date" [(ngModel)]="closedDate" [disabled]="isReview" />
</label>
<label class="inv-form-field" style="grid-column: span 2">
<span>Note (optional)</span>
<input
type="text"
[(ngModel)]="note"
[disabled]="isReview"
placeholder="e.g. Member exit — all shares redeemed"
/>
</label>
</div>
@if (!isReview) {
<div class="inv-form-actions">
<button
type="button"
class="pg-btn pg-btn--primary"
[disabled]="!isFormValid || submitting"
(click)="openConfirmDialog()"
>
{{ submitting ? 'Submitting…' : 'Review &amp; Submit' }}
</button>
</div>
}
</div>
}
@if (isReview && reviewRequest?.status === 'PENDING') {
<div class="pg-card">
<div class="pos-section-label">Verification</div>
<div class="swrv-trigger">
<div class="swrv-trigger__icon">
<mat-icon>fact_check</mat-icon>
</div>
<div class="swrv-trigger__text">
<div class="swrv-trigger__title">Run a live backend validation</div>
<div class="swrv-trigger__sub">
Re-checks shares held, account status, active loan/guarantee and client compliance before you approve.
</div>
</div>
<button type="button" class="pg-btn pg-btn--primary" (click)="openValidateModal()">
<mat-icon>play_circle</mat-icon>
Validate Request
</button>
</div>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="approveReview()">
<mat-icon>check_circle</mat-icon>
Approve &amp; Close Account
</button>
<button type="button" class="pg-btn pg-btn--danger" (click)="openRejectDialog()">
<mat-icon>cancel</mat-icon>
Reject
</button>
</div>
</div>
}
@if (showValidateModal) {
<div class="swrv-modal">
<div class="swrv-modal__card">
<div class="swrv-modal__header">
<div class="swrv-modal__title">
<span class="swrv-modal__title-icon"><mat-icon>fact_check</mat-icon></span>
Validate Share Closure Request
</div>
<button type="button" class="swrv-modal__close" [disabled]="validating" (click)="closeValidateModal()">
<mat-icon>close</mat-icon>
</button>
</div>
<div class="swrv-modal__body">
<p class="swrv-modal__intro">
Re-checking closure of <strong>{{ account?.accountNo }}</strong> ({{ account?.clientName }}) against the
live account, loan/guarantee and compliance state — not just what was captured at submission time.
</p>
@if (backendChecks && !validating && !validateAllPassed) {
<div class="swrv-modal__warn">
<mat-icon>warning</mat-icon>
<div>
<strong>Some checks need review before approving.</strong>
Scroll down to see which ones failed and why.
</div>
</div>
}
<ol class="swrv-steps">
@for (step of validateSteps; track step.label; let i = $index) {
<li
class="swrv-step"
[class.swrv-step--active]="step.status === 'active'"
[class.swrv-step--done]="step.status === 'done'"
[class.swrv-step--failed]="step.status === 'failed'"
>
<div class="swrv-step__indicator">
@if (step.status === 'done') {
<mat-icon>check</mat-icon>
} @else if (step.status === 'active') {
<mat-icon class="swrv-spin">autorenew</mat-icon>
} @else if (step.status === 'failed') {
<mat-icon>close</mat-icon>
} @else {
<span>{{ i + 1 }}</span>
}
</div>
<div class="swrv-step__content">
<div class="swrv-step__label">{{ step.label }}</div>
<div class="swrv-step__msg">{{ step.detail }}</div>
</div>
</li>
}
</ol>
@if (validating && validateSteps.length === 0) {
<div class="pg-empty">Connecting to server…</div>
}
@if (backendChecks && !validating && validateAllPassed) {
<div class="swrv-modal__result">
<mat-icon>verified</mat-icon>
<span>All checks passed — safe to approve.</span>
</div>
}
</div>
<div class="swrv-modal__footer">
@if (!validating && backendChecks) {
<button type="button" class="pg-btn" (click)="runBackendValidation()">
<mat-icon>refresh</mat-icon>
Re-run
</button>
}
@if (!validating && backendChecks && validateAllPassed) {
<button type="button" class="pg-btn pg-btn--primary" (click)="closeValidateModal(); approveReview()">
<mat-icon>check_circle</mat-icon>
Approve &amp; Close Account
</button>
}
<button type="button" class="pg-btn" [disabled]="validating" (click)="closeValidateModal()">
{{ validating ? 'Validating…' : 'Close' }}
</button>
</div>
</div>
</div>
}
@if (isReview && activityLog.length > 0) {
<div class="pg-card">
<div class="pos-section-label">Activity Log</div>
<table class="pg-table">
<thead>
<tr>
<th>Timestamp</th>
<th>Action</th>
<th>User</th>
<th>Note</th>
</tr>
</thead>
<tbody>
@for (entry of activityLog; track entry.timestamp) {
<tr>
<td>{{ entry.timestamp | date: 'medium' }}</td>
<td>{{ entry.action }}</td>
<td>{{ entry.user }}</td>
<td>{{ entry.note || '—' }}</td>
</tr>
}
</tbody>
</table>
</div>
}
@if (showConfirmDialog) {
<div class="swr-modal-backdrop">
<div class="swr-modal">
<h3>Confirm Share Closure Request</h3>
<p class="pg-sub">Review the details below before submitting for Bank Manager approval.</p>
<table class="pg-table">
<tbody>
<tr>
<td>Account No / Client</td>
<td>{{ account?.accountNo }} — {{ account?.clientName }}</td>
</tr>
<tr>
<td>Shares Held</td>
<td>{{ heldShares }}</td>
</tr>
<tr>
<td>Closed Date</td>
<td>{{ closedDate }}</td>
</tr>
<tr>
<td>Note</td>
<td>{{ note || '—' }}</td>
</tr>
</tbody>
</table>
<div class="pos-section-label" style="margin-top: 12px">Validation Checks</div>
<table class="pg-table">
<tbody>
@for (check of verificationChecks; track check.label) {
<tr>
<td>{{ check.label }}</td>
<td [class.swr-check-pass]="check.pass" [class.swr-check-fail]="!check.pass">
{{ check.pass ? 'Pass — ' : 'Review — ' }}{{ check.detail }}
</td>
</tr>
}
</tbody>
</table>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" [disabled]="submitting" (click)="confirmSubmit()">
{{ submitting ? 'Submitting…' : 'Confirm & Submit' }}
</button>
<button type="button" class="pg-btn" (click)="cancelConfirmDialog()">Back to Edit</button>
</div>
</div>
</div>
}
@if (showRejectDialog) {
<div class="swr-modal-backdrop">
<div class="swr-modal">
<h3>Reject Share Closure Request</h3>
<label class="inv-form-field">
<span>* Rejection Reason</span>
<textarea rows="3" [(ngModel)]="rejectionReason"></textarea>
</label>
<div class="inv-form-actions">
<button
type="button"
class="pg-btn pg-btn--danger"
[disabled]="!rejectionReason.trim()"
(click)="confirmRejectReview()"
>
Confirm Reject
</button>
<button type="button" class="pg-btn" (click)="showRejectDialog = false">Cancel</button>
</div>
</div>
</div>
}
</div>
@@ -0,0 +1,336 @@
/**
* 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, EventEmitter, Input, OnInit, Output, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { catchError } from 'rxjs/operators';
import { of } from 'rxjs';
import { SharesService } from '../../shares/shares.service';
import { ClerkShareClosureService } from '../../cashier/clerk-share-closure.service';
import { SearchSelectComponent, SearchSelectOption } from '../../shared/search-select/search-select.component';
export type ShareClosureMode = 'create' | 'review';
interface ActivityEntry {
timestamp: string;
action: string;
user: string;
note?: string;
}
/**
* Share Account Closure request — maker screen in the Clerk Dashboard. A clerk finds a member's
* Share Account that already holds zero shares (all shares already withdrawn), enters the closed
* date plus an optional note, and submits for Bank Manager approval. This is the ONLY entry point
* a share account closure should go through — approval server-side executes the real Fineract
* "close" command (see ClerkShareClosureRequestApiResource on the backend).
*/
@Component({
selector: 'mifosx-share-closure-request',
templateUrl: './share-closure-request.component.html',
styleUrls: [
'../../cashier/cashier-shared.scss',
'../clerk-shared.scss',
'../share-withdrawal-request/share-withdrawal-request.component.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
RouterModule,
MatIconModule,
MatSnackBarModule,
SearchSelectComponent
]
})
export class ShareClosureRequestComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private sharesService = inject(SharesService);
private closureService = inject(ClerkShareClosureService);
private snackBar = inject(MatSnackBar);
/** When embedded (e.g. opened as a popup from a list screen instead of routed to directly), mode
* and requestId come from these inputs rather than the route, and success/close actions emit
* events instead of navigating away. */
@Input() embedded = false;
@Input() overrideMode: ShareClosureMode | null = null;
@Input() overrideRequestId: number | null = null;
@Output() closeRequested = new EventEmitter<void>();
@Output() actionCompleted = new EventEmitter<void>();
mode: ShareClosureMode = 'create';
requestId: number | null = null;
submitting = false;
loadingAccount = false;
selectedAccountId = '';
accountOptions: SearchSelectOption[] = [];
account: any = null;
closedDate = '';
note = '';
activityLog: ActivityEntry[] = [];
reviewRequest: any = null;
showRejectDialog = false;
rejectionReason = '';
showConfirmDialog = false;
showValidateModal = false;
validating = false;
validateAllPassed = false;
backendChecks: { label: string; pass: boolean; detail: string }[] | null = null;
validateSteps: { label: string; detail: string; pass: boolean; status: 'pending' | 'active' | 'done' | 'failed' }[] =
[];
get heldShares(): number {
return Number(this.account?.summary?.totalApprovedShares ?? this.account?.totalApprovedShares ?? 0);
}
get isFormValid(): boolean {
if (!this.account) return false;
if (this.heldShares !== 0) return false;
if (!this.closedDate) return false;
return true;
}
/** Shown to the clerk before submit and to the manager before approval, so both can verify the same checks. */
get verificationChecks(): { label: string; pass: boolean; detail: string }[] {
return [
{ label: 'No shares currently held', pass: this.heldShares === 0, detail: `${this.heldShares} share(s) held` },
{ label: 'Closed Date provided', pass: !!this.closedDate, detail: this.closedDate || 'Missing' }
];
}
get isReview(): boolean {
return this.mode === 'review';
}
get pageTitle(): string {
return this.isReview ? 'Review Share Closure Request' : 'New Share Closure Request';
}
get pageSubtitle(): string {
return this.isReview
? "Approve or reject the clerk's share account closure request."
: 'Close a Share Account whose shares have already been fully withdrawn, for Bank Manager approval.';
}
ngOnInit(): void {
this.mode = this.overrideMode ?? (this.route.snapshot.data?.['mode'] as ShareClosureMode) ?? 'create';
const idParam = this.overrideRequestId ?? this.route.snapshot.params?.['id'];
this.requestId = idParam ? Number(idParam) : null;
const today = new Date();
this.closedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
this.sharesService
.getShareAccountSummaries()
.pipe(catchError(() => of([])))
.subscribe((rows: any[]) => {
this.accountOptions = (rows || [])
.filter((r) => (r.status?.value || '').toLowerCase() === 'active' && Number(r.totalApprovedShares ?? 0) === 0)
.map((r) => ({
value: String(r.id),
label: `${r.accountNo} — ${r.clientName} (0 shares held)`,
searchText: `${r.accountNo} ${r.clientName}`
}));
});
if (this.requestId && this.mode === 'review') {
this.loadReviewRequest(this.requestId);
}
}
onAccountSelected(accountId: string): void {
this.selectedAccountId = accountId;
if (!accountId) {
this.account = null;
return;
}
this.loadingAccount = true;
this.sharesService
.getSharesAccountData(accountId, false)
.pipe(catchError(() => of(null)))
.subscribe((acc: any) => {
this.account = acc;
this.loadingAccount = false;
});
}
private loadReviewRequest(id: number): void {
this.closureService.getById(id).subscribe({
next: (req: any) => {
this.reviewRequest = req;
this.closedDate = req.closed_date ? String(req.closed_date).split('T')[0].split(' ')[0] : this.closedDate;
this.note = req.note ?? '';
if (req.activity_log) {
try {
this.activityLog = JSON.parse(req.activity_log);
} catch {
this.activityLog = [];
}
}
this.sharesService
.getSharesAccountData(String(req.share_account_id), false)
.pipe(catchError(() => of(null)))
.subscribe((acc: any) => (this.account = acc));
},
error: () => this.snackBar.open('Failed to load share closure request.', 'Close', { duration: 5000 })
});
}
/** "Review before Save" per requirement — opens the confirm dialog instead of submitting directly. */
openConfirmDialog(): void {
if (!this.isFormValid || this.submitting) {
return;
}
this.showConfirmDialog = true;
}
cancelConfirmDialog(): void {
this.showConfirmDialog = false;
}
confirmSubmit(): void {
this.showConfirmDialog = false;
this.submitting = true;
const payload = {
shareAccountId: Number(this.selectedAccountId),
accountNo: this.account?.accountNo,
clientId: this.account?.clientId,
clientName: this.account?.clientName,
closedDate: this.closedDate,
note: this.note
};
this.closureService.submit(payload).subscribe({
next: () => {
this.snackBar.open('Share closure request submitted for approval.', 'Close', { duration: 4000 });
if (this.embedded) {
this.actionCompleted.emit();
} else {
this.router.navigate(['/clerk/dashboard']);
}
},
error: (err) => {
this.submitting = false;
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not submit the request.', 'Close', {
duration: 6000
});
}
});
}
openValidateModal(): void {
if (!this.reviewRequest) return;
this.showValidateModal = true;
this.runBackendValidation();
}
closeValidateModal(): void {
if (this.validating) return;
this.showValidateModal = false;
}
/** Re-checks live backend state (shares still zero, account still active, no active loan/guarantee,
* compliance standing) rather than relying on data already loaded in the browser — catches drift
* since submission. Results are revealed one step at a time for the pipeline-style animation. */
runBackendValidation(): void {
if (!this.reviewRequest || this.validating) return;
this.validating = true;
this.backendChecks = null;
this.validateSteps = [];
this.closureService.validate(this.reviewRequest.id).subscribe({
next: (res: any) => {
const checks = (res?.checks ?? []) as { label: string; pass: boolean; detail: string }[];
this.backendChecks = checks;
this.validateAllPassed = !!res?.allPassed;
this.validateSteps = checks.map((c) => ({ ...c, status: 'pending' as const }));
this.revealValidateSteps(0);
},
error: (err) => {
this.validating = false;
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not run backend validation.', 'Close', {
duration: 6000
});
}
});
}
private revealValidateSteps(index: number): void {
if (index >= this.validateSteps.length) {
this.validating = false;
return;
}
this.validateSteps[index].status = 'active';
setTimeout(() => {
this.validateSteps[index].status = this.validateSteps[index].pass ? 'done' : 'failed';
this.revealValidateSteps(index + 1);
}, 320);
}
approveReview(): void {
if (!this.reviewRequest) return;
this.closureService.approve(this.reviewRequest.id).subscribe({
next: () => {
this.snackBar.open('Share closure approved — account closed.', 'Close', { duration: 6000 });
if (this.embedded) {
this.actionCompleted.emit();
} else {
this.router.navigate(['/checker-inbox-and-tasks/share-closure-requests']);
}
},
error: (err) =>
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not approve the request.', 'Close', {
duration: 6000
})
});
}
openRejectDialog(): void {
this.showRejectDialog = true;
this.rejectionReason = '';
}
confirmRejectReview(): void {
if (!this.reviewRequest || !this.rejectionReason.trim()) return;
this.closureService.reject(this.reviewRequest.id, this.rejectionReason.trim()).subscribe({
next: () => {
this.showRejectDialog = false;
this.snackBar.open('Share closure request rejected.', 'Close', { duration: 4000 });
if (this.embedded) {
this.actionCompleted.emit();
} else {
this.router.navigate(['/checker-inbox-and-tasks/share-closure-requests']);
}
},
error: (err) =>
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not reject the request.', 'Close', {
duration: 6000
})
});
}
goBack(): void {
if (this.embedded) {
this.closeRequested.emit();
return;
}
this.router.navigate(this.isReview ? ['/checker-inbox-and-tasks/share-closure-requests'] : ['/clerk/dashboard']);
}
}
@@ -0,0 +1,431 @@
<!--
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">{{ isReview ? 'CHECKER · APPROVAL' : 'CLERK · SHARE WITHDRAWAL' }}</div>
<h1 class="pg-title">{{ pageTitle }}</h1>
<p class="pg-sub">{{ pageSubtitle }}</p>
</div>
<div class="pg-toolbar">
<button type="button" class="pg-btn" (click)="goBack()">
<mat-icon>arrow_back</mat-icon>
{{ isReview ? 'Back to Requests' : 'My Share Requests' }}
</button>
</div>
</header>
<div class="pg-card">
<div class="pos-section-label">Share Account</div>
<div class="inv-form-grid" style="padding: 16px">
@if (!isReview) {
<label class="inv-form-field" style="grid-column: span 2">
<span>* Search Account No. / Client Name</span>
<mifosx-search-select
[options]="accountOptions"
[(ngModel)]="selectedAccountId"
(ngModelChange)="onAccountSelected($event)"
placeholder="Search share account no. or client name…"
></mifosx-search-select>
</label>
}
@if (account) {
<label class="inv-form-field">
<span>Account No</span>
<input type="text" [value]="account.accountNo" readonly />
</label>
<label class="inv-form-field">
<span>Client</span>
<input type="text" [value]="account.clientName" readonly />
</label>
<label class="inv-form-field">
<span>Shares Held</span>
<input
type="text"
[value]="account.summary?.totalApprovedShares ?? account.totalApprovedShares ?? 0"
readonly
/>
</label>
<label class="inv-form-field">
<span>Unit Price</span>
<input type="text" [value]="'₹' + (unitPrice | number: '1.2-2')" readonly />
</label>
}
@if (loadingAccount) {
<div class="pg-empty">Loading account…</div>
}
</div>
</div>
@if (isReview && reviewRequest) {
<div class="pg-card">
<div class="pos-section-label">Client Compliance</div>
<div class="inv-form-grid" style="padding: 16px">
<div class="inv-form-field">
<span>KYC Status</span>
<span
class="swr-chip"
[class.swr-chip--ok]="reviewRequest.client_kyc_verified"
[class.swr-chip--warn]="!reviewRequest.client_kyc_verified"
>
{{ reviewRequest.client_kyc_status_label }}
</span>
</div>
<div class="inv-form-field">
<span>Risk Category</span>
<span
class="swr-chip"
[class.swr-chip--ok]="!reviewRequest.client_risk_high"
[class.swr-chip--warn]="reviewRequest.client_risk_high"
>
{{ reviewRequest.client_risk_category_label }}
</span>
</div>
<div class="inv-form-field">
<span>Open AML Alerts</span>
<span
class="swr-chip"
[class.swr-chip--ok]="!reviewRequest.client_open_aml_alerts"
[class.swr-chip--warn]="reviewRequest.client_open_aml_alerts > 0"
>
{{ reviewRequest.client_open_aml_alerts || 0 }}
</span>
</div>
</div>
</div>
}
@if (account) {
<div class="pg-card">
<div class="pos-section-label">Withdrawal Details</div>
<div class="inv-form-grid" style="padding: 16px">
<label class="inv-form-field">
<span>* Shares to Withdraw</span>
<input type="number" [(ngModel)]="requestedShares" min="1" [disabled]="isReview" />
</label>
<label class="inv-form-field">
<span>* Requested Date</span>
<input type="date" [(ngModel)]="requestedDate" [disabled]="isReview" />
</label>
<label class="inv-form-field">
<span>Withdrawal Value</span>
<input type="text" [value]="'₹' + (withdrawalValue | number: '1.2-2')" readonly />
</label>
<label class="inv-form-field">
<span>Remaining Shares After Withdrawal</span>
<input type="text" [value]="remainingShares" readonly />
</label>
<label class="inv-form-field">
<span>* Board Resolution Number</span>
<input type="text" [(ngModel)]="resolutionNumber" [disabled]="isReview" placeholder="e.g. BR-2026-014" />
</label>
<label class="inv-form-field">
<span>* Date of Meeting</span>
<input type="date" [(ngModel)]="meetingDate" [disabled]="isReview" />
</label>
<label class="inv-form-field">
<span>* Settlement Payment Type</span>
@if (!isReview) {
<select [(ngModel)]="settlementPaymentTypeId">
<option [ngValue]="null">Select…</option>
@for (pt of paymentTypeOptions; track pt.id) {
<option [ngValue]="pt.id">{{ pt.name }}</option>
}
</select>
} @else {
<input type="text" [value]="settlementPaymentTypeName" readonly />
}
</label>
<label class="inv-form-field" style="grid-column: span 2">
<span>Description / Remarks</span>
<input type="text" [(ngModel)]="description" [disabled]="isReview" />
</label>
</div>
@if (!isReview) {
<div class="inv-form-actions">
<button
type="button"
class="pg-btn pg-btn--primary"
[disabled]="!isFormValid || submitting"
(click)="openConfirmDialog()"
>
{{ submitting ? 'Submitting…' : 'Review &amp; Submit' }}
</button>
</div>
}
</div>
}
@if (isReview && reviewRequest?.status === 'PENDING') {
<div class="pg-card">
<div class="pos-section-label">Verification</div>
<div class="swrv-trigger">
<div class="swrv-trigger__icon">
<mat-icon>fact_check</mat-icon>
</div>
<div class="swrv-trigger__text">
<div class="swrv-trigger__title">Run a live backend validation</div>
<div class="swrv-trigger__sub">
Re-checks account status, share balance, linked savings account and GL mapping before you approve.
</div>
</div>
<button type="button" class="pg-btn pg-btn--primary" (click)="openValidateModal()">
<mat-icon>play_circle</mat-icon>
Validate Request
</button>
</div>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="approveReview()">
<mat-icon>check_circle</mat-icon>
Approve &amp; Process Withdrawal
</button>
<button type="button" class="pg-btn pg-btn--danger" (click)="openRejectDialog()">
<mat-icon>cancel</mat-icon>
Reject
</button>
</div>
</div>
}
@if (showValidateModal) {
<div class="swrv-modal">
<div class="swrv-modal__card">
<div class="swrv-modal__header">
<div class="swrv-modal__title">
<span class="swrv-modal__title-icon"><mat-icon>fact_check</mat-icon></span>
Validate Share Withdrawal Request
</div>
<button type="button" class="swrv-modal__close" [disabled]="validating" (click)="closeValidateModal()">
<mat-icon>close</mat-icon>
</button>
</div>
<div class="swrv-modal__body">
<p class="swrv-modal__intro">
Re-checking <strong>{{ requestedShares }} share(s)</strong> withdrawal from
<strong>{{ account?.accountNo }}</strong> ({{ account?.clientName }}) against the live account, savings link
and GL configuration — not just what was captured at submission time.
</p>
@if (backendChecks && !validating && !validateAllPassed) {
<div class="swrv-modal__warn">
<mat-icon>warning</mat-icon>
<div>
<strong>Some checks need review before approving.</strong>
Scroll down to see which ones failed and why.
</div>
</div>
}
<ol class="swrv-steps">
@for (step of validateSteps; track step.label; let i = $index) {
<li
class="swrv-step"
[class.swrv-step--active]="step.status === 'active'"
[class.swrv-step--done]="step.status === 'done'"
[class.swrv-step--failed]="step.status === 'failed'"
>
<div class="swrv-step__indicator">
@if (step.status === 'done') {
<mat-icon>check</mat-icon>
} @else if (step.status === 'active') {
<mat-icon class="swrv-spin">autorenew</mat-icon>
} @else if (step.status === 'failed') {
<mat-icon>close</mat-icon>
} @else {
<span>{{ i + 1 }}</span>
}
</div>
<div class="swrv-step__content">
<div class="swrv-step__label">{{ step.label }}</div>
<div class="swrv-step__msg">{{ step.detail }}</div>
</div>
</li>
}
</ol>
@if (validating && validateSteps.length === 0) {
<div class="pg-empty">Connecting to server…</div>
}
@if (backendChecks && !validating && validateAllPassed) {
<div class="swrv-modal__result">
<mat-icon>verified</mat-icon>
<span>All checks passed — safe to approve.</span>
</div>
}
</div>
<div class="swrv-modal__footer">
@if (!validating && backendChecks) {
<button type="button" class="pg-btn" (click)="runBackendValidation()">
<mat-icon>refresh</mat-icon>
Re-run
</button>
}
@if (!validating && backendChecks && validateAllPassed) {
<button type="button" class="pg-btn pg-btn--primary" (click)="closeValidateModal(); approveReview()">
<mat-icon>check_circle</mat-icon>
Approve &amp; Process Withdrawal
</button>
}
<button type="button" class="pg-btn" [disabled]="validating" (click)="closeValidateModal()">
{{ validating ? 'Validating…' : 'Close' }}
</button>
</div>
</div>
</div>
}
@if (isReview && reviewRequest?.status === 'APPROVED') {
<div class="pg-card">
<div class="pos-section-label">Settlement</div>
<div class="inv-form-grid" style="padding: 16px">
@if (reviewRequest?.settlement_amount) {
<label class="inv-form-field">
<span>Amount Credited</span>
<input type="text" [value]="'₹' + (reviewRequest.settlement_amount | number: '1.2-2')" readonly />
</label>
<label class="inv-form-field">
<span>Credited To (Linked Savings A/C)</span>
<input type="text" [value]="account?.savingsAccountNumber || '—'" readonly />
</label>
<label class="inv-form-field">
<span>Savings Transaction Ref.</span>
<input type="text" [value]="reviewRequest.settlement_savings_transaction_id" readonly />
</label>
} @else {
<div class="pg-empty" style="grid-column: span 2">
Settlement is processed automatically on approval. Details aren't available when reopening an
already-approved request — check the linked savings account's transaction history to confirm.
</div>
}
</div>
</div>
}
@if (isReview && activityLog.length > 0) {
<div class="pg-card">
<div class="pos-section-label">Activity Log</div>
<table class="pg-table">
<thead>
<tr>
<th>Timestamp</th>
<th>Action</th>
<th>User</th>
<th>Note</th>
</tr>
</thead>
<tbody>
@for (entry of activityLog; track entry.timestamp) {
<tr>
<td>{{ entry.timestamp | date: 'medium' }}</td>
<td>{{ entry.action }}</td>
<td>{{ entry.user }}</td>
<td>{{ entry.note || '—' }}</td>
</tr>
}
</tbody>
</table>
</div>
}
@if (showConfirmDialog) {
<div class="swr-modal-backdrop">
<div class="swr-modal">
<h3>Confirm Share Withdrawal Request</h3>
<p class="pg-sub">Review the details below before submitting for Bank Manager approval.</p>
<table class="pg-table">
<tbody>
<tr>
<td>Account No / Client</td>
<td>{{ account?.accountNo }} — {{ account?.clientName }}</td>
</tr>
<tr>
<td>Shares to Withdraw</td>
<td>
{{ requestedShares }} @ ₹{{ unitPrice | number: '1.2-2' }} = ₹{{ withdrawalValue | number: '1.2-2' }}
</td>
</tr>
<tr>
<td>Remaining Shares After Withdrawal</td>
<td>{{ remainingShares }}</td>
</tr>
<tr>
<td>Requested Date</td>
<td>{{ requestedDate }}</td>
</tr>
<tr>
<td>Board Resolution Number</td>
<td>{{ resolutionNumber }}</td>
</tr>
<tr>
<td>Date of Meeting</td>
<td>{{ meetingDate }}</td>
</tr>
<tr>
<td>Settlement Payment Type</td>
<td>{{ settlementPaymentTypeName }}</td>
</tr>
<tr>
<td>Linked Savings Account</td>
<td>{{ account?.savingsAccountNumber || 'None — settlement will not run automatically' }}</td>
</tr>
</tbody>
</table>
<div class="pos-section-label" style="margin-top: 12px">Validation Checks</div>
<table class="pg-table">
<tbody>
@for (check of verificationChecks; track check.label) {
<tr>
<td>{{ check.label }}</td>
<td [class.swr-check-pass]="check.pass" [class.swr-check-fail]="!check.pass">
{{ check.pass ? 'Pass — ' : 'Review — ' }}{{ check.detail }}
</td>
</tr>
}
</tbody>
</table>
<div class="inv-form-actions">
<button type="button" class="pg-btn pg-btn--primary" [disabled]="submitting" (click)="confirmSubmit()">
{{ submitting ? 'Submitting…' : 'Confirm & Submit' }}
</button>
<button type="button" class="pg-btn" (click)="cancelConfirmDialog()">Back to Edit</button>
</div>
</div>
</div>
}
@if (showRejectDialog) {
<div class="swr-modal-backdrop">
<div class="swr-modal">
<h3>Reject Share Withdrawal Request</h3>
<label class="inv-form-field">
<span>* Rejection Reason</span>
<textarea rows="3" [(ngModel)]="rejectionReason"></textarea>
</label>
<div class="inv-form-actions">
<button
type="button"
class="pg-btn pg-btn--danger"
[disabled]="!rejectionReason.trim()"
(click)="confirmRejectReview()"
>
Confirm Reject
</button>
<button type="button" class="pg-btn" (click)="showRejectDialog = false">Cancel</button>
</div>
</div>
</div>
}
</div>
@@ -0,0 +1,433 @@
/**
* 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/.
*/
.swr-modal-backdrop {
position: fixed;
inset: 0;
background: rgb(13 27 42 / 55%);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.swr-modal {
background: #fff;
border-radius: 12px;
padding: 20px;
width: 420px;
max-width: 90vw;
max-height: 85vh;
overflow-y: auto;
box-shadow: 0 12px 32px rgb(15 23 42 / 20%);
h3 {
margin: 0 0 12px;
font-size: 16px;
color: #0d1b2a;
}
textarea {
width: 100%;
padding: 8px 10px;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-family: inherit;
font-size: 13px;
resize: vertical;
box-sizing: border-box;
}
&:has(table) {
width: 560px;
}
}
.swr-check-pass {
color: #1a7f37;
font-weight: 600;
}
.swr-check-fail {
color: #b42318;
font-weight: 600;
}
.swr-chip {
display: inline-flex;
align-items: center;
width: fit-content;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
&--ok {
background: #eafaf0;
color: #1a7f37;
}
&--warn {
background: #fdecea;
color: #b42318;
}
}
/* ---------- Verification trigger card ---------- */
.swrv-trigger {
display: flex;
align-items: center;
gap: 14px;
padding: 16px;
flex-wrap: wrap;
&__icon {
width: 42px;
height: 42px;
border-radius: 12px;
background: linear-gradient(135deg, #3457d5, #6c8cff);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: 0 4px 12px rgb(52 87 213 / 30%);
}
&__text {
flex: 1;
min-width: 200px;
}
&__title {
font-size: 13px;
font-weight: 700;
color: #0d1b2a;
}
&__sub {
font-size: 12px;
color: #64748b;
margin-top: 2px;
line-height: 1.4;
}
}
/* ---------- Validate pipeline modal ---------- */
@keyframes swrv-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes swrv-zoom {
from {
opacity: 0;
transform: scale(0.95) translateY(8px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
@keyframes swrv-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.swrv-spin {
animation: swrv-spin 0.9s linear infinite;
}
.swrv-modal {
position: fixed;
inset: 0;
// No backdrop-filter: it would make this element a containing block for any position:fixed
// descendant (e.g. if this modal is ever nested inside another fixed popup, or gains one of its
// own later) — see the identical fix in share-withdrawal-requests.component.scss for the bug this
// caused. Extra opacity compensates for the loss of the blur look.
background: rgb(15 25 45 / 65%);
z-index: 1200;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
animation: swrv-fade 0.2s ease-out;
&__card {
background: #fff;
border-radius: 16px;
width: 100%;
max-width: 520px;
max-height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 24px 60px rgb(0 0 0 / 25%);
animation: swrv-zoom 0.25s ease-out;
}
&__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 22px;
border-bottom: 1px solid #eef1f5;
}
&__title {
display: flex;
align-items: center;
gap: 10px;
font-size: 15px;
font-weight: 700;
color: #0d1b2a;
}
&__title-icon {
width: 34px;
height: 34px;
border-radius: 10px;
background: linear-gradient(135deg, #3457d5, #6c8cff);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
&__close {
width: 32px;
height: 32px;
border-radius: 8px;
border: 1px solid #e2e8f0;
background: #fff;
color: #64748b;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.12s;
&:not(:disabled):hover {
color: #b42318;
border-color: #b42318;
background: #fdecea;
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
&__body {
padding: 20px 22px;
overflow-y: auto;
flex: 1;
}
&__intro {
font-size: 13px;
color: #475569;
line-height: 1.55;
margin: 0 0 16px;
strong {
color: #0d1b2a;
font-weight: 700;
}
}
&__warn {
display: flex;
gap: 10px;
padding: 12px 14px;
background: #fef3e2;
border: 1px solid rgb(217 119 6 / 30%);
border-left: 3px solid #d97706;
border-radius: 8px;
margin-bottom: 16px;
font-size: 12px;
color: #633806;
line-height: 1.5;
mat-icon {
color: #d97706;
font-size: 18px;
width: 18px;
height: 18px;
flex-shrink: 0;
}
strong {
display: block;
font-weight: 700;
color: #4d2a05;
}
}
&__result {
display: flex;
align-items: center;
gap: 8px;
margin-top: 6px;
padding: 12px 14px;
background: #eafaf0;
border: 1px solid rgb(26 127 55 / 25%);
border-radius: 8px;
font-size: 13px;
font-weight: 600;
color: #1a7f37;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
}
&__footer {
padding: 16px 22px;
border-top: 1px solid #eef1f5;
display: flex;
justify-content: flex-end;
gap: 10px;
background: #f8fafc;
}
}
.swrv-steps {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
}
.swrv-step {
display: flex;
gap: 14px;
padding: 10px 0;
position: relative;
&:not(:last-child)::after {
content: '';
position: absolute;
left: 15px;
top: 38px;
bottom: -4px;
width: 2px;
background: #e2e8f0;
}
&__indicator {
width: 32px;
height: 32px;
border-radius: 50%;
background: #fff;
border: 2px solid #e2e8f0;
color: #64748b;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
flex-shrink: 0;
position: relative;
z-index: 1;
transition: all 0.25s;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
}
&__content {
padding-top: 5px;
flex: 1;
min-width: 0;
}
&__label {
font-size: 13px;
font-weight: 600;
color: #475569;
}
&__msg {
font-size: 11px;
color: #94a3b8;
margin-top: 2px;
}
&--active {
.swrv-step__indicator {
background: #3457d5;
border-color: #3457d5;
color: #fff;
box-shadow: 0 0 0 4px rgb(52 87 213 / 15%);
}
.swrv-step__label {
color: #3457d5;
}
}
&--done {
.swrv-step__indicator {
background: #1a7f37;
border-color: #1a7f37;
color: #fff;
}
&::after {
background: #1a7f37;
}
.swrv-step__label {
color: #0d1b2a;
}
}
&--failed {
.swrv-step__indicator {
background: #b42318;
border-color: #b42318;
color: #fff;
}
.swrv-step__label {
color: #b42318;
}
}
}
@media (prefers-reduced-motion: reduce) {
.swrv-spin {
animation: none;
}
.swrv-modal,
.swrv-modal__card {
animation: none;
}
}
@@ -0,0 +1,416 @@
/**
* 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, EventEmitter, Input, OnInit, Output, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { catchError } from 'rxjs/operators';
import { of } from 'rxjs';
import { SharesService } from '../../shares/shares.service';
import { ClerkShareWithdrawalService } from '../../cashier/clerk-share-withdrawal.service';
import { CashierService } from '../../cashier/cashier.service';
import { SearchSelectComponent, SearchSelectOption } from '../../shared/search-select/search-select.component';
export type ShareWdMode = 'create' | 'review';
interface ActivityEntry {
timestamp: string;
action: string;
user: string;
note?: string;
}
/**
* Share Withdrawal (redeem shares) request — maker screen in the Clerk Dashboard. A clerk finds
* the member's Share Account, enters how many shares to withdraw plus the governing Board
* Resolution Number / Date of Meeting, and submits for Bank Manager approval. This is the ONLY
* entry point a share withdrawal should go through — approval server-side executes the real
* "redeemshares" command (see ClerkShareWithdrawalRequestApiResource on the backend).
*/
@Component({
selector: 'mifosx-share-withdrawal-request',
templateUrl: './share-withdrawal-request.component.html',
styleUrls: [
'../../cashier/cashier-shared.scss',
'../clerk-shared.scss',
'./share-withdrawal-request.component.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
RouterModule,
MatIconModule,
MatSnackBarModule,
SearchSelectComponent
]
})
export class ShareWithdrawalRequestComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private sharesService = inject(SharesService);
private wdService = inject(ClerkShareWithdrawalService);
private cashierService = inject(CashierService);
private snackBar = inject(MatSnackBar);
/** When embedded (e.g. opened as a popup from a list screen instead of routed to directly), mode
* and requestId come from these inputs rather than the route, and success/close actions emit
* events instead of navigating away. */
@Input() embedded = false;
@Input() overrideMode: ShareWdMode | null = null;
@Input() overrideRequestId: number | null = null;
@Output() closeRequested = new EventEmitter<void>();
@Output() actionCompleted = new EventEmitter<void>();
mode: ShareWdMode = 'create';
requestId: number | null = null;
submitting = false;
loadingAccount = false;
selectedAccountId = '';
accountOptions: SearchSelectOption[] = [];
account: any = null;
requestedShares: number | null = null;
requestedDate = '';
resolutionNumber = '';
meetingDate = '';
description = '';
paymentTypes: any[] = [];
settlementPaymentTypeId: number | null = null;
activityLog: ActivityEntry[] = [];
reviewRequest: any = null;
showRejectDialog = false;
rejectionReason = '';
showConfirmDialog = false;
showValidateModal = false;
validating = false;
validateAllPassed = false;
backendChecks: { label: string; pass: boolean; detail: string }[] | null = null;
validateSteps: { label: string; detail: string; pass: boolean; status: 'pending' | 'active' | 'done' | 'failed' }[] =
[];
get remainingShares(): number {
const held = Number(this.account?.summary?.totalApprovedShares ?? this.account?.totalApprovedShares ?? 0);
return held - (this.requestedShares || 0);
}
get unitPrice(): number {
return Number(this.account?.currentMarketPrice ?? 0);
}
get withdrawalValue(): number {
return (this.requestedShares || 0) * this.unitPrice;
}
get paymentTypeOptions(): any[] {
return this.paymentTypes.filter((pt: any) => !pt.isCashPayment);
}
get selectedSettlementPaymentType(): any {
return this.paymentTypes.find((pt: any) => pt.id === this.settlementPaymentTypeId);
}
get settlementPaymentTypeName(): string {
if (this.isReview) {
return this.reviewRequest?.settlement_payment_type_name || '—';
}
return this.selectedSettlementPaymentType?.name || '—';
}
get isFormValid(): boolean {
if (!this.account) return false;
if (!this.requestedShares || this.requestedShares <= 0) return false;
const held = Number(this.account?.summary?.totalApprovedShares ?? this.account?.totalApprovedShares ?? 0);
if (this.requestedShares > held) return false;
if (!this.requestedDate) return false;
if (!this.resolutionNumber.trim()) return false;
if (!this.meetingDate) return false;
if (!this.settlementPaymentTypeId) return false;
return true;
}
/** Shown to the clerk before submit and to the manager before approval, so both can verify the same checks. */
get verificationChecks(): { label: string; pass: boolean; detail: string }[] {
const held = Number(this.account?.summary?.totalApprovedShares ?? this.account?.totalApprovedShares ?? 0);
const shares = this.requestedShares || 0;
return [
{
label: 'Board Resolution Number provided',
pass: !!this.resolutionNumber?.trim(),
detail: this.resolutionNumber?.trim() || 'Missing'
},
{ label: 'Date of Meeting provided', pass: !!this.meetingDate, detail: this.meetingDate || 'Missing' },
{
label: 'Shares requested within shares held',
pass: shares > 0 && shares <= held,
detail: `${shares} of ${held} shares held`
},
{
label: 'Linked savings account confirmed',
pass: !!this.account?.savingsAccountNumber,
detail: this.account?.savingsAccountNumber || 'No linked savings account — settlement cannot run automatically'
},
{
label: 'Settlement Payment Type selected (non-cash)',
pass: this.settlementPaymentTypeName !== '—',
detail: this.settlementPaymentTypeName
}
];
}
get isReview(): boolean {
return this.mode === 'review';
}
get pageTitle(): string {
return this.isReview ? 'Review Share Withdrawal Request' : 'New Share Withdrawal Request';
}
get pageSubtitle(): string {
return this.isReview
? "Approve or reject the clerk's share withdrawal request."
: 'Submit a share withdrawal (redeem shares) request for Bank Manager approval.';
}
ngOnInit(): void {
this.mode = this.overrideMode ?? (this.route.snapshot.data?.['mode'] as ShareWdMode) ?? 'create';
const idParam = this.overrideRequestId ?? this.route.snapshot.params?.['id'];
this.requestId = idParam ? Number(idParam) : null;
const today = new Date();
this.requestedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
this.meetingDate = this.requestedDate;
this.sharesService
.getShareAccountSummaries()
.pipe(catchError(() => of([])))
.subscribe((rows: any[]) => {
this.accountOptions = (rows || [])
.filter((r) => (r.status?.value || '').toLowerCase() === 'active')
.map((r) => ({
value: String(r.id),
label: `${r.accountNo} — ${r.clientName} (${r.totalApprovedShares} shares)`,
searchText: `${r.accountNo} ${r.clientName}`
}));
});
this.cashierService
.getPaymentTypes()
.pipe(catchError(() => of([])))
.subscribe((types: any[]) => (this.paymentTypes = types ?? []));
if (this.requestId && this.mode === 'review') {
this.loadReviewRequest(this.requestId);
}
}
onAccountSelected(accountId: string): void {
this.selectedAccountId = accountId;
if (!accountId) {
this.account = null;
return;
}
this.loadingAccount = true;
this.sharesService
.getSharesAccountData(accountId, false)
.pipe(catchError(() => of(null)))
.subscribe((acc: any) => {
this.account = acc;
this.loadingAccount = false;
});
}
private loadReviewRequest(id: number): void {
this.wdService.getById(id).subscribe({
next: (req: any) => {
this.reviewRequest = req;
this.requestedShares = Number(req.requested_shares);
this.requestedDate = req.requested_date
? String(req.requested_date).split('T')[0].split(' ')[0]
: this.requestedDate;
this.resolutionNumber = req.resolution_number ?? '';
this.meetingDate = req.meeting_date ? String(req.meeting_date).split('T')[0].split(' ')[0] : this.meetingDate;
this.description = req.description ?? '';
if (req.activity_log) {
try {
this.activityLog = JSON.parse(req.activity_log);
} catch {
this.activityLog = [];
}
}
this.sharesService
.getSharesAccountData(String(req.share_account_id), false)
.pipe(catchError(() => of(null)))
.subscribe((acc: any) => (this.account = acc));
},
error: () => this.snackBar.open('Failed to load share withdrawal request.', 'Close', { duration: 5000 })
});
}
/** "Review before Save" per requirement — opens the confirm dialog instead of submitting directly. */
openConfirmDialog(): void {
if (!this.isFormValid || this.submitting) {
return;
}
this.showConfirmDialog = true;
}
cancelConfirmDialog(): void {
this.showConfirmDialog = false;
}
confirmSubmit(): void {
this.showConfirmDialog = false;
this.submitting = true;
const payload = {
shareAccountId: Number(this.selectedAccountId),
accountNo: this.account?.accountNo,
clientId: this.account?.clientId,
clientName: this.account?.clientName,
requestedShares: this.requestedShares,
requestedDate: this.requestedDate,
resolutionNumber: this.resolutionNumber.trim(),
meetingDate: this.meetingDate,
description: this.description,
settlementPaymentTypeId: this.settlementPaymentTypeId
};
this.wdService.submit(payload).subscribe({
next: () => {
this.snackBar.open('Share withdrawal request submitted for approval.', 'Close', { duration: 4000 });
if (this.embedded) {
this.actionCompleted.emit();
} else {
this.router.navigate(['/clerk/dashboard']);
}
},
error: (err) => {
this.submitting = false;
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not submit the request.', 'Close', {
duration: 6000
});
}
});
}
openValidateModal(): void {
if (!this.reviewRequest) return;
this.showValidateModal = true;
this.runBackendValidation();
}
closeValidateModal(): void {
if (this.validating) return;
this.showValidateModal = false;
}
/** Re-checks live backend state (account still active, GL mapping still configured, etc.) rather
* than relying on data already loaded in the browser — catches drift since submission. Results
* are revealed one step at a time (see revealValidateSteps()) for the pipeline-style animation. */
runBackendValidation(): void {
if (!this.reviewRequest || this.validating) return;
this.validating = true;
this.backendChecks = null;
this.validateSteps = [];
this.wdService.validate(this.reviewRequest.id).subscribe({
next: (res: any) => {
const checks = (res?.checks ?? []) as { label: string; pass: boolean; detail: string }[];
this.backendChecks = checks;
this.validateAllPassed = !!res?.allPassed;
this.validateSteps = checks.map((c) => ({ ...c, status: 'pending' as const }));
this.revealValidateSteps(0);
},
error: (err) => {
this.validating = false;
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not run backend validation.', 'Close', {
duration: 6000
});
}
});
}
private revealValidateSteps(index: number): void {
if (index >= this.validateSteps.length) {
this.validating = false;
return;
}
this.validateSteps[index].status = 'active';
setTimeout(() => {
this.validateSteps[index].status = this.validateSteps[index].pass ? 'done' : 'failed';
this.revealValidateSteps(index + 1);
}, 320);
}
approveReview(): void {
if (!this.reviewRequest) return;
this.wdService.approve(this.reviewRequest.id).subscribe({
next: (res: any) => {
let message =
res?.settlementAmount != null
? `Share withdrawal approved. ₹${Number(res.settlementAmount).toLocaleString('en-IN', { minimumFractionDigits: 2 })} credited to the linked savings account.`
: (res?.settlementWarning ?? 'Share withdrawal approved and processed.');
if (res?.autoClosureRequestId) {
message += ' All shares now withdrawn — a Share Closure Request was auto-submitted for manager review.';
}
this.snackBar.open(message, 'Close', { duration: 8000 });
if (this.embedded) {
this.actionCompleted.emit();
} else {
this.router.navigate(['/checker-inbox-and-tasks/share-withdrawal-requests']);
}
},
error: (err) =>
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not approve the request.', 'Close', {
duration: 6000
})
});
}
openRejectDialog(): void {
this.showRejectDialog = true;
this.rejectionReason = '';
}
confirmRejectReview(): void {
if (!this.reviewRequest || !this.rejectionReason.trim()) return;
this.wdService.reject(this.reviewRequest.id, this.rejectionReason.trim()).subscribe({
next: () => {
this.showRejectDialog = false;
this.snackBar.open('Share withdrawal request rejected.', 'Close', { duration: 4000 });
if (this.embedded) {
this.actionCompleted.emit();
} else {
this.router.navigate(['/checker-inbox-and-tasks/share-withdrawal-requests']);
}
},
error: (err) =>
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not reject the request.', 'Close', {
duration: 6000
})
});
}
goBack(): void {
if (this.embedded) {
this.closeRequested.emit();
return;
}
this.router.navigate(this.isReview ? ['/checker-inbox-and-tasks/share-withdrawal-requests'] : ['/clerk/dashboard']);
}
}
@@ -0,0 +1,90 @@
<div class="fm-dialog">
<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>
</div>
<mat-dialog-content>
<form [formGroup]="childrenForm">
<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>
}
</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>
</div>
</div>
@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>
</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>
@@ -0,0 +1,98 @@
import { Component, Inject, OnInit } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { UntypedFormBuilder, UntypedFormGroup, UntypedFormArray, Validators } from '@angular/forms';
import { MatIcon } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatDialogModule } from '@angular/material/dialog';
import { MatTooltipModule } from '@angular/material/tooltip';
import { ReactiveFormsModule } from '@angular/forms';
import { CommonModule } from '@angular/common';
@Component({
selector: 'cbs-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,
imports: [
CommonModule,
ReactiveFormsModule,
MatDialogModule,
MatIcon,
MatButtonModule,
MatInputModule,
MatSelectModule,
MatDatepickerModule,
MatTooltipModule
]
})
export class ClientFamilyMemberChildDialogComponent implements OnInit {
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;
this.childrenForm = this.formBuilder.group({
children: this.formBuilder.array([])
});
}
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}$/)]
}));
});
} else {
this.addChild();
}
}
get children(): UntypedFormArray {
return this.childrenForm.get('children') as UntypedFormArray;
}
createChildFormGroup(): UntypedFormGroup {
return this.formBuilder.group({
id: [''],
fullName: ['', Validators.required],
genderId: ['', Validators.required],
dateOfBirth: [''],
nationality: ['INDIAN'],
aadhaarNumber: [''],
panNumber: ['', Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)]
});
}
addChild(): void {
this.children.push(this.createChildFormGroup());
}
removeChild(index: number): void {
this.children.removeAt(index);
}
submit(): void {
if (this.childrenForm.valid) {
this.dialogRef.close(this.children.value);
}
}
}
@@ -139,6 +139,21 @@
<span class="fm-readonly-value">{{ familyMemberForm.get('remarks')?.value }}</span>
</div>
}
@if (isSpouse && familyMemberForm.get('children')?.value?.length > 0) {
<div class="fm-readonly-remarks">
<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;">
<span>{{ child.fullName }}</span>
@if (child.dateOfBirth) {
<span> · DOB: {{ child.dateOfBirth | date: 'dd MMM yyyy' }}</span>
}
</div>
}
</div>
</div>
}
}
<!-- ═══ ADD / EDIT MODE ═══ -->
@@ -447,6 +462,19 @@
<mat-label>Remarks</mat-label>
<textarea matInput formControlName="remarks" rows="2"></textarea>
</mat-form-field>
<!-- 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;">
<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;">
<mat-icon>edit</mat-icon> Edit Children Details ({{ children.length }})
</button>
}
</div>
}
}
</form>
</mat-dialog-content>
@@ -17,7 +17,7 @@ import {
MatDialogClose,
MatDialogContent
} from '@angular/material/dialog';
import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms';
import { UntypedFormGroup, UntypedFormBuilder, UntypedFormArray, Validators } from '@angular/forms';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { ClientsService } from 'app/clients/clients.service';
@@ -58,6 +58,8 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
private dialog = inject(MatDialog);
maxDate = new Date();
hasChildrenChecked = false;
familyMemberForm: UntypedFormGroup;
/** Client search */
@@ -197,6 +199,23 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
// Re-fetch linked client details (profile, signature, KYC status)
this.fetchLinkedClientData(m.linkedClientId.toString());
}
if (m.children && m.children.length > 0) {
this.hasChildrenChecked = true;
this.familyMemberForm.patchValue({ hasChildren: true });
m.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}$/)]
})
);
});
}
if (this.isViewMode) {
this.familyMemberForm.disable();
}
@@ -225,8 +244,83 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
isAlive: [true],
marriageDate: [''],
marriageAuthority: [''],
linkedClientId: ['']
linkedClientId: [''],
hasChildren: [false],
children: this.formBuilder.array([])
});
this.familyMemberForm.get('hasChildren')?.valueChanges.subscribe(value => {
if (value && !this.hasChildrenChecked) {
this.openChildrenDialog();
} else if (!value) {
this.hasChildrenChecked = false;
this.children.clear();
}
});
}
get children(): UntypedFormArray {
return this.familyMemberForm.get('children') as UntypedFormArray;
}
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');
const dialogRef = this.dialog.open(ClientFamilyMemberChildDialogComponent, {
data: {
children: this.children.value,
genderIdOptions: this.data.options.genderIdOptions
},
width: '500px'
});
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}$/)]
}));
});
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 });
}
}
});
}
createChildFormGroup(): UntypedFormGroup {
return this.formBuilder.group({
id: [''],
fullName: ['', Validators.required],
genderId: ['', Validators.required],
dateOfBirth: [''],
nationality: ['INDIAN'],
aadhaarNumber: [''],
panNumber: ['', Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)]
});
}
addChild(): void {
this.children.push(this.createChildFormGroup());
}
removeChild(index: number): void {
this.children.removeAt(index);
if (this.children.length === 0) {
this.familyMemberForm.patchValue({ hasChildren: false });
}
}
/** Handle marriage certificate file selection */
@@ -481,6 +575,25 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
member._linkedClient = this.selectedClient;
}
// Format children dates
if (this.isSpouse && member.children && member.children.length > 0) {
member.children = member.children.map((child: any) => {
const c = { ...child };
if (c.dateOfBirth instanceof Date) {
c.dateOfBirth = this.dateUtils.formatDate(c.dateOfBirth, dateFormat);
} else if (!c.dateOfBirth) {
delete c.dateOfBirth;
}
if (c.panNumber) {
c.panNumber = c.panNumber.toUpperCase();
}
return c;
});
} else {
delete member.children;
}
delete member.hasChildren;
// Attach marriage certificate file if present
if (this.marriageCertFile) {
member._marriageCertFile = this.marriageCertFile;
@@ -533,8 +533,6 @@ 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
@@ -711,12 +709,6 @@ 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,
@@ -751,7 +743,6 @@ 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;
@@ -966,8 +957,13 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
this.linkMonthlyAnnual('monthlyIncome', 'annualIncome');
this.linkMonthlyAnnual('monthlyTurnover', 'annualTurnover');
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;
/**
* Keeps a Monthly/Annual pair of amount fields in sync at a 12x ratio — typing into either
* one auto-fills the other. Uses { emitEvent: false } on the write side so the two
@@ -1002,6 +998,37 @@ 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.
*/
@@ -2236,26 +2263,26 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
}
// Match monthly income ranges
if (rule.minMonthlyIncome != null && rule.minMonthlyIncome > 0 && monthlyIncome < rule.minMonthlyIncome) {
if (rule.minMonthlyIncome != null && monthlyIncome < rule.minMonthlyIncome) {
continue;
}
if (rule.maxMonthlyIncome != null && rule.maxMonthlyIncome > 0 && monthlyIncome > rule.maxMonthlyIncome) {
if (rule.maxMonthlyIncome != null && monthlyIncome > rule.maxMonthlyIncome) {
continue;
}
// Match annual income ranges (evaluated against totalAnnualIncome)
if (rule.minAnnualIncome != null && rule.minAnnualIncome > 0 && totalAnnualIncome < rule.minAnnualIncome) {
if (rule.minAnnualIncome != null && totalAnnualIncome < rule.minAnnualIncome) {
continue;
}
if (rule.maxAnnualIncome != null && rule.maxAnnualIncome > 0 && totalAnnualIncome > rule.maxAnnualIncome) {
if (rule.maxAnnualIncome != null && totalAnnualIncome > rule.maxAnnualIncome) {
continue;
}
// Match turnover ranges
if (rule.minAnnualTurnover != null && rule.minAnnualTurnover > 0 && annualTurnover < rule.minAnnualTurnover) {
if (rule.minAnnualTurnover != null && annualTurnover < rule.minAnnualTurnover) {
continue;
}
if (rule.maxAnnualTurnover != null && rule.maxAnnualTurnover > 0 && annualTurnover > rule.maxAnnualTurnover) {
if (rule.maxAnnualTurnover != null && annualTurnover > rule.maxAnnualTurnover) {
continue;
}
@@ -723,6 +723,33 @@
<span>PAN verification requires client to be created first. Complete General tab and click Next to save.</span>
</div>
}
<!-- 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;">
<fa-icon icon="exclamation-triangle"></fa-icon>
KYC Exception Flag
</h5>
<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-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="GSTIN Verification Failed">GSTIN Verification Failed</mat-option>
<mat-option value="CIN Verification Failed">CIN Verification Failed</mat-option>
}
</mat-select>
</mat-form-field>
}
</div>
</div>
</div>
<!-- ═══ SECTION: KYC Document Management ═══ -->
@@ -1410,6 +1437,7 @@
</div>
</div>
<!-- ═══ SECTION: Photograph & Signature ═══ -->
<div class="section-block">
<h4 class="section-title">
@@ -29,6 +29,7 @@ import { ClientsService } from '../../clients.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { ProofType, normalizeProofType, groupProofsByCategory } from '../../shared/kyc-proof-types';
import { deriveKycExceptionFields } from '../../kyc-exception.util';
import { KycUploadHelperService } from '../../shared/kyc-upload-helper.service';
import { CaptureImageDialogComponent } from '../../clients-view/custom-dialogs/capture-image-dialog/capture-image-dialog.component';
import { DrawSignatureDialogComponent } from '../../clients-view/custom-dialogs/draw-signature-dialog/draw-signature-dialog.component';
@@ -152,7 +153,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
kycDocumentName: 'KYC Document Type',
kycDocumentNumber: 'KYC Document Number',
amlCheckStatus: 'AML Check Status',
bannedEntityMatch: 'Banned Entity List Match'
bannedEntityMatch: 'Banned Entity List Match',
remarks: 'Reason for Re-edit'
};
/** Names every currently-invalid `kycForm` control (required-but-empty, or an entered value
@@ -274,6 +276,14 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
riskCalculationReason = '';
showRiskRulesTable = false;
/**
* Exception state as last read back from the server. Needed to tell "this client never had an
* exception" from "this client had one and has now cleared it" — only the latter should be
* stamped resolved and surface in the Resolved tab.
*/
persistedIsException = false;
persistedExceptionReason: string | null = null;
/** PAN verification */
panVerifyStatus: 'NONE' | 'VERIFIED' | 'FAILED' = 'NONE';
panVerifying = false;
@@ -423,8 +433,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
aadhaarNumber: [
'',
[
Validators.pattern(/^\d{12}$/),
Validators.maxLength(12)
Validators.pattern(/^(\d{12}|[X•*\s\d]{12,14})$/),
Validators.maxLength(14)
]
],
panAadhaarSeedingStatus: [''],
@@ -468,6 +478,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
allowRiskOverride: [false],
allowAmlOverride: [false],
remarks: [''],
isException: [false],
exceptionReason: [[]],
amlCheckStatus: [
'CLEAR',
Validators.required
@@ -610,8 +622,12 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
dormantFlag: 'ACTIVE',
accountStatus: 'ACTIVE',
form60Type: 'FORM_60',
form15Type: '15G'
form15Type: '15G',
isException: false,
exceptionReason: []
});
this.persistedIsException = false;
this.persistedExceptionReason = null;
this.kycId = '';
this.kycLoaded = false;
this.kycStatus = 0;
@@ -942,8 +958,18 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
form60Date: parseDate(data.form60Date),
form15Applicable: data.form15Applicable || false,
form15Type: data.form15Type || '15G',
form15FY: data.form15FY || ''
form15FY: data.form15FY || '',
isException: data.isException || false,
exceptionReason: data.exceptionReason ? data.exceptionReason.split(', ') : []
});
// Set remarks as required if KYC is verified
if (this.isKycVerified) {
this.kycForm.get('remarks')?.setValidators(Validators.required);
this.kycForm.get('remarks')?.updateValueAndValidity();
this.kycForm.get('remarks')?.markAsTouched(); // Force red underline so user knows it's blocking
}
this.persistedIsException = data.isException === true;
this.persistedExceptionReason = data.exceptionReason || null;
// Sync chip-picker selection with the prefilled document name
if (data.kycDocumentName) {
this.selectedKycDoc = this.kycDocProofs.find((p) => p.name === data.kycDocumentName) || null;
@@ -1843,7 +1869,21 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
form15Applicable: v.form15Applicable || false,
form15Type: v.form15Type || null,
form15FY: v.form15FY || null,
remarks: v.remarks || null
remarks: v.remarks || null,
// Exception state is DERIVED from verification status, not from a checkbox — an unverified
// 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',
aadhaarVerified: this.aadhaarVerifyStatus === 'VERIFIED',
// Non-individual clients submit a dummy masked Aadhaar, so checking it would saddle every
// company with a permanent exception.
aadhaarApplicable: !this.isNonIndividual,
hadException: this.persistedIsException,
previousReason: this.persistedExceptionReason,
manualReasons: Array.isArray(v.exceptionReason) ? v.exceptionReason : [],
todayFormatted: this.dateUtils.formatDate(new Date(), dateFormat)
})
};
}
@@ -1856,7 +1896,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
v.enterprisePan ||
v.cinNumber ||
v.kycDocumentName ||
v.form15Applicable
v.form15Applicable ||
v.isException
);
}
@@ -60,6 +60,7 @@ import { ClientCollateralResolver } from './common-resolvers/client-collateral.r
import { KycTabComponent } from './clients-view/kyc-tab/kyc-tab.component';
import { CkycTabComponent } from './clients-view/ckyc-tab/ckyc-tab.component';
import { Form60TabComponent } from './clients-view/form60-tab/form60-tab.component';
import { Form121TabComponent } from './clients-view/form121-tab/form121-tab.component';
import { EntityClassificationTabComponent } from './clients-view/entity-classification-tab/entity-classification-tab.component';
import { familyAllowedGuard, nomineeAllowedGuard } from './utils/family-nominee.guard';
@@ -193,6 +194,11 @@ const routes: Routes = [
component: Form60TabComponent,
data: { title: 'Form 97', breadcrumb: 'Form 97', routeParamBreadcrumb: false }
},
{
path: 'form121',
component: Form121TabComponent,
data: { title: 'Form 121 / TDS', breadcrumb: 'Form 121 / TDS', routeParamBreadcrumb: false }
},
{
path: 'documents',
component: DocumentsTabComponent,
@@ -545,6 +545,15 @@
>
<i class="fa fa-file-text cv-tab-icon"></i> {{ 'labels.inputs.Form 60' | translate }}
</a>
<a
mat-tab-link
[routerLink]="['./form121']"
routerLinkActive
#form121="routerLinkActive"
[active]="form121.isActive"
>
<i class="fa fa-percent cv-tab-icon"></i> Form 121 / TDS
</a>
<a
mat-tab-link
[routerLink]="['./documents']"
@@ -36,7 +36,34 @@
<strong>Status:</strong>
<span class="status-badge status-{{ dispatch.status | lowercase }}">{{ dispatch.status }}</span>
</p>
</div>
<!-- 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 -->
<hr *ngIf="activeDispatches.length > 0 && welcomeKitsData?.canDispatchNew" />
@@ -165,6 +165,30 @@ 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 || '';
@@ -0,0 +1,428 @@
<!--
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="f121">
@if (loading) {
<div class="f121__loading">
<mat-spinner [diameter]="36"></mat-spinner>
<span>Reading the customer's deposit ledger…</span>
</div>
} @else if (loadError) {
<div class="f121__note f121__note--warn">
<mat-icon>error_outline</mat-icon>
<div>Could not load the customer's accounts. Try again, or check the deposit service.</div>
</div>
} @else {
<!-- ── Header ── -->
<div class="f121__head">
<div>
<h3 class="f121__title">Form 121 &amp; TDS on deposit interest</h3>
<div class="f121__sub">
Tax deducted at source under {{ statute.section }} on fixed and recurring deposit interest.
</div>
</div>
<div class="f121__controls">
@if (availableFys.length) {
<label class="f121__field">
<span>Financial year</span>
<select [(ngModel)]="fy" (ngModelChange)="recompute()">
@for (year of availableFys; track year) {
<option [value]="year">FY {{ year }}</option>
}
</select>
</label>
}
<div class="f121__field">
<span>Settlement</span>
<div
class="f121__mode"
[matTooltip]="'Set by the deposit product\'s interest-posting frequency: tax falls due when interest is credited, so the branch cannot defer it. Not a customer preference.'"
>
<mat-icon>schedule</mat-icon>
{{ mode === 'QUARTERLY' ? 'Quarterly' : 'At year end' }}
</div>
</div>
</div>
</div>
@if (!schedule) {
<div class="f121__note">
<mat-icon>info_outline</mat-icon>
<div>
@if (!dateOfBirth) {
Date of birth is not on record, so the senior-citizen threshold cannot be determined. Add it on the Personal
Data tab.
} @else {
No interest has been credited to this customer's deposits yet.
}
</div>
</div>
} @else {
<!-- ── KPI strip ── -->
<div class="f121__kpis">
<div class="f121__kpi">
<div class="f121__kpi-n">₹{{ schedule.totalInterest | number: '1.0-0' }}</div>
<div class="f121__kpi-l">Interest earned (FY {{ schedule.fy }})</div>
</div>
<div class="f121__kpi" [class.f121__kpi--warn]="actualTds > 0">
<div class="f121__kpi-n">₹{{ actualTds | number: '1.0-0' }}</div>
<div class="f121__kpi-l">TDS deducted</div>
</div>
<div class="f121__kpi">
<div class="f121__kpi-n">₹{{ schedule.threshold | number: '1.0-0' }}</div>
<div class="f121__kpi-l">Threshold ({{ schedule.senior ? 'senior citizen' : 'general' }})</div>
</div>
@if (thresholdCrossed) {
<div class="f121__kpi f121__kpi--warn">
<div class="f121__kpi-n">Crossed</div>
<div class="f121__kpi-l">TDS applies this year</div>
</div>
} @else {
<div class="f121__kpi f121__kpi--ok">
<div class="f121__kpi-n">₹{{ headroom | number: '1.0-0' }}</div>
<div class="f121__kpi-l">More interest before TDS starts</div>
</div>
}
@if (tdsSaved > 0) {
<div class="f121__kpi f121__kpi--ok">
<div class="f121__kpi-n">₹{{ tdsSaved | number: '1.0-0' }}</div>
<div class="f121__kpi-l">Saved by Form 121</div>
</div>
}
</div>
<!-- ── Alerts ── -->
@if (variance !== 0) {
<div class="f121__note f121__note--warn">
<mat-icon>warning_amber</mat-icon>
<div>
<strong>Ledger and computed tax differ by ₹{{ variance | number: '1.0-0' }}.</strong>
The ledger shows ₹{{ actualTds | number: '1.0-0' }} withheld; {{ statute.section }} on this year's interest
comes to ₹{{ schedule.totalTds | number: '1.0-0' }}. Investigate before the quarterly return is filed.
</div>
</div>
}
@if (declarationNotAppliedToAccounts) {
<div class="f121__note f121__note--warn">
<mat-icon>warning_amber</mat-icon>
<div>
A Form 121 declaration is on record for this year, but withholding is still switched on for one or more
deposits. Turn it off on those accounts, or tax will keep being deducted.
</div>
</div>
}
@if (declarationUselessOnJointAccounts) {
<div class="f121__note f121__note--warn">
<mat-icon>warning_amber</mat-icon>
<div>
This customer has filed Form 121, but tax on
{{ jointHolderAccounts.length }} joint deposit{{ jointHolderAccounts.length === 1 ? '' : 's' }} is deducted
against the first-named holder. Their declaration does not cover those — the primary holder must file their
own.
</div>
</div>
}
@if (shouldSuggestDeclaration) {
<div class="f121__note f121__note--tip">
<mat-icon>lightbulb_outline</mat-icon>
<div>
₹{{ schedule.totalTds | number: '1.0-0' }} of tax falls due this year. If the customer's total tax liability
for the year is nil, a {{ formLabel }} declaration would stop it being deducted at all — better than
reclaiming it later as a refund.
</div>
</div>
}
@if (!panWellFormed) {
<div class="f121__note f121__note--warn">
<mat-icon>error_outline</mat-icon>
<div>
No valid PAN on record, so tax is deducted at the higher section 206AA rate of
{{ schedule.rateApplied * 100 | number: '1.0-0' }}%. A Form 121 declaration is void without a valid PAN.
</div>
</div>
}
@for (note of schedule.notes; track note) {
<div class="f121__note">
<mat-icon>info_outline</mat-icon>
<div>{{ note }}</div>
</div>
}
<!-- ── Declaration panel ── -->
<div class="f121__card">
<div class="f121__card-head">
<mat-icon>assignment_turned_in</mat-icon>
<span>Declaration</span>
</div>
<div class="f121__kv">
<div class="f121__kv-row">
<span>Status</span>
<span>
@if (declarationOnRecord) {
<span class="f121__pill f121__pill--ok">On record — {{ formLabel }}</span>
} @else {
<span class="f121__pill">Not filed</span>
}
</span>
</div>
<div class="f121__kv-row">
<span>Form &amp; threshold band</span>
<span>{{ formLabel }} · {{ bandLabel }} band · {{ age }} yrs in this FY</span>
</div>
<div class="f121__kv-row">
<span>PAN</span>
<span class="f121__mono">
{{ pan || '—' }}
@if (pan && !panWellFormed) {
<span class="f121__pill f121__pill--warn">invalid</span>
}
</span>
</div>
@if (declarationOnRecord && declarationFy) {
<div class="f121__kv-row">
<span>Declared for</span>
<span>FY {{ declarationFy }}</span>
</div>
}
@if (declarationOnRecord && !acceptanceDateKnown) {
<div class="f121__kv-row f121__kv-row--muted">
<span>Acceptance date</span>
<span>
Not captured — the declaration is treated as covering the whole year. A mid-year acceptance would change
the figures below.
</span>
</div>
}
@if (eligibility) {
@for (reason of eligibility.reasons; track reason) {
<div class="f121__kv-row f121__kv-row--warn">
<span>Eligibility</span>
<span>{{ reason }}</span>
</div>
}
@for (note of eligibility.unassessed; track note) {
<div class="f121__kv-row f121__kv-row--muted">
<span>Not assessed</span>
<span>{{ note }}</span>
</div>
}
}
</div>
</div>
<!-- ── Quarter-wise ── -->
<div class="f121__card">
<div class="f121__card-head">
<mat-icon>table_chart</mat-icon>
<span>Quarter by quarter</span>
</div>
<div class="f121__table-wrap">
<table class="f121__table">
<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 schedule.lines; track trackLine($index, line)) {
<tr [class.f121__row--warn]="line.thresholdCrossedHere">
<td>
{{ line.label }}
@if (line.thresholdCrossedHere) {
<span class="f121__pill f121__pill--warn">threshold crossed</span>
}
@if (line.declarationActive) {
<span class="f121__pill f121__pill--ok">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="muted">—</span>
}
</td>
<td class="muted">{{ line.dueDate }}</td>
</tr>
}
<tr class="f121__row--total">
<td><strong>Total</strong></td>
<td class="num">
<strong>₹{{ schedule.totalInterest | number: '1.0-0' }}</strong>
</td>
<td></td>
<td></td>
<td class="num">
<strong>₹{{ schedule.totalTds | number: '1.0-0' }}</strong>
</td>
<td></td>
</tr>
</tbody>
</table>
</div>
@if (schedule.shieldedInterest > 0) {
<div class="f121__foot">
₹{{ schedule.shieldedInterest | number: '1.0-0' }} was paid without deduction on the strength of the
declaration. This amount is still reportable in the quarterly {{ statute.statement }} return.
</div>
}
</div>
<!-- ── Section 1: accounts that count ── -->
<div class="f121__card">
<div class="f121__card-head">
<mat-icon>account_balance</mat-icon>
<span>Deposits counted towards this customer's TDS</span>
</div>
@if (liableAccounts.length) {
<div class="f121__table-wrap">
<table class="f121__table">
<thead>
<tr>
<th>Account</th>
<th>Type</th>
<th class="num">Interest (FY)</th>
<th class="num">TDS deducted</th>
<th>Withholding</th>
<th>Settles</th>
</tr>
</thead>
<tbody>
@for (a of liableAccounts; track trackAccount($index, a)) {
<tr>
<td>
<div class="f121__mono">{{ a.accountNo }}</div>
<div class="muted">{{ a.productName }}</div>
</td>
<td>{{ a.kind }}</td>
<td class="num">₹{{ a.interest | number: '1.0-0' }}</td>
<td class="num">₹{{ a.tdsDeducted | number: '1.0-0' }}</td>
<td>
@if (a.withHoldTax) {
<span class="f121__pill f121__pill--warn">On</span>
@if (a.taxGroup) {
<div class="muted">{{ a.taxGroup }}</div>
}
} @else {
<span class="f121__pill">Off</span>
}
</td>
<td>{{ a.mode === 'QUARTERLY' ? 'Quarterly' : 'Year end' }}</td>
</tr>
}
</tbody>
</table>
</div>
<div class="f121__foot">
Aggregated across every branch — a bank on core banking computes the deduction threshold for the bank as a
whole, not branch by branch.
</div>
} @else {
<div class="f121__empty">No fixed or recurring deposits where this customer is the deductee.</div>
}
</div>
<!-- ── Section 2: joint holdings ── -->
@if (jointHolderAccounts.length) {
<div class="f121__card">
<div class="f121__card-head">
<mat-icon>group</mat-icon>
<span>Deposits where this customer is a joint holder</span>
</div>
<div class="f121__table-wrap">
<table class="f121__table">
<thead>
<tr>
<th>Account</th>
<th>Type</th>
<th class="num">Interest (FY)</th>
<th>Deducted against</th>
</tr>
</thead>
<tbody>
@for (a of jointHolderAccounts; track trackAccount($index, a)) {
<tr>
<td>
<div class="f121__mono">{{ a.accountNo }}</div>
<div class="muted">{{ a.productName }}</div>
</td>
<td>{{ a.kind }}</td>
<td class="num">₹{{ a.interest | number: '1.0-0' }}</td>
<td class="muted">
First-named holder
@if (a.deducteeClientId) {
(client {{ a.deducteeClientId }})
}
</td>
</tr>
}
</tbody>
</table>
</div>
<div class="f121__foot">
Tax on a joint deposit is deducted against the first-named holder's PAN, so this interest is
<strong>not</strong> counted in the figures above and does not use up this customer's threshold.
</div>
</div>
}
<!-- ── Section 3: exempt accounts ── -->
@if (exemptAccounts.length) {
<div class="f121__card">
<div class="f121__card-head">
<mat-icon>savings</mat-icon>
<span>Savings and current accounts</span>
</div>
<div class="f121__table-wrap">
<table class="f121__table">
<thead>
<tr>
<th>Account</th>
<th>Type</th>
<th class="num">Interest (FY)</th>
<th>TDS</th>
</tr>
</thead>
<tbody>
@for (a of exemptAccounts; track trackAccount($index, a)) {
<tr>
<td>
<div class="f121__mono">{{ a.accountNo }}</div>
<div class="muted">{{ a.productName }}</div>
</td>
<td>{{ a.kind }}</td>
<td class="num">₹{{ a.interest | number: '1.0-0' }}</td>
<td><span class="f121__pill">Not applicable</span></td>
</tr>
}
</tbody>
</table>
</div>
<div class="f121__foot">
The charge applies to time deposits. Savings bank interest is outside it and is excluded from the threshold
— the customer claims the 80TTA or 80TTB deduction on it in their own return instead.
</div>
</div>
}
}
}
</div>
@@ -0,0 +1,334 @@
/**
* 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/.
*/
.f121 {
--f121-line: #e5e7eb;
--f121-bg: #f9fafb;
--f121-ink: #111827;
--f121-muted: #6b7280;
--f121-blue: #1d4ed8;
--f121-green: #15803d;
--f121-amber: #b45309;
padding: 16px;
font-size: 13px;
color: var(--f121-ink);
}
.f121__loading {
display: flex;
align-items: center;
gap: 12px;
padding: 40px 16px;
color: var(--f121-muted);
}
/* ── Header ── */
.f121__head {
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 16px;
}
.f121__title {
margin: 0;
font-size: 16px;
font-weight: 700;
}
.f121__sub {
margin-top: 4px;
color: var(--f121-muted);
font-size: 12px;
}
.f121__controls {
display: flex;
gap: 16px;
align-items: flex-end;
}
.f121__field {
display: flex;
flex-direction: column;
gap: 4px;
> span {
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--f121-muted);
}
select {
padding: 7px 10px;
border: 1px solid var(--f121-line);
border-radius: 6px;
background: #fff;
font-size: 13px;
color: inherit;
}
}
.f121__mode {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 10px;
border: 1px solid var(--f121-line);
border-radius: 6px;
background: var(--f121-bg);
font-weight: 600;
cursor: help;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
color: var(--f121-muted);
}
}
/* ── KPI strip ── */
.f121__kpis {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 10px;
margin-bottom: 16px;
}
.f121__kpi {
padding: 12px 14px;
border: 1px solid var(--f121-line);
border-left: 3px solid var(--f121-blue);
border-radius: 8px;
background: #fff;
&--ok {
border-left-color: var(--f121-green);
}
&--warn {
border-left-color: var(--f121-amber);
}
}
.f121__kpi-n {
font-size: 19px;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.f121__kpi-l {
margin-top: 2px;
font-size: 11px;
color: var(--f121-muted);
line-height: 1.35;
}
/* ── Notes ── */
.f121__note {
display: flex;
gap: 10px;
align-items: flex-start;
margin-bottom: 12px;
padding: 11px 13px;
border-radius: 8px;
border-left: 3px solid var(--f121-blue);
background: color-mix(in srgb, var(--f121-blue) 6%, transparent);
line-height: 1.5;
mat-icon {
flex-shrink: 0;
font-size: 18px;
width: 18px;
height: 18px;
color: var(--f121-blue);
}
&--warn {
border-left-color: var(--f121-amber);
background: color-mix(in srgb, var(--f121-amber) 8%, transparent);
mat-icon {
color: var(--f121-amber);
}
}
&--tip {
border-left-color: var(--f121-green);
background: color-mix(in srgb, var(--f121-green) 7%, transparent);
mat-icon {
color: var(--f121-green);
}
}
}
/* ── Cards ── */
.f121__card {
margin-bottom: 16px;
border: 1px solid var(--f121-line);
border-radius: 10px;
background: #fff;
overflow: hidden;
}
.f121__card-head {
display: flex;
align-items: center;
gap: 8px;
padding: 11px 14px;
background: var(--f121-bg);
border-bottom: 1px solid var(--f121-line);
font-weight: 700;
font-size: 12.5px;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: var(--f121-muted);
}
}
.f121__foot {
padding: 10px 14px;
border-top: 1px solid var(--f121-line);
background: var(--f121-bg);
color: var(--f121-muted);
font-size: 11.5px;
line-height: 1.5;
}
.f121__empty {
padding: 20px 14px;
color: var(--f121-muted);
text-align: center;
}
/* ── Key/value list ── */
.f121__kv-row {
display: grid;
grid-template-columns: 190px 1fr;
gap: 12px;
padding: 10px 14px;
border-bottom: 1px solid #f3f4f6;
line-height: 1.5;
&:last-child {
border-bottom: 0;
}
> span:first-child {
color: var(--f121-muted);
font-weight: 600;
}
&--muted > span:last-child {
color: var(--f121-muted);
font-size: 12px;
}
&--warn > span:last-child {
color: var(--f121-amber);
}
}
/* ── Tables ── */
.f121__table-wrap {
overflow-x: auto;
}
.f121__table {
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
th {
text-align: left;
padding: 10px 14px;
background: #fff;
border-bottom: 1px solid var(--f121-line);
color: var(--f121-muted);
font-weight: 700;
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: 0.04em;
white-space: nowrap;
}
td {
padding: 10px 14px;
border-bottom: 1px solid #f3f4f6;
vertical-align: top;
}
tr:last-child td {
border-bottom: 0;
}
.num {
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.muted {
color: var(--f121-muted);
font-size: 11.5px;
}
}
.f121__row--warn > td {
background: color-mix(in srgb, var(--f121-amber) 7%, transparent);
}
.f121__row--total > td {
background: var(--f121-bg);
border-top: 1px solid var(--f121-line);
}
/* ── Bits ── */
.f121__pill {
display: inline-flex;
align-items: center;
padding: 2px 9px;
border-radius: 999px;
border: 1px solid var(--f121-line);
background: var(--f121-bg);
color: var(--f121-muted);
font-size: 10.5px;
font-weight: 700;
white-space: nowrap;
& + & {
margin-left: 5px;
}
&--ok {
border-color: color-mix(in srgb, var(--f121-green) 35%, transparent);
background: color-mix(in srgb, var(--f121-green) 10%, transparent);
color: var(--f121-green);
}
&--warn {
border-color: color-mix(in srgb, var(--f121-amber) 35%, transparent);
background: color-mix(in srgb, var(--f121-amber) 12%, transparent);
color: var(--f121-amber);
}
}
.f121__mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-weight: 600;
}
@@ -0,0 +1,500 @@
/**
* 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, OnDestroy, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute } from '@angular/router';
import { MatIcon } from '@angular/material/icon';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { MatTooltip } from '@angular/material/tooltip';
import { Observable, Subject, forkJoin, of } from 'rxjs';
import { catchError, switchMap, takeUntil } from 'rxjs/operators';
/** Custom Services */
import { ClientsService } from '../../clients.service';
import { SavingsService } from 'app/savings/savings.service';
import { FixedDepositsService } from 'app/deposits/fixed-deposits/fixed-deposits.service';
import { RecurringDepositsService } from 'app/deposits/recurring-deposits/recurring-deposits.service';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Domain logic */
import {
Declaration,
DeclarationForm,
EligibilityResult,
FY_TAX_CONFIG,
InterestCredit,
TdsAccountInput,
TdsMode,
TdsQuarterLine,
TdsSchedule,
ageInFy,
buildSchedule,
checkEligibility,
collectTdsCredits,
declarationFormFor,
formKindFor,
fyOf,
isTdsApplicable,
isWellFormedPan,
normaliseFy,
tdsDeducteeOf,
tdsModeFor
} from 'app/letter-mgmt/certificates/form-15gh/tds-15gh.compute';
/** One of the customer's interest-bearing accounts, with its FY figures. */
export interface Form121AccountRow {
accountNo: string;
productName: string;
/** 'Fixed Deposit', 'Recurring Deposit' or 'Savings'. */
kind: string;
/** Interest credited to this account inside the selected FY. */
interest: number;
/** Tax Fineract has actually withheld on this account inside the FY. */
tdsDeducted: number;
/** Whether Fineract is currently withholding tax on this account. */
withHoldTax: boolean;
taxGroup: string | null;
/** When tax on this account settles — from the product, not a preference. */
mode: TdsMode;
/** Interest here attracts TDS at all (a time deposit, not a savings account). */
tdsApplicable: boolean;
/** This customer is the deductee — false when they are a joint holder only. */
isDeductee: boolean;
/** Who the tax is deducted against, when it is not this customer. */
deducteeClientId: number | null;
}
/** Fineract hands dates back as [yyyy, m, d]; normalise to ISO. */
function toIso(value: unknown): string | null {
if (Array.isArray(value) && value.length >= 3) {
const [
y,
m,
d
] = value as number[];
return `${y}-${`${m}`.padStart(2, '0')}-${`${d}`.padStart(2, '0')}`;
}
if (typeof value === 'string' && value.trim()) {
const parsed = new Date(value);
if (!isNaN(parsed.getTime())) {
return parsed.toISOString().slice(0, 10);
}
}
return null;
}
@Component({
selector: 'mifosx-form121-tab',
templateUrl: './form121-tab.component.html',
styleUrls: ['./form121-tab.component.scss'],
standalone: true,
imports: [
...STANDALONE_SHARED_IMPORTS,
CommonModule,
FormsModule,
MatIcon,
MatProgressSpinner,
MatTooltip
]
})
export class Form121TabComponent implements OnInit, OnDestroy {
private route = inject(ActivatedRoute);
private clientsService = inject(ClientsService);
private savingsService = inject(SavingsService);
private fixedDepositsService = inject(FixedDepositsService);
private recurringDepositsService = inject(RecurringDepositsService);
private destroy$ = new Subject<void>();
clientId = '';
loading = true;
loadError = false;
// ── Customer ──
clientName = '';
dateOfBirth = '';
pan = '';
panWellFormed = false;
// ── Declaration on record ──
declarationOnRecord = false;
/**
* The form actually in force for the selected year. From FY 2026-27 a single
* Form 121 replaced the 15G/15H split, so this is derived from the year rather
* than from the payee's age.
*/
declarationForm: DeclarationForm = '121';
/** Kind recorded at KYC, meaningful only for a pre-2026 year. */
declarationKind: '15G' | '15H' = '15G';
declarationFy = '';
/**
* The KYC step records only the year, not the date the branch accepted the
* declaration, so it has to be treated as covering the whole year. Surfaced on
* screen rather than hidden — a mid-year acceptance would change the figures.
*/
acceptanceDateKnown = false;
eligibility: EligibilityResult | null = null;
// ── Selection ──
availableFys: string[] = [];
fy = '';
// ── Computed ──
accounts: Form121AccountRow[] = [];
schedule: TdsSchedule | null = null;
actualTds = 0;
tdsSaved = 0;
/** Raw account inputs, kept so the year selector can recompute cheaply. */
private accountInputs: (TdsAccountInput & { meta: Form121AccountRow })[] = [];
/** Withheld-tax postings by FY, from the ledger. */
private actualTdsByFy = new Map<string, number>();
ngOnInit(): void {
this.route.parent.params.pipe(takeUntil(this.destroy$)).subscribe((params: any) => {
this.clientId = params['clientId'];
this.load();
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
// ────────────────────────────── Loading ──────────────────────────────
private load(): void {
this.loading = true;
this.loadError = false;
forkJoin({
client: this.clientsService.getClientData(this.clientId, true).pipe(catchError(() => of(null))),
kyc: this.clientsService.getClientKyc(this.clientId, true).pipe(catchError(() => of(null))),
accounts: this.clientsService.getClientAccountData(this.clientId).pipe(catchError(() => of(null)))
})
.pipe(
takeUntil(this.destroy$),
switchMap(({ client, kyc, accounts }) => {
this.applyClient(client);
this.applyKyc(kyc);
const deposits = this.depositAccountsOf(accounts);
return deposits.length ? forkJoin(deposits.map((a) => this.fetchAccount(a))) : of([] as any[]);
}),
catchError(() => {
this.loadError = true;
return of([] as any[]);
})
)
.subscribe((details: any[]) => {
this.loading = false;
this.ingest(details.filter(Boolean));
this.recompute();
});
}
private applyClient(client: any): void {
this.clientName = client?.displayName ?? '';
this.dateOfBirth = toIso(client?.dateOfBirth) ?? '';
}
private applyKyc(kyc: any): void {
this.pan = (kyc?.panNumber ?? '').toUpperCase();
this.panWellFormed = isWellFormedPan(this.pan);
this.declarationOnRecord = !!kyc?.form15Applicable;
this.declarationKind = kyc?.form15Type === '15H' ? '15H' : '15G';
this.declarationForm = this.fy ? declarationFormFor(this.fy, this.dateOfBirth) : '121';
this.declarationFy = kyc?.form15FY ?? '';
this.acceptanceDateKnown = false;
}
/** Savings, FD and RD accounts — everything that can credit interest. */
private depositAccountsOf(accounts: any): any[] {
return (accounts?.savingsAccounts ?? []).filter(
(a: any) => a.status?.active || a.status?.matured || a.status?.closed
);
}
private fetchAccount(a: any): Observable<any> {
const id = `${a.id}`;
const code = a.depositType?.code ?? '';
let call: Observable<any>;
if (code.includes('fixedDeposit')) {
call = this.fixedDepositsService.getFixedDepositsAccountData(id);
} else if (code.includes('recurringDeposit')) {
call = this.recurringDepositsService.getRecurringDepositsAccountData(id);
} else {
call = this.savingsService.getSavingsAccountData(id);
}
// One unreadable account must not blank the whole tab.
return call.pipe(catchError(() => of(null)));
}
/**
* Turns each account payload into an engine input.
*
* Joint and minor attributes are read defensively: they are captured on the
* account stepper but may not surface on every read endpoint, and an absent
* field must default to a single-name account rather than throw.
*/
private ingest(details: any[]): void {
this.accountInputs = [];
this.actualTdsByFy = new Map<string, number>();
const fys = new Set<string>();
const clientIdNum = Number(this.clientId);
for (const d of details) {
const code: string = d.depositType?.code ?? '';
const extra = d.extraAttributes ?? d.externalAttributes ?? d;
const ref = {
id: d.id,
accountNo: d.accountNo ?? `${d.id}`,
depositTypeCode: code,
accountStatus: extra.accountStatus ?? d.accountStatus ?? 'SINGLE',
clientId: d.clientId ?? clientIdNum,
primaryClientId: extra.primaryClientId ?? d.primaryClientId ?? null,
guardianClientId: extra.guardianClientId ?? d.guardianClientId ?? null,
jointHolders: extra.jointHolders ?? d.jointHolders ?? [],
interestPostingPeriodType: d.interestPostingPeriodType ?? null
};
const credits: InterestCredit[] = [];
let tdsDeducted = 0;
for (const t of d.transactions ?? []) {
if (t.reversed || t.isReversal) {
continue;
}
const iso = toIso(t.date);
if (!iso) {
continue;
}
fys.add(fyOf(iso));
if (t.transactionType?.interestPosting) {
credits.push({ date: iso, amount: t.amount ?? 0, accountNo: ref.accountNo });
} else if (t.transactionType?.withholdTax) {
const amount = t.amount ?? 0;
tdsDeducted += amount;
const txnFy = fyOf(iso);
this.actualTdsByFy.set(txnFy, (this.actualTdsByFy.get(txnFy) ?? 0) + amount);
}
}
const deductee = tdsDeducteeOf(ref);
const meta: Form121AccountRow = {
accountNo: ref.accountNo,
productName: d.savingsProductName ?? d.depositProductName ?? d.productName ?? '—',
kind: code.includes('fixedDeposit')
? 'Fixed Deposit'
: code.includes('recurringDeposit')
? 'Recurring Deposit'
: 'Savings',
interest: 0,
tdsDeducted,
withHoldTax: !!d.withHoldTax,
taxGroup: d.taxGroup?.name ?? null,
mode: tdsModeFor(ref),
tdsApplicable: isTdsApplicable(ref),
isDeductee: deductee === clientIdNum,
deducteeClientId: deductee
};
this.accountInputs.push({ ...ref, credits, meta });
}
this.availableFys = [...fys].sort().reverse();
if (!this.availableFys.includes(this.fy)) {
this.fy = this.availableFys[0] ?? (this.declarationFy ? normaliseFy(this.declarationFy) : '');
}
}
// ───────────────────────────── Computation ─────────────────────────────
/** Re-runs the engine for the selected year. */
recompute(): void {
this.schedule = null;
this.eligibility = null;
this.tdsSaved = 0;
this.accounts = this.accountInputs.map((a) => a.meta);
if (!this.fy) {
return;
}
const year = normaliseFy(this.fy);
// Per-account interest for the selected year, for the three lists.
for (const a of this.accountInputs) {
a.meta.interest = a.credits.filter((c) => fyOf(c.date) === year).reduce((s, c) => s + c.amount, 0);
}
this.actualTds = this.actualTdsByFy.get(year) ?? 0;
if (!this.dateOfBirth) {
return;
}
// The one place rules 8 to 12 are applied: savings interest dropped, joint
// deposits attributed to the primary holder, every branch aggregated.
const credits = collectTdsCredits(Number(this.clientId), this.accountInputs, year);
const ctx = { fy: year, dateOfBirth: this.dateOfBirth, pan: this.pan, resident: true };
const declaration = this.declarationForSelectedFy();
this.schedule = buildSchedule(this.overallMode, credits, { ...ctx, declaration });
if (declaration) {
const without = buildSchedule(this.overallMode, credits, { ...ctx, declaration: null });
this.tdsSaved = Math.max(0, without.totalTds - this.schedule.totalTds);
}
// The branch cannot compute income earned outside the bank, so the income
// limb is reported as unassessed rather than silently passed.
this.eligibility = checkEligibility(ctx, {
estimatedInterest: this.schedule.totalInterest,
estimatedIncome: 0,
incomeOnRecord: false
});
}
/**
* When tax settles for this customer overall.
*
* Timing is per account, but the customer needs one view. A quarterly account
* anywhere in the book means a deduction falls due at the first quarter end, so
* that is the honest headline; the per-account list carries the detail.
*/
private get overallMode(): TdsMode {
const applicable = this.accountInputs.filter((a) => a.meta.tdsApplicable && a.meta.isDeductee);
return applicable.some((a) => a.meta.mode === 'QUARTERLY') ? 'QUARTERLY' : 'ANNUAL';
}
/** The declaration, but only when it was filed for the year being shown. */
private declarationForSelectedFy(): Declaration | null {
if (!this.declarationOnRecord || !this.fy) {
return null;
}
const year = normaliseFy(this.fy);
if (this.declarationFy && normaliseFy(this.declarationFy) !== year) {
return null;
}
return {
kind: this.declarationKind,
// No acceptance date on record — treated as covering the whole year.
acceptedOn: `${year.slice(0, 4)}-04-01`,
fy: year
};
}
// ───────────────────────── Template helpers ─────────────────────────
get mode(): TdsMode {
return this.schedule?.mode ?? 'QUARTERLY';
}
get age(): number {
return this.dateOfBirth && this.fy ? ageInFy(this.dateOfBirth, this.fy) : 0;
}
get formKind(): string {
return this.dateOfBirth && this.fy ? formKindFor(this.dateOfBirth, this.fy) : '—';
}
/** The form the payee would file for the selected year. */
get formLabel(): string {
if (!this.fy) {
return '—';
}
const form = declarationFormFor(this.fy, this.dateOfBirth);
return form === '121' ? 'Form 121' : `Form ${form}`;
}
/** Which deduction trigger applies, now that the forms no longer split by age. */
get bandLabel(): string {
return this.schedule?.senior ? 'Senior citizen' : 'General';
}
/**
* Provision and return references for the selected year. Both changed on
* 01 April 2026, so the template reads them rather than hard-coding them.
*/
get statute(): { section: string; statement: string; rule: string; frequency: string } {
const cfg = this.fy ? FY_TAX_CONFIG[normaliseFy(this.fy)] : undefined;
return {
section: cfg?.interestTdsSection ?? '—',
statement: cfg?.tdsStatementForm ?? '—',
rule: cfg?.declarationRule ?? '—',
frequency: cfg?.declarationStatementFrequency === 'MONTHLY' ? 'monthly' : 'quarterly'
};
}
/** Accounts whose interest counts towards this customer's deduction aggregate. */
get liableAccounts(): Form121AccountRow[] {
return this.accounts.filter((a) => a.tdsApplicable && a.isDeductee);
}
/** Deposits where this customer is a joint holder but not the deductee. */
get jointHolderAccounts(): Form121AccountRow[] {
return this.accounts.filter((a) => a.tdsApplicable && !a.isDeductee);
}
/** Savings and current accounts — interest outside the charge on time deposits. */
get exemptAccounts(): Form121AccountRow[] {
return this.accounts.filter((a) => !a.tdsApplicable);
}
/** Engine figure less what the ledger shows was actually withheld. */
get variance(): number {
return (this.schedule?.totalTds ?? 0) - this.actualTds;
}
/** How much more interest before TDS starts. */
get headroom(): number {
if (!this.schedule) {
return 0;
}
return Math.max(0, this.schedule.threshold - this.schedule.totalInterest);
}
get thresholdCrossed(): boolean {
return !!this.schedule && this.schedule.totalInterest > this.schedule.threshold;
}
/**
* A declaration is on record but deposit accounts are still set to withhold —
* the branch has not switched them off.
*/
get declarationNotAppliedToAccounts(): boolean {
return !!this.declarationForSelectedFy() && this.liableAccounts.some((a) => a.withHoldTax);
}
/**
* The customer filed a declaration but holds joint deposits where someone else
* is the deductee — their form does nothing for those.
*/
get declarationUselessOnJointAccounts(): boolean {
return !!this.declarationForSelectedFy() && this.jointHolderAccounts.length > 0;
}
/** Worth prompting the branch to collect a declaration. */
get shouldSuggestDeclaration(): boolean {
return !this.declarationForSelectedFy() && (this.schedule?.totalTds ?? 0) > 0;
}
trackLine(_: number, line: TdsQuarterLine): number {
return line.quarter;
}
trackAccount(_: number, account: Form121AccountRow): string {
return account.accountNo;
}
}
@@ -42,6 +42,19 @@
<!-- 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>
<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>
}
<!-- Verification Table -->
<div class="kv-table">
<div class="kv-table__header">
@@ -740,11 +753,33 @@
</mat-select>
</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>
@if (kycForm.get('isException')?.value) {
<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="GSTIN Verification Failed">GSTIN Verification Failed</mat-option>
<mat-option value="CIN Verification Failed">CIN Verification Failed</mat-option>
}
</mat-select>
</mat-form-field>
}
</div>
<!-- Remarks -->
<mat-form-field appearance="outline" class="full-span">
<mat-label>{{ 'labels.inputs.Remarks' | translate }}</mat-label>
<textarea matInput formControlName="remarks" rows="3"></textarea>
</mat-form-field>
</div>
@if (errorMessage) {
@@ -14,6 +14,7 @@ import { catchError, of, Subject, takeUntil } from 'rxjs';
/** Custom Services */
import { ClientsService } from '../../clients.service';
import { deriveKycExceptionFields } from '../../kyc-exception.util';
import { Dates } from 'app/core/utils/dates';
import { jsPDF } from 'jspdf';
import { MatIcon } from '@angular/material/icon';
@@ -121,7 +122,9 @@ export class KycTabComponent implements OnInit, OnDestroy {
'',
[Validators.pattern(/^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$/)]
],
remarks: ['']
remarks: [''],
isException: [false],
exceptionReason: [[]]
});
ngOnInit(): void {
@@ -157,7 +160,9 @@ export class KycTabComponent implements OnInit, OnDestroy {
// the full 12-digit number to re-verify, so don't pre-fill.
aadhaarNumber: '',
gstinNumber: data.gstinNumber || '',
remarks: data.remarks || ''
remarks: data.remarks || '',
isException: data.isException || false,
exceptionReason: data.exceptionReason ? data.exceptionReason.split(', ') : []
});
}
});
@@ -195,6 +200,28 @@ export class KycTabComponent implements OnInit, OnDestroy {
}
}
/**
* Exception columns derived from verification status — see `deriveKycExceptionFields()`.
*
* A field counts as verified if it was verified in this session OR is already verified on the
* stored record; either way the customer has cleared that check. Verifying one of two
* outstanding items narrows the exception to the remaining one rather than closing it.
*/
private buildExceptionFields() {
const panVerified = this.verificationStatus.pan === 'VERIFIED' || this.kycData?.panVerified === true;
const aadhaarVerified = this.verificationStatus.aadhaar === 'VERIFIED' || this.kycData?.aadhaarVerified === true;
return deriveKycExceptionFields({
panVerified,
aadhaarVerified,
aadhaarApplicable: !this.isNonIndividual,
hadException: this.kycData?.isException === true,
previousReason: this.kycData?.exceptionReason || null,
manualReasons: Array.isArray(this.kycForm.value.exceptionReason) ? this.kycForm.value.exceptionReason : [],
todayFormatted: this.dateUtils.formatDate(new Date(), 'dd MMMM yyyy')
});
}
saveKyc(): void {
if (this.kycForm.invalid) {
return;
@@ -210,7 +237,10 @@ export class KycTabComponent implements OnInit, OnDestroy {
panNumber: this.kycForm.value.panNumber || null,
aadhaarNumberMasked: aadhaarMasked,
gstinNumber: this.kycForm.value.gstinNumber || null,
remarks: this.kycForm.value.remarks || null
remarks: this.kycForm.value.remarks || null,
// dateFormat is required so the backend can parse exceptionResolvedOn when an exception closes.
dateFormat: 'dd MMMM yyyy',
...this.buildExceptionFields()
};
if (!this.kycId) {
@@ -244,6 +274,37 @@ export class KycTabComponent implements OnInit, OnDestroy {
});
}
private saveKycBeforeVerify(callback: () => void, field: 'pan' | 'aadhaar' | 'gstin'): void {
if (this.isEditMode) {
const aadhaarRaw = this.kycForm.value.aadhaarNumber || '';
const aadhaarMasked = aadhaarRaw.length === 12 ? 'XXXXXXXX' + aadhaarRaw.slice(-4) : aadhaarRaw || null;
const payload: any = {
locale: 'en',
panNumber: this.kycForm.value.panNumber || null,
aadhaarNumberMasked: aadhaarMasked,
gstinNumber: this.kycForm.value.gstinNumber || null,
remarks: this.kycForm.value.remarks || null,
riskCategory: this.kycForm.value.riskCategory || 1,
dateFormat: 'dd MMMM yyyy',
...this.buildExceptionFields()
};
this.clientsService.updateClientKyc(this.clientId, this.kycId, payload).subscribe({
next: () => {
this.loadKycData();
callback();
},
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.`;
}
});
} else {
callback();
}
}
verifyPAN(): void {
if (!this.kycId) {
this.verificationError.pan = 'KYC record not found. Save KYC details first.';
@@ -254,31 +315,33 @@ export class KycTabComponent implements OnInit, OnDestroy {
this.verificationError.pan = null;
this.verificationDetails.pan = null;
this.clientsService
.verifyPAN(this.clientId, this.kycId)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (response: any) => {
this.verifying.pan = false;
if (response?.changes?.panVerified) {
this.verificationStatus.pan = 'VERIFIED';
this.verificationDetails.pan = response.changes;
this.loadKycData();
} else {
this.saveKycBeforeVerify(() => {
this.clientsService
.verifyPAN(this.clientId, this.kycId)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (response: any) => {
this.verifying.pan = false;
if (response?.changes?.panVerified) {
this.verificationStatus.pan = 'VERIFIED';
this.verificationDetails.pan = response.changes;
this.loadKycData();
} else {
this.verificationStatus.pan = 'FAILED';
this.verificationError.pan = 'PAN verification failed.';
}
},
error: (err: any) => {
this.verifying.pan = false;
this.verificationStatus.pan = 'FAILED';
this.verificationError.pan = 'PAN verification failed.';
this.verificationError.pan =
err?.error?.developerMessage ||
err?.error?.defaultUserMessage ||
err?.message ||
'PAN verification failed. Please try again.';
}
},
error: (err: any) => {
this.verifying.pan = false;
this.verificationStatus.pan = 'FAILED';
this.verificationError.pan =
err?.error?.developerMessage ||
err?.error?.defaultUserMessage ||
err?.message ||
'PAN verification failed. Please try again.';
}
});
});
}, 'pan');
}
/** Show Aadhaar input in summary view for re-verification */
@@ -305,31 +368,33 @@ export class KycTabComponent implements OnInit, OnDestroy {
this.aadhaarOtpSent = false;
this.aadhaarOtp = '';
this.clientsService
.sendAadhaarOtp(this.clientId, this.kycId, aadhaar)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (response: any) => {
this.verifying.aadhaar = false;
const refNumber =
response?.changes?.aadhaarRefNumber ||
response?.aadhaarRefNumber ||
response?.changes?.referenceNumber ||
response?.resourceId;
this.aadhaarOtpSent = true;
this.aadhaarOtpReferenceId = refNumber || null;
this.verificationStatus.aadhaar = 'PENDING';
},
error: (err: any) => {
this.verifying.aadhaar = false;
this.verificationStatus.aadhaar = 'FAILED';
this.verificationError.aadhaar =
err?.error?.developerMessage ||
err?.error?.defaultUserMessage ||
err?.message ||
'Failed to send OTP. Please try again.';
}
});
this.saveKycBeforeVerify(() => {
this.clientsService
.sendAadhaarOtp(this.clientId, this.kycId, aadhaar)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (response: any) => {
this.verifying.aadhaar = false;
const refNumber =
response?.changes?.aadhaarRefNumber ||
response?.aadhaarRefNumber ||
response?.changes?.referenceNumber ||
response?.resourceId;
this.aadhaarOtpSent = true;
this.aadhaarOtpReferenceId = refNumber || null;
this.verificationStatus.aadhaar = 'PENDING';
},
error: (err: any) => {
this.verifying.aadhaar = false;
this.verificationStatus.aadhaar = 'FAILED';
this.verificationError.aadhaar =
err?.error?.developerMessage ||
err?.error?.defaultUserMessage ||
err?.message ||
'Failed to send OTP. Please try again.';
}
});
}, 'aadhaar');
}
/** Step 2: Verify OTP entered by user */
@@ -380,31 +445,33 @@ export class KycTabComponent implements OnInit, OnDestroy {
this.verificationError.gstin = null;
this.verificationDetails.gstin = null;
this.clientsService
.verifyGSTIN(this.clientId, this.kycId)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (response: any) => {
this.verifying.gstin = false;
if (response?.changes?.gstinVerified) {
this.verificationStatus.gstin = 'VERIFIED';
this.verificationDetails.gstin = response.changes;
this.loadKycData();
} else {
this.saveKycBeforeVerify(() => {
this.clientsService
.verifyGSTIN(this.clientId, this.kycId)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (response: any) => {
this.verifying.gstin = false;
if (response?.changes?.gstinVerified) {
this.verificationStatus.gstin = 'VERIFIED';
this.verificationDetails.gstin = response.changes;
this.loadKycData();
} else {
this.verificationStatus.gstin = 'FAILED';
this.verificationError.gstin = 'GSTIN verification failed.';
}
},
error: (err: any) => {
this.verifying.gstin = false;
this.verificationStatus.gstin = 'FAILED';
this.verificationError.gstin = 'GSTIN verification failed.';
this.verificationError.gstin =
err?.error?.developerMessage ||
err?.error?.defaultUserMessage ||
err?.message ||
'GSTIN verification failed. Please try again.';
}
},
error: (err: any) => {
this.verifying.gstin = false;
this.verificationStatus.gstin = 'FAILED';
this.verificationError.gstin =
err?.error?.developerMessage ||
err?.error?.defaultUserMessage ||
err?.message ||
'GSTIN verification failed. Please try again.';
}
});
});
}, 'gstin');
}
formatDate(date: any): string {
+2
View File
@@ -62,6 +62,7 @@ import { ClientFamilyMembersStepComponent } from './client-stepper/client-family
import { ClientPreviewStepComponent } from './client-stepper/client-preview-step/client-preview-step.component';
import { ClientAddressStepComponent } from './client-stepper/client-address-step/client-address-step.component';
import { ClientFamilyMemberDialogComponent } from './client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component';
import { ClientFamilyMemberChildDialogComponent } from './client-stepper/client-family-members-step/client-family-member-child-dialog/client-family-member-child-dialog.component';
import { CaptureImageDialogComponent } from './clients-view/custom-dialogs/capture-image-dialog/capture-image-dialog.component';
import { AddClientCollateralComponent } from './clients-view/client-actions/add-client-collateral/add-client-collateral.component';
import { ClientDatatableStepComponent } from './client-stepper/client-datatable-step/client-datatable-step.component';
@@ -127,6 +128,7 @@ import { Form60TabComponent } from './clients-view/form60-tab/form60-tab.compone
ClientPreviewStepComponent,
ClientAddressStepComponent,
ClientFamilyMemberDialogComponent,
ClientFamilyMemberChildDialogComponent,
CaptureImageDialogComponent,
AddClientCollateralComponent,
ClientDatatableStepComponent,
+40
View File
@@ -123,6 +123,20 @@ export class ClientsService {
return this.http.post(`/clients/${clientId}/edit/reject`, { rejectReason: rejectReason || '' });
}
/** Fetches the exact email content (subject, body, sentAt, openedAt) for a given pending edit.
* Only populated after the Checker has approved and the send succeeded. */
getClientEditEmailPreview(clientId: string | number, pendingEditId: string | number) {
return this.http.get<{
emailSubject: string | null;
emailBody: string | null;
emailSent: boolean;
emailSentAt: string | null;
emailOpenedAt: string | null;
emailFrom: string | null;
emailTo: string | null;
}>(`/clients/${clientId}/edit/${pendingEditId}/email-preview`);
}
getClientDataAndTemplate(clientId: string) {
const httpParams = new HttpParams().set('template', 'true').set('staffInSelectedOfficeOnly', 'true');
return this.http.get(`/clients/${clientId}`, { params: httpParams });
@@ -958,4 +972,30 @@ export class ClientsService {
dispatchWelcomeKit(clientId: string | number, data: any): Observable<any> {
return this.http.post(`/clients/${clientId}/welcomekit`, data);
}
updateWelcomeKitStatus(welcomeKitId: number, status: string): Observable<any> {
return this.http.put(`/welcomekit/${welcomeKitId}`, { status });
}
getKycExceptions(resolved = false): Observable<any[]> {
return this.http.get<any[]>(`/kyc/exceptions?resolved=${resolved}`);
}
/**
* Records a KYC-exception outreach attempt against a customer, and — for the SMS, EMAIL and
* WHATSAPP channels — actually transmits the notice.
*
* The destination is resolved server-side from the client record; it is deliberately not part of
* this payload, so a tampered request cannot redirect a KYC notice elsewhere.
*
* 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> {
return this.http.post(`/kyc/exceptions/${clientId}/contact-log`, data);
}
getKycContactLog(clientId: string | number): Observable<any[]> {
return this.http.get<any[]>(`/kyc/exceptions/${clientId}/contact-log`);
}
}
@@ -430,8 +430,20 @@ export class CreateClientComponent {
}
this.validationError = '';
// Save KYC
if (this.clientKycStep?.hasKycData && !this.clientKycStep.kycId) {
this.clientsService.saveClientKyc(clientId, this.clientKycStep.kycData).subscribe({ error: () => {} });
if (this.clientKycStep?.hasKycData) {
if (!this.clientKycStep.kycId) {
this.clientsService.saveClientKyc(clientId, this.clientKycStep.kycData).subscribe({
next: (res: any) => {
if (this.clientKycStep) {
this.clientKycStep.kycId = res?.resourceId?.toString() || res?.entityId?.toString() || '';
}
},
error: () => {}
});
} else {
const { kycType, ...updatePayload } = this.clientKycStep.kycData;
this.clientsService.updateClientKyc(clientId, this.clientKycStep.kycId, updatePayload).subscribe({ error: () => {} });
}
}
// Upload photo & signature
if (this.clientKycStep?.photographFile) {
@@ -696,12 +708,20 @@ export class CreateClientComponent {
// Persist the KYC document picker selection + uploaded files as a client identifier
this.saveKycDocProof(clientId);
// Save KYC only if it wasn't already created during the KYC step
if (this.clientKycStep?.hasKycData && !this.clientKycStep.kycId) {
this.clientsService.saveClientKyc(clientId, this.clientKycStep.kycData).subscribe({
next: () => navigateToClient(),
error: () => navigateToClient()
});
// Save/update KYC to ensure latest exception fields are persisted
if (this.clientKycStep?.hasKycData) {
if (!this.clientKycStep.kycId) {
this.clientsService.saveClientKyc(clientId, this.clientKycStep.kycData).subscribe({
next: () => navigateToClient(),
error: () => navigateToClient()
});
} else {
const { kycType, ...updatePayload } = this.clientKycStep.kycData;
this.clientsService.updateClientKyc(clientId, this.clientKycStep.kycId, updatePayload).subscribe({
next: () => navigateToClient(),
error: () => navigateToClient()
});
}
} else {
navigateToClient();
}
@@ -84,14 +84,46 @@
<mat-icon>phone_iphone</mat-icon>
<span>Mobile Number</span>
</div>
<div class="contact-verify-panel__exception-row" style="margin-bottom: 12px;">
<mat-checkbox
[checked]="oldMobileNotAccessible"
(change)="onOldMobileNotAccessibleToggle($event.checked)"
color="primary"
>
Old mobile number not accessible (lost / deactivated SIM)
</mat-checkbox>
</div>
<div class="contact-verify-panel__grid">
<mifosx-client-contact-otp-block
[clientId]="clientId"
channel="MOBILE"
[destination]="contactChanges.oldMobile"
label="Old Mobile Number"
(stateChange)="onOldMobileOtpState($event)"
></mifosx-client-contact-otp-block>
@if (!oldMobileNotAccessible) {
<mifosx-client-contact-otp-block
[clientId]="clientId"
channel="MOBILE"
[destination]="contactChanges.oldMobile"
label="Old Mobile Number"
(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;">
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">
<mat-icon>upload_file</mat-icon>
{{ firFileName ? firFileName : 'Select FIR Document' }}
</button>
@if (uploadingFir) {
<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>
}
</div>
</div>
}
<mifosx-client-contact-otp-block
[clientId]="clientId"
channel="MOBILE"
@@ -33,6 +33,7 @@ import { ContactOtpBlockComponent, ContactOtpState } from './contact-otp-block/c
import { MatTabsModule } from '@angular/material/tabs';
import { MatIconModule } from '@angular/material/icon';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatDivider } from '@angular/material/divider';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
@@ -51,6 +52,7 @@ import { ORIGINAL_VALUES_PAYLOAD_KEY, buildOriginalValuesSnapshot } from '../sha
...STANDALONE_SHARED_IMPORTS,
MatTabsModule,
MatIconModule,
MatCheckboxModule,
MatDivider,
FaIconComponent,
MatProgressSpinner,
@@ -108,6 +110,13 @@ export class EditClientComponent {
newMobileOtpId: number | null = null;
oldMobileVerified = false;
newMobileVerified = false;
// FIR exception flow properties for inaccessible old mobile number
oldMobileNotAccessible = false;
firFileName = '';
firDocumentId: number | null = null;
uploadingFir = false;
oldEmailOtpId: number | null = null;
newEmailOtpId: number | null = null;
oldEmailVerified = false;
@@ -215,11 +224,16 @@ export class EditClientComponent {
return this.detectSensitiveFieldChanges(this.clientDataAndTemplate);
}
/** Whether every OTP block required by the fields that actually changed has been verified. */
/** Whether every OTP block required by the fields that actually changed has been verified (or FIR uploaded for lost mobile). */
get contactVerificationComplete(): boolean {
const changes = this.contactChanges;
if (changes.mobileChanged && !(this.oldMobileVerified && this.newMobileVerified)) {
return false;
if (changes.mobileChanged) {
const isOldSatisfied = this.oldMobileNotAccessible
? (this.firDocumentId != null)
: (this.oldMobileVerified);
if (!isOldSatisfied || !this.newMobileVerified) {
return false;
}
}
if (changes.emailChanged && !(this.oldEmailVerified && this.newEmailVerified)) {
return false;
@@ -227,6 +241,41 @@ export class EditClientComponent {
return true;
}
onOldMobileNotAccessibleToggle(checked?: boolean): void {
if (checked !== undefined) {
this.oldMobileNotAccessible = checked;
}
if (this.oldMobileNotAccessible) {
this.oldMobileOtpId = null;
this.oldMobileVerified = false;
}
}
onFirFileSelected(event: any): void {
const file: File = event.target?.files?.[0];
if (!file) return;
this.firFileName = file.name;
this.uploadingFir = true;
const formData = new FormData();
formData.append('file', file);
formData.append('name', 'FIR - Lost Mobile Number');
formData.append('description', 'FIR police complaint copy for lost mobile number verification exception');
this.clientsService.uploadClientDocument(this.clientId, formData).subscribe({
next: (res: any) => {
this.uploadingFir = false;
this.firDocumentId = res?.resourceId || res?.id || null;
this.snackBar.open('FIR document uploaded successfully', 'Close', { duration: 3000 });
},
error: () => {
this.uploadingFir = false;
this.firDocumentId = null;
this.snackBar.open('Failed to upload FIR document', 'Close', { duration: 4000 });
}
});
}
onOldMobileOtpState(state: ContactOtpState): void {
this.oldMobileOtpId = state.otpId;
this.oldMobileVerified = state.verified;
@@ -254,6 +303,10 @@ export class EditClientComponent {
this.newMobileOtpId = null;
this.oldMobileVerified = false;
this.newMobileVerified = false;
this.oldMobileNotAccessible = false;
this.firFileName = '';
this.firDocumentId = null;
this.uploadingFir = false;
this.oldEmailOtpId = null;
this.newEmailOtpId = null;
this.oldEmailVerified = false;
@@ -439,7 +492,11 @@ export class EditClientComponent {
// email's when BOTH changed in the same save. See the class-level JSDoc above the OTP
// state fields for the full naming rationale.
if (changedFields.mobileChanged) {
clientData.mobileOldOtpId = this.oldMobileOtpId;
if (this.oldMobileNotAccessible && this.firDocumentId) {
(clientData as any).oldMobileVerificationExceptionDocumentId = this.firDocumentId;
} else {
clientData.mobileOldOtpId = this.oldMobileOtpId;
}
clientData.mobileNewOtpId = this.newMobileOtpId;
}
if (changedFields.emailChanged) {
@@ -630,25 +687,34 @@ export class EditClientComponent {
private detectSensitiveFieldChanges(originalData: any) {
const form = this.clientGeneralForm;
// 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 case-normalized for email, so we never silently rewrite the
// customer's email casing into the saved record or the OTP destination.
const newMobile = (form?.get('mobileNo')?.value || '').trim();
const newEmail = (form?.get('emailAddress')?.value || '').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.
if (!form || !originalData) {
return {
nameChanged: false,
mobileChanged: false,
emailChanged: false,
dobChanged: false,
oldName: '',
newName: '',
oldMobile: '',
newMobile: '',
oldEmail: '',
newEmail: '',
oldDob: '',
newDob: ''
};
}
const mobileControl = form.get('mobileNo');
const emailControl = form.get('emailAddress');
// Trimmed so browser autofill adding invisible leading/trailing whitespace never reads as a change
const newMobile = (mobileControl?.value || '').toString().trim();
const newEmail = (emailControl?.value || '').toString().trim();
const newDobRaw = form.get('dateOfBirth')?.value;
const newDob = newDobRaw instanceof Date ? this.toLocalDateString(newDobRaw) : newDobRaw || '';
const oldMobile = (originalData.mobileNo || '').trim();
const oldEmail = (originalData.emailAddress || '').trim();
const oldMobile = (originalData.mobileNo || '').toString().trim();
const oldEmail = (originalData.emailAddress || '').toString().trim();
const oldDobRaw = originalData.dateOfBirth;
const oldDob = Array.isArray(oldDobRaw)
? `${oldDobRaw[0]}-${String(oldDobRaw[1]).padStart(2, '0')}-${String(oldDobRaw[2]).padStart(2, '0')}`
@@ -669,10 +735,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 || '',
@@ -681,12 +747,19 @@ export class EditClientComponent {
fullname: originalData.fullname || ''
});
// Normalize phone numbers by stripping non-digit characters (+91 9009009009 -> 9009009009)
const normPhone = (val: string) => val.replace(/\D/g, '');
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());
return {
nameChanged: newName !== oldName,
mobileChanged: newMobile !== oldMobile,
// 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.
emailChanged: newEmail.toLowerCase() !== oldEmail.toLowerCase(),
mobileChanged,
emailChanged,
dobChanged: newDob !== oldDob,
oldName,
newName,
+175
View File
@@ -0,0 +1,175 @@
/**
* 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 {
deriveKycExceptionFields,
KycExceptionInput,
KYC_EXCEPTION_AADHAAR,
KYC_EXCEPTION_PAN
} from './kyc-exception.util';
const TODAY = '05 August 2026';
/** Individual client, nothing previously recorded — the client-creation case. */
const newIndividual = (panVerified: boolean, aadhaarVerified: boolean): KycExceptionInput => ({
panVerified,
aadhaarVerified,
aadhaarApplicable: true,
hadException: false,
previousReason: null,
todayFormatted: TODAY
});
/** Individual client that already carries an open exception. */
const existingException = (
panVerified: boolean,
aadhaarVerified: boolean,
previousReason: string
): KycExceptionInput => ({
panVerified,
aadhaarVerified,
aadhaarApplicable: true,
hadException: true,
previousReason,
todayFormatted: TODAY
});
describe('deriveKycExceptionFields', () => {
describe('at client creation', () => {
it('marks both PAN and Aadhaar when neither is entered or verified', () => {
expect(deriveKycExceptionFields(newIndividual(false, false))).toEqual({
isException: true,
exceptionReason: `${KYC_EXCEPTION_PAN}, ${KYC_EXCEPTION_AADHAAR}`,
exceptionResolved: false,
exceptionResolvedOn: null
});
});
it('marks only PAN when Aadhaar verified but PAN did not', () => {
expect(deriveKycExceptionFields(newIndividual(false, true))).toEqual({
isException: true,
exceptionReason: KYC_EXCEPTION_PAN,
exceptionResolved: false,
exceptionResolvedOn: null
});
});
it('marks only Aadhaar when PAN verified but Aadhaar did not', () => {
expect(deriveKycExceptionFields(newIndividual(true, false))).toEqual({
isException: true,
exceptionReason: KYC_EXCEPTION_AADHAAR,
exceptionResolved: false,
exceptionResolvedOn: null
});
});
it('raises nothing when both verify, and does not mark the client resolved', () => {
// A clean client never had an exception, so it must not appear in the Resolved tab either.
expect(deriveKycExceptionFields(newIndividual(true, true))).toEqual({
isException: false,
exceptionReason: null,
exceptionResolved: false,
exceptionResolvedOn: null
});
});
it('does not raise an Aadhaar exception for a non-individual client', () => {
// Companies submit a dummy masked Aadhaar; checking it would mean a permanent exception.
expect(
deriveKycExceptionFields({
panVerified: true,
aadhaarVerified: false,
aadhaarApplicable: false,
hadException: false,
previousReason: null,
todayFormatted: TODAY
}).isException
).toBe(false);
});
});
describe('resolving', () => {
it('closes the exception once both checks pass, keeping the reason for the audit trail', () => {
const both = `${KYC_EXCEPTION_PAN}, ${KYC_EXCEPTION_AADHAAR}`;
expect(deriveKycExceptionFields(existingException(true, true, both))).toEqual({
isException: false,
exceptionReason: both,
exceptionResolved: true,
exceptionResolvedOn: TODAY
});
});
it('narrows to the outstanding item when only one of two verifies', () => {
const both = `${KYC_EXCEPTION_PAN}, ${KYC_EXCEPTION_AADHAAR}`;
// PAN verified, Aadhaar still outstanding — stays open, listing Aadhaar alone.
expect(deriveKycExceptionFields(existingException(true, false, both))).toEqual({
isException: true,
exceptionReason: KYC_EXCEPTION_AADHAAR,
exceptionResolved: false,
exceptionResolvedOn: null
});
});
it('narrows the other way round too', () => {
const both = `${KYC_EXCEPTION_PAN}, ${KYC_EXCEPTION_AADHAAR}`;
expect(deriveKycExceptionFields(existingException(false, true, both)).exceptionReason).toBe(KYC_EXCEPTION_PAN);
});
});
describe('re-opening', () => {
it('clears a prior resolution so a new exception returns to Pending', () => {
// Regression: without exceptionResolved=false the record still matched the Resolved query
// and a brand-new exception appeared as already closed.
const result = deriveKycExceptionFields({
panVerified: false,
aadhaarVerified: true,
aadhaarApplicable: true,
hadException: false,
previousReason: 'PAN Card Verification Failed',
todayFormatted: TODAY
});
expect(result.exceptionResolved).toBe(false);
expect(result.exceptionResolvedOn).toBeNull();
expect(result.isException).toBe(true);
});
});
describe('officer-entered reasons', () => {
it('keeps a manual reason alongside the automatic ones', () => {
const result = deriveKycExceptionFields({
...newIndividual(true, true),
manualReasons: ['Address proof pending']
});
expect(result).toEqual({
isException: true,
exceptionReason: 'Address proof pending',
exceptionResolved: false,
exceptionResolvedOn: null
});
});
it('drops a stale automatic reason left in the form once the check passes', () => {
// The form still holds 'PAN Card Verification Failed' from a previous load; PAN has since
// verified, so it must not survive into the payload.
const result = deriveKycExceptionFields({
...newIndividual(true, true),
manualReasons: [KYC_EXCEPTION_PAN]
});
expect(result.isException).toBe(false);
expect(result.exceptionReason).toBeNull();
});
it('does not duplicate a manual reason that matches an outstanding automatic one', () => {
const result = deriveKycExceptionFields({
...newIndividual(false, true),
manualReasons: [KYC_EXCEPTION_PAN]
});
expect(result.exceptionReason).toBe(KYC_EXCEPTION_PAN);
});
});
});
+112
View File
@@ -0,0 +1,112 @@
/**
* 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 a client's KYC exception state from their verification status.
*
* A KYC exception is not something an officer should have to remember to tick — it is simply the
* fact that a mandatory identity check has not passed. Deriving it means the exception list can
* never drift out of step with reality: raising, narrowing and closing all fall out of whether PAN
* and Aadhaar are verified at the moment of the save.
*
* Shared by the client-creation stepper and the client-view KYC tab so both write identical state.
*/
export const KYC_EXCEPTION_PAN = 'PAN Card Verification Failed';
export const KYC_EXCEPTION_AADHAAR = 'Aadhaar Verification Failed';
/** The two reasons this module owns. Anything else on a record was added by an officer by hand. */
export const AUTO_MANAGED_KYC_EXCEPTION_REASONS: string[] = [
KYC_EXCEPTION_PAN,
KYC_EXCEPTION_AADHAAR
];
export interface KycExceptionInput {
/** PAN verified against the Income Tax database. Unverified OR simply not entered → exception. */
panVerified: boolean;
/** Aadhaar verified against UIDAI. Unverified OR simply not entered → exception. */
aadhaarVerified: boolean;
/**
* False for non-individual clients, where no Aadhaar is collected (the payload sends a dummy
* masked value). Without this guard every company would carry a permanent Aadhaar exception.
*/
aadhaarApplicable: boolean;
/** Whether an exception is already recorded — decides raise vs. resolve on a clean save. */
hadException: boolean;
/** Reason currently persisted, kept verbatim when closing so the audit trail survives. */
previousReason: string | null;
/** Officer-entered reasons beyond the two automatic ones; carried through untouched. */
manualReasons?: string[];
/** Today in the tenant's date format — stamped as exceptionResolvedOn when closing. */
todayFormatted: string;
}
export interface KycExceptionFields {
isException: boolean;
exceptionReason: string | null;
exceptionResolved: boolean;
exceptionResolvedOn: string | null;
}
/**
* Maps verification status to the four exception columns the API expects.
*
* Three outcomes:
* - **Something outstanding** → raise/keep the exception, listing only what is still failing. A
* record that had both PAN and Aadhaar outstanding and has since verified PAN comes back with
* Aadhaar alone, which is what narrows the entry rather than closing it.
* - **Nothing outstanding, exception existed** → close it. `exceptionReason` is retained on
* purpose: the Resolved tab shows what was resolved, and clearing it would erase the record of
* why the exception was ever raised.
* - **Nothing outstanding, no exception** → leave every flag off, so a clean client never appears
* in either tab.
*/
export function deriveKycExceptionFields(input: KycExceptionInput): KycExceptionFields {
const outstanding: string[] = [];
if (!input.panVerified) {
outstanding.push(KYC_EXCEPTION_PAN);
}
if (input.aadhaarApplicable && !input.aadhaarVerified) {
outstanding.push(KYC_EXCEPTION_AADHAAR);
}
// Officer-added reasons ride along, but the two automatic ones are filtered out first — a stale
// 'PAN Card Verification Failed' left in the form must not survive PAN actually being verified.
for (const reason of input.manualReasons || []) {
const trimmed = (reason || '').trim();
if (trimmed && !AUTO_MANAGED_KYC_EXCEPTION_REASONS.includes(trimmed) && !outstanding.includes(trimmed)) {
outstanding.push(trimmed);
}
}
if (outstanding.length > 0) {
return {
isException: true,
exceptionReason: outstanding.join(', '),
exceptionResolved: false,
exceptionResolvedOn: null
};
}
if (input.hadException) {
return {
isException: false,
exceptionReason: input.previousReason || null,
exceptionResolved: true,
exceptionResolvedOn: input.todayFormatted
};
}
return {
isException: false,
exceptionReason: null,
exceptionResolved: false,
exceptionResolvedOn: null
};
}
@@ -384,6 +384,14 @@ 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();
@@ -440,18 +448,6 @@ 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.
*
+105 -361
View File
@@ -99,6 +99,30 @@
</svg>
My Disbursements
</a>
<a
class="cashier-nav__item cashier-nav__item--sec"
[class.cashier-nav__item--active]="currentUrl.includes('/clerk/share-withdrawal-request')"
[routerLink]="['/clerk/share-withdrawal-requests']"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="9" />
<path
d="M9.5 9a2.5 2.5 0 0 1 2.5-1.5c1.4 0 2.5.8 2.5 2s-1 1.5-2.5 1.9c-1.5.4-2.5.9-2.5 2.1s1.1 2 2.5 2 2.5-.7 2.5-1.6"
/>
</svg>
My Share Requests
</a>
<a
class="cashier-nav__item cashier-nav__item--sec"
[class.cashier-nav__item--active]="currentUrl.includes('/clerk/share-closure-request')"
[routerLink]="['/clerk/share-closure-requests']"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="4" y="11" width="16" height="9" rx="2" />
<path d="M8 11V7a4 4 0 0 1 8 0v4" />
</svg>
My Share Closures
</a>
@if (clerkAccountLoaded) {
<div class="cashier-nav__item-wrap">
<a
@@ -350,10 +374,6 @@
<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>
@@ -366,10 +386,6 @@
<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>
@@ -446,10 +462,6 @@
<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"
@@ -504,15 +516,6 @@
<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>
@@ -594,6 +597,12 @@
<button mat-menu-item routerLink="/reports/risk-review" tabindex="0">
<i class="fa fa-user-shield menu-item-icon"></i> Risk Review Report
</button>
<button mat-menu-item routerLink="/reports/tds-interest" tabindex="0">
<i class="fa fa-percent menu-item-icon"></i> Deposit Interest &amp; TDS
</button>
<button mat-menu-item routerLink="/reports/tds-remittance" tabindex="0">
<i class="fa fa-landmark menu-item-icon"></i> TDS Remitted to Government
</button>
</mat-menu>
<!-- Admin Mega Menu — 3-column rectangular panel grouping Setup,
@@ -681,7 +690,6 @@
<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
>
@@ -730,11 +738,8 @@
</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; 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('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('DDS Entry')" (click)="subLedgerOpen = false"
><fa-icon icon="coins" class="gl-icon"></fa-icon> DDS Entry</a
@@ -840,72 +845,50 @@
<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')"
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('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('Profit and Loss')"
routerLink="/gl/reports/profit-and-loss"
(click)="glMenuOpen = false"
(click)="glMenuOpen = false; openAccountingReportsDialog('PNL')"
><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')"
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
(click)="glMenuOpen = false; openAccountingReportsDialog('BALANCE-SHEET')"
><fa-icon icon="file-alt" class="gl-icon" style="color: #b45309"></fa-icon> Consolidate Balance Sheet</a
>
<a
class="gl-link"
[class.hidden]="!matchesGlSearch('Comparative Balance Sheet')"
routerLink="/gl/reports/comparative-balance-sheet"
(click)="glMenuOpen = false"
(click)="glMenuOpen = false; openAccountingReportsDialog('COMPARATIVE-BS')"
><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')"
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 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 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('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('Account wise Balance List')"
routerLink="/gl/reports/account-wise-balance"
(click)="glMenuOpen = false"
><fa-icon icon="list" class="gl-icon" style="color: #b45309"></fa-icon> Account wise Balance List</a
><fa-icon icon="list" class="gl-icon"></fa-icon> Account wise Balance List</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('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('GL Wise DR CR')"
routerLink="/gl/reports/gl-wise-dr-cr"
(click)="glMenuOpen = false"
(click)="glMenuOpen = false; openAccountingReportsDialog('GL-DR-CR')"
><fa-icon icon="project-diagram" class="gl-icon" style="color: #b45309"></fa-icon> GL Wise DR / CR
Report</a
>
@@ -915,47 +898,28 @@
<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" style="color: #b45309"></fa-icon> Client wise Balance Report</a
><fa-icon icon="users" class="gl-icon"></fa-icon> Client wise Balance Report</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('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('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
<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
>
</div>
<div class="gl-group">
<div class="gl-group-title">Registers</div>
<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('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('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('Voucher Register')" (click)="glMenuOpen = false"
><fa-icon icon="receipt" class="gl-icon"></fa-icon> Voucher Register</a
>
<a
class="gl-link"
[class.hidden]="!matchesGlSearch('Misc Expense Ledger')"
routerLink="/gl/reports/misc-expense-ledger"
(click)="glMenuOpen = false"
(click)="glMenuOpen = false; openAccountingReportsDialog('MISC-EXP')"
><fa-icon icon="receipt" class="gl-icon" style="color: #b45309"></fa-icon> Misc Expense Ledger</a
>
</div>
@@ -1228,7 +1192,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 routerLink="/investment/bonds" tabindex="0">
<button mat-menu-item 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()">
@@ -1244,13 +1208,13 @@
<!-- Investment -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Investment</div>
<button mat-menu-item routerLink="/investment/dashboard" tabindex="0">
<button mat-menu-item tabindex="0">
<fa-icon icon="calendar-alt" class="menu-item-icon"></fa-icon> Due Date Diary for All Investment
</button>
<button mat-menu-item routerLink="/investment/reports" tabindex="0">
<button mat-menu-item tabindex="0">
<fa-icon icon="dollar-sign" class="menu-item-icon"></fa-icon> Total Int Received &amp; Receivable Report
</button>
<button mat-menu-item routerLink="/investment/reports" tabindex="0">
<button mat-menu-item 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">
@@ -1281,96 +1245,39 @@
<!-- NPA Reports -->
<div class="deposit-menu-group">
<div class="deposit-group-title">NPA Reports</div>
<button mat-menu-item tabindex="0" routerLink="/npa/reports/interest">
<fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> NPA Report
<button mat-menu-item tabindex="0">
<fa-icon icon="chart-line" class="menu-item-icon"></fa-icon> NPA Interest
</button>
<button mat-menu-item tabindex="0" routerLink="/npa/reports/status">
<button mat-menu-item tabindex="0">
<fa-icon icon="file-alt" class="menu-item-icon"></fa-icon> NPA Status Report
</button>
<button mat-menu-item tabindex="0" routerLink="/npa/reports/recovery">
<button mat-menu-item tabindex="0">
<fa-icon icon="undo" class="menu-item-icon"></fa-icon> NPA Recovery Report
</button>
<button mat-menu-item tabindex="0" routerLink="/npa/reports/summary">
<button mat-menu-item tabindex="0">
<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" routerLink="/npa/reports/account-detail">
<button mat-menu-item tabindex="0">
<fa-icon icon="list-alt" class="menu-item-icon"></fa-icon> NPA Account Detail Report
</button>
<button mat-menu-item tabindex="0" routerLink="/npa/reports/movement">
<button mat-menu-item tabindex="0">
<fa-icon icon="exchange" class="menu-item-icon"></fa-icon> NPA Movement Report
</button>
<button mat-menu-item tabindex="0" routerLink="/npa/reports/provision">
<button mat-menu-item tabindex="0">
<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" routerLink="/npa/reports/probable">
<button mat-menu-item tabindex="0">
<fa-icon icon="exclamation-circle" class="menu-item-icon"></fa-icon> Probable NPA
</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>
<button mat-menu-item tabindex="0"><fa-icon icon="flag" class="menu-item-icon"></fa-icon> SMA Report</button>
</div>
</div>
</mat-menu>
@@ -1403,27 +1310,27 @@
<!-- Core / Books -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Core &amp; Books</div>
<button mat-menu-item tabindex="0" routerLink="/reconciliation/voucher">
<button mat-menu-item tabindex="0">
<fa-icon icon="receipt" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Voucher Reconciliation
</button>
<button mat-menu-item tabindex="0" routerLink="/reconciliation/sundry-dr-cr">
<button mat-menu-item tabindex="0">
<fa-icon icon="random" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Sundry Dr / Cr Reconciliation
</button>
<button mat-menu-item tabindex="0" routerLink="/reconciliation/pnl-impact">
<button mat-menu-item tabindex="0">
<fa-icon icon="chart-line" class="menu-item-icon" style="color: #0d9488"></fa-icon>
P&amp;L Impact Reconciliation
</button>
<button mat-menu-item tabindex="0" routerLink="/reconciliation/gl-reconciliation">
<button mat-menu-item tabindex="0">
<fa-icon icon="book-open" class="menu-item-icon" style="color: #0d9488"></fa-icon>
GL Reconciliation
</button>
<button mat-menu-item tabindex="0" routerLink="/reconciliation/fixed-assets">
<button mat-menu-item tabindex="0">
<fa-icon icon="cube" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Fixed Assets Reconciliation
</button>
<button mat-menu-item tabindex="0" routerLink="/reconciliation/inter-company">
<button mat-menu-item tabindex="0">
<fa-icon icon="sitemap" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Inter-Company Reconciliation
</button>
@@ -1432,19 +1339,19 @@
<!-- Bank & Clearing -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Bank &amp; Clearing</div>
<button mat-menu-item tabindex="0" routerLink="/reconciliation/bank-reco">
<button mat-menu-item tabindex="0">
<fa-icon icon="university" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Bank Reconciliation
</button>
<button mat-menu-item tabindex="0" routerLink="/reconciliation/api-bank-reco">
<button mat-menu-item tabindex="0">
<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" routerLink="/reconciliation/neft-rtgs">
<button mat-menu-item tabindex="0">
<fa-icon icon="exchange" class="menu-item-icon" style="color: #0d9488"></fa-icon>
NEFT / RTGS Reconciliation
</button>
<button mat-menu-item tabindex="0" routerLink="/reconciliation/clearing-reco">
<button mat-menu-item tabindex="0">
<fa-icon icon="hand-holding-usd" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Clearing Reconciliation
</button>
@@ -1453,15 +1360,15 @@
<!-- Digital / Payments -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Digital &amp; Payments</div>
<button mat-menu-item tabindex="0" routerLink="/reconciliation/udir">
<button mat-menu-item tabindex="0">
<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" routerLink="/reconciliation/three-way-digital">
<button mat-menu-item tabindex="0">
<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" routerLink="/reconciliation/nach-reco">
<button mat-menu-item tabindex="0">
<fa-icon icon="sync-alt" class="menu-item-icon" style="color: #0d9488"></fa-icon>
NACH Reconciliation
</button>
@@ -1474,7 +1381,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" routerLink="/reconciliation/share-member">
<button mat-menu-item tabindex="0">
<fa-icon icon="coins" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Share Member Reconciliation
</button>
@@ -1483,11 +1390,11 @@
<!-- Compliance & Financials -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Compliance &amp; Financials</div>
<button mat-menu-item tabindex="0" routerLink="/reconciliation/invoice-payment">
<button mat-menu-item tabindex="0">
<fa-icon icon="file-invoice" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Invoice Payment
</button>
<button mat-menu-item tabindex="0" routerLink="/reconciliation/tax-reco">
<button mat-menu-item tabindex="0">
<fa-icon icon="percentage" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Tax Reconciliation
</button>
@@ -1599,12 +1506,17 @@
<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>
<button mat-menu-item routerLink="/reports/tds-interest" tabindex="0">
<fa-icon icon="percentage" class="menu-item-icon" style="color: #15803d"></fa-icon> Deposit Interest &amp;
TDS
</button>
<button mat-menu-item routerLink="/reports/tds-remittance" tabindex="0">
<fa-icon icon="building-columns" class="menu-item-icon" style="color: #15803d"></fa-icon> TDS Remitted to
Govt
</button>
<button mat-menu-item routerLink="/letter-mgmt/hypothecation-certificate" tabindex="0">
<fa-icon icon="file-alt" class="menu-item-icon" style="color: #15803d"></fa-icon>
Hypothecation Certificate
@@ -1806,41 +1718,28 @@
>
<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')"
routerLink="/rbi/oss1"
(click)="rbiMenuOpen = false"
>
<fa-icon icon="file-alt" class="gl-icon" style="color: #0891b2"></fa-icon> OSS 1 XBRL/CIMS
<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>
<a
class="gl-link"
[class.hidden]="!matchesRbiSearch('OSS 2 XBRL')"
routerLink="/rbi/oss2"
(click)="rbiMenuOpen = false"
(click)="rbiMenuOpen = false; openCimsXbrlDialog('OSS-2')"
>
<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')"
routerLink="/rbi/oss3"
(click)="rbiMenuOpen = false"
>
<fa-icon icon="file-alt" class="gl-icon" style="color: #0891b2"></fa-icon> OSS 3 XBRL/CIMS
<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>
</div>
<!-- CIMS / XBRL Returns -->
@@ -1971,161 +1870,6 @@
</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 &amp; 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 &amp; 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>
-41
View File
@@ -677,26 +677,6 @@ $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;
@@ -749,13 +729,6 @@ $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));
@@ -883,20 +856,6 @@ $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;
+4 -35
View File
@@ -98,10 +98,6 @@ 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. */
@@ -120,11 +116,6 @@ 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[] {
@@ -188,9 +179,9 @@ export class ShellComponent implements OnInit, OnDestroy {
return this.router.url;
}
/** True on the manager's full-screen withdrawal-request review screen. */
/** True on the manager's full-screen withdrawal-request (cash or share) review screen. */
get isWithdrawalReviewUrl(): boolean {
return /\/checker-inbox-and-tasks\/withdrawal-requests\/\d+\/review/.test(this.router.url);
return /\/checker-inbox-and-tasks\/(share-)?withdrawal-requests\/\d+\/review/.test(this.router.url);
}
/** ESC key — exit fullscreen at Clerk Dashboard / Election / review screen, restoring full shell header. */
@@ -251,7 +242,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' | 'CONTINGENT-LIAB' | 'GL-DR-CR' | 'MISC-EXP' = 'PNL'
initialReport: 'PNL' | 'BALANCE-SHEET' | 'COMPARATIVE-BS' | 'GL-DR-CR' | 'MISC-EXP' = 'PNL'
): Promise<void> {
const { AccountingReportsDialogComponent } =
await import('../../home/dashboard/accounting-reports-dialog/accounting-reports-dialog.component');
@@ -294,9 +285,7 @@ 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-4' | 'OSS-5' | 'OSS-6' | 'OSS-7' = 'OSS-2'
): Promise<void> {
async openCimsXbrlDialog(initialForm: 'OSS-2' | 'PSA' | 'RAL' | 'RCP-UCB' = 'OSS-2'): Promise<void> {
const { CimsXbrlDialogComponent } =
await import('../../home/dashboard/cims-xbrl-dialog/cims-xbrl-dialog.component');
this.dialog.open(CimsXbrlDialogComponent, {
@@ -312,26 +301,6 @@ 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)="navigateMenu('/cashier/cash-clearing-transfer-summary'); subLedgerOpen = false"
(click)="subLedgerOpen = false"
>
<fa-icon icon="money-bill" class="subledger-icon"></fa-icon> Cash / Clearing / Transfer
</a>
@@ -403,26 +403,17 @@
<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')"
routerLink="/gl/reports/trial-balance"
(click)="glMenuOpen = false"
<a class="subledger-link" [class.hidden]="!matchesGlSearch('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')"
routerLink="/gl/reports/profit-and-loss"
(click)="glMenuOpen = false"
<a class="subledger-link" [class.hidden]="!matchesGlSearch('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('Consolidated Balance Sheet')"
routerLink="/gl/reports/balance-sheet"
[class.hidden]="!matchesGlSearch('Consolidate Balance Sheet')"
(click)="glMenuOpen = false"
><fa-icon icon="file-alt" class="subledger-icon"></fa-icon> Consolidated Balance Sheet</a
><fa-icon icon="file-alt" class="subledger-icon"></fa-icon> Consolidate Balance Sheet</a
>
</div>
<div class="subledger-group">
@@ -457,12 +448,8 @@
(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 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('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('Banker Head Report')" (click)="glMenuOpen = false"
><fa-icon icon="university" class="subledger-icon"></fa-icon> Banker Head Report</a
@@ -365,21 +365,6 @@
</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>
@@ -131,90 +131,6 @@ 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 }[] = [
@@ -736,7 +652,6 @@ 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 });
},
@@ -761,7 +676,6 @@ 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 });
},
+3 -3
View File
@@ -81,7 +81,7 @@ export interface DeafEligibleAccount {
/** Form I — Dec 2025 cycle */
export const FORM_I: DeafForm = {
bankName: 'CBSNexora Bank Ltd, Mumbai Main Branch',
bankName: 'Bharat Urban 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: 'CBSNexora Bank Ltd, Head Office',
bankName: 'Bharat Urban 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: 'CBSNexora Bank Ltd',
bankName: 'Bharat Urban Bank Ltd',
bankDeafCode: '1100',
currentAcWithRbi: '03010200099001',
sponsorBank: 'State Bank of India',
+1 -1
View File
@@ -53,7 +53,7 @@ export interface SahayDepositor {
}
export const DICGC_BANK = {
name: 'CBSNexora Bank Ltd',
name: 'Bharat Urban Bank Ltd',
branch: 'Head Office',
reportAsOn: '29-01-2026',
printDate: '29-01-2026 04:19 PM',
+270 -702
View File
File diff suppressed because it is too large Load Diff
+175 -516
View File
@@ -14,25 +14,12 @@
<div>
<h2 class="fiu-head-t">Financial Intelligence Unit – India (FIU-IND)</h2>
<div class="fiu-head-s">
{{ bank.name }} &middot; {{ scopeName }} &middot; Reporting Month <strong>{{ bank.reportingMonth }}</strong>
{{ bank.name }} &middot; {{ bank.branch }} &middot; Reporting Month <strong>{{ bank.reportingMonth }}</strong>
&middot; 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>
@@ -84,19 +71,17 @@
<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">{{ liveStrPending | number }}</div>
<div class="fiu-kpi-n">{{ pendingStr | number }}</div>
<div class="fiu-kpi-l">STR Pending</div>
<div class="fiu-kpi-sub">
{{ liveStrOpen }} to investigate &middot; {{ liveStrAwaitingFiling }} awaiting filing
</div>
<div class="fiu-kpi-sub">draft / PO review</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">{{ liveStrFiled | number }}</div>
<div class="fiu-kpi-n">{{ filedStr | number }}</div>
<div class="fiu-kpi-l">STR Filed</div>
<div class="fiu-kpi-sub">{{ liveStrCleared }} concluded clear</div>
<div class="fiu-kpi-sub">acknowledged by FIU</div>
</div>
</div>
<div class="fiu-kpi fiu-kpi--purple">
@@ -109,52 +94,6 @@
</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">
@@ -173,12 +112,7 @@
</div>
<div class="dc-meta">Filing window: <strong>within 7 days of detection</strong></div>
<div class="dc-meta">
To investigate: <strong>{{ liveStrOpen }}</strong> &middot; Awaiting filing:
<strong>{{ liveStrAwaitingFiling }}</strong>
</div>
<div class="dc-meta">
Filed: <strong>{{ liveStrFiled }}</strong> &middot; Concluded clear:
<strong>{{ liveStrCleared }}</strong>
Pending: <strong>{{ pendingStr }}</strong> &middot; Filed: <strong>{{ filedStr }}</strong>
</div>
<button class="dc-btn" (click)="showTab('str')"><fa-icon icon="external-link-alt"></fa-icon> Open</button>
</div>
@@ -195,118 +129,21 @@
<!-- CTR -->
@if (activeTab === 'ctr') {
<div class="fiu-section">
<!-- FINNET FILE SEQUENCE — the CTR submission, in prescribed order -->
<div class="seq-block">
<div class="fiu-form-meta">
<div class="fiu-form-title">
<h3>CTR Submission Files — FINnet 2.0</h3>
<p>
The CTR is built in this order: the account is described first, then who is behind it, then the two
transaction files. Every grid below is the exact content of its CSV. The customer cards (KC1 / KC2) travel
with the same bundle and are worked on the Customer Cards tab.
</p>
</div>
<button class="fiu-btn fiu-btn-xls" (click)="exportAll()">
<fa-icon icon="file-csv"></fa-icon> Download full bundle
</button>
</div>
<!-- Scope: these controls decide which transactions the six files carry. -->
<div class="fiu-filters">
<div class="fiu-filter">
<label>Type</label>
<select class="fiu-inp" [(ngModel)]="ctrFilterMode">
<option value="">All</option>
<option value="Deposit">Deposit</option>
<option value="Withdrawal">Withdrawal</option>
</select>
</div>
<div class="fiu-filter">
<label>Account contains</label>
<input class="fiu-inp" type="text" [(ngModel)]="ctrFilterAccount" placeholder="A/c partial" />
</div>
<div class="fiu-filter">
<label>Min Amount (&#8377;)</label>
<input class="fiu-inp" type="number" [(ngModel)]="ctrFilterMinAmount" placeholder="0" />
</div>
<button class="fiu-btn fiu-btn-g" (click)="resetCtrFilters()">Reset</button>
</div>
<!-- Step rail -->
<div class="seq-rail">
@for (f of finnetSequence; track f.key) {
<button class="seq-step" [class.active]="activeFinnetFile === f.key" (click)="showFinnetFile(f.key)">
<span class="seq-n">{{ f.step }}</span>
<span class="seq-txt">
<strong>{{ f.name }}</strong>
<em>{{ f.table.rows.length }} {{ f.table.rows.length === 1 ? 'row' : 'rows' }}</em>
</span>
</button>
@if (!$last) {
<fa-icon icon="chevron-right" class="seq-arrow"></fa-icon>
}
}
</div>
<!-- Open file -->
<div class="seq-panel">
<div class="seq-panel-h">
<div>
<h4>Step {{ openFinnetFile.step }} — {{ openFinnetFile.title }}</h4>
<p>{{ openFinnetFile.purpose }}</p>
</div>
<button class="fiu-btn fiu-btn-csv" (click)="downloadOpenFinnetFile()">
<fa-icon icon="file-csv"></fa-icon> {{ openFinnetFile.name }}.csv
</button>
</div>
<div class="seq-tbl-wrap">
<table class="seq-tbl">
<thead>
<tr>
<th class="seq-rownum">#</th>
@for (h of openFinnetFile.table.headers; track $index) {
<th>{{ h }}</th>
}
</tr>
</thead>
<tbody>
@for (r of openFinnetFile.table.rows; track $index) {
<tr>
<td class="seq-rownum">{{ $index + 1 }}</td>
@for (c of r; track $index) {
<td>{{ c }}</td>
}
</tr>
} @empty {
<tr>
<td [attr.colspan]="openFinnetFile.table.headers.length + 1" class="seq-empty">
@if (openFinnetFile.key === 'tc1') {
No ATM or card transactions match the current scope. When the bank books none at all, TC1 is
still submitted as a header-only file — that is what FIU-IND expects.
} @else {
No rows for the current filters.
}
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>
}
<!-- STR -->
@if (activeTab === 'str') {
<div class="fiu-section">
<!-- LIVE: suspicious AML alerts awaiting AML Officer review (Structuring, PEP, etc.) -->
<!-- LIVE: cash-related AML alerts awaiting AML Officer review -->
<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>
<h3>Live Cash Alerts</h3>
<p>
Raised by the transaction-monitoring rule engine — review and whitelist or escalate to Principal Officer
</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">
@@ -319,13 +156,12 @@
<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) {
@for (alert of amlAlerts; track alert.id) {
<tr>
<td>{{ alert.id }}</td>
<td>{{ alert.clientDisplayName }}</td>
@@ -336,9 +172,6 @@
<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
@@ -362,20 +195,153 @@
</tr>
} @empty {
<tr>
<td colspan="10" class="fiu-empty">No suspicious behavioral alerts.</td>
<td colspan="9" class="fiu-empty">No live alerts.</td>
</tr>
}
</tbody>
</table>
</div>
<p class="fiu-tbl-legend">
<span class="report-pill report-pending">Pending</span> awaiting Whitelist/Escalate decision &middot;
<span class="report-pill report-cleared">Cleared</span> whitelisted — no report filed &middot;
<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: monthly CTR reports (mechanical, threshold-based) -->
<div class="fiu-form-meta">
<div class="fiu-form-title">
<h3>Monthly CTR Reports</h3>
<p>
Threshold: <strong>&#8377;{{ bank.cashThreshold | number }}</strong> per single txn / connected aggregate
per month
</p>
</div>
<div class="fiu-action-group">
<input class="fiu-inp" type="month" [(ngModel)]="ctrGenerateMonth" />
<button class="fiu-btn fiu-btn-g" *mifosxHasPermission="'REVIEW_AML_ALERT'" (click)="generateCtrReport()">
Generate
</button>
</div>
</div>
<div class="fiu-tbl-wrap">
<table class="fiu-tbl">
<thead>
<tr>
<th>Month</th>
<th>Generated On</th>
<th class="ta-c">Records</th>
<th>Status</th>
<th>Submitted On</th>
</tr>
</thead>
<tbody>
@for (r of amlCtrReports; track r.id) {
<tr>
<td>{{ r.reportMonth }}</td>
<td>{{ r.generatedOn | date: 'medium' }}</td>
<td class="ta-c">{{ r.recordCount }}</td>
<td>
<span [class]="amlStatusClass(r.status)">{{ r.status }}</span>
</td>
<td>{{ r.submittedOn ? (r.submittedOn | date: 'medium') : '—' }}</td>
</tr>
} @empty {
<tr>
<td colspan="5" class="fiu-empty">No CTR reports generated yet.</td>
</tr>
}
</tbody>
</table>
</div>
<!-- FINnet export template (separate concern — TC1/TC2 file-format generation) -->
<div class="fiu-form-meta">
<div class="fiu-form-title">
<h3>FINnet TC2 Export Template</h3>
<p>
All cash transactions ≥ &#8377;{{ bank.cashThreshold | number }} (single or aggregate per month per account)
</p>
</div>
<button class="fiu-btn fiu-btn-csv" (click)="exportTc2()">
<fa-icon icon="file-csv"></fa-icon> Download TC2.csv
</button>
</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 (&#8377;)</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>&#8377;{{ t.txnAmount | number: '1.0-0' }}</strong>
</td>
<td>
{{ t.instrumentType }} <span class="muted">{{ t.instrumentId }}</span>
</td>
<td class="mono">{{ t.branchCode }}</td>
<td class="ta-c">
<span class="rel-pill" [class.non-cust]="t.relationshipFlag === 'Non-Customer'">{{
t.relationshipFlag
}}</span>
</td>
</tr>
} @empty {
<tr>
<td colspan="9" class="fiu-empty">No transactions match the filters.</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
<!-- STR -->
@if (activeTab === 'str') {
<div class="fiu-section">
<!-- LIVE: STR cases with full Principal Officer actions -->
<div class="fiu-form-meta">
<div class="fiu-form-title">
@@ -441,17 +407,6 @@
<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>
@@ -461,99 +416,17 @@
<label for="fiuSourceOfFundsNotes">Source-of-funds notes</label>
<textarea id="fiuSourceOfFundsNotes" rows="2" [(ngModel)]="concludeSourceOfFundsNotes"></textarea>
</div>
<!-- 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 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>
</div>
<!-- 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>
<button class="fiu-btn fiu-btn-xml" (click)="concludeStrCase()">Conclude</button>
@if (selectedStrCase.status === 'CONCLUDED_SUSPICIOUS') {
<div class="file-str-row">
@@ -584,9 +457,8 @@
<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">Closed — Clean</option>
<option value="rejected">Rejected</option>
</select>
</div>
<div class="fiu-filter">
@@ -606,12 +478,7 @@
<div class="fiu-str-list">
@for (s of filteredStr; track s.reportRefNo) {
<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" [class.str-card--filed]="s.status === 'filed'">
<div class="str-card-h">
<div class="str-card-id">
<span class="str-ref">STR-{{ s.reportRefNo }}</span>
@@ -643,9 +510,6 @@
<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>
@@ -657,208 +521,6 @@
<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> &middot; 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 {
@@ -964,13 +626,10 @@
<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. Counts reflect any filters set on the CTR and STR
tabs, so every file in the bundle covers the same transactions.
</p>
<p>6 CSV files in FIU-IND prescribed schema, plus STR XML</p>
</div>
<button class="fiu-btn fiu-btn-xls" (click)="exportAll()">
<fa-icon icon="file-csv"></fa-icon> Download All 7
<fa-icon icon="file-csv"></fa-icon> Download All
</button>
</div>
@@ -982,7 +641,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 ({{ filteredCtr.length }} rows)</p>
<p>Branch / cash transactions ({{ ctrTxns.length }} rows)</p>
<button class="fiu-btn fiu-btn-csv" (click)="exportTc2()">
<fa-icon icon="file-csv"></fa-icon> Download
</button>
@@ -1017,7 +676,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 ({{ strSuspects.length }} suspects — cases concluded clean are excluded)</p>
<p>Suspicious Transaction Report ({{ filedStr + pendingStr }} suspects)</p>
<button class="fiu-btn fiu-btn-xml" (click)="exportStrXml()">
<fa-icon icon="file-alt"></fa-icon> Download XML
</button>
-838
View File
@@ -386,172 +386,6 @@
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%;
@@ -704,50 +538,6 @@
}
}
.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;
@@ -780,11 +570,6 @@
color: var(--f-amber);
}
&.st-awaiting-filing {
background: #ffedd5;
color: #9a3412;
}
&.st-filed {
background: var(--f-green-l);
color: var(--f-green);
@@ -912,11 +697,6 @@
&--filed {
border-color: #86efac;
}
&--highlight {
border-color: var(--f-red);
box-shadow: 0 0 0 3px rgb(192 57 43 / 20%);
}
}
.str-card-h {
@@ -1176,29 +956,6 @@
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;
@@ -1238,303 +995,6 @@
}
}
/* ── 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;
@@ -1549,301 +1009,3 @@
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;
}
File diff suppressed because it is too large Load Diff
@@ -1,638 +0,0 @@
/**
* 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);
}
@@ -1,370 +0,0 @@
<!--
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>
@@ -1,608 +0,0 @@
/**
* 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;
}
}
@@ -1,474 +0,0 @@
/**
* 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);
}
}
@@ -1,296 +0,0 @@
/**
* 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)
};
}
@@ -1,34 +0,0 @@
/**
* 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
@@ -1,560 +0,0 @@
/**
* 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
-843
View File
@@ -1,843 +0,0 @@
/**
* 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 }));
}
-20
View File
@@ -1,20 +0,0 @@
/**
* 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 {}
@@ -397,64 +397,6 @@
</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">&#8377; {{ 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 &mdash; As on {{ bank.asOnDate }} (₹ in Lakhs)</h3>
<button mat-stroked-button class="acr-btn-xls" (click)="exportContingentLiabCsv()">
<fa-icon icon="file-csv"></fa-icon>&nbsp; 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: &#8377; {{ contingentLiab.current | number: '1.2-2' }} Lakh</strong>
</td>
</tr>
</tfoot>
</table>
</div>
<p class="acr-note">
<fa-icon icon="info-circle"></fa-icon>&nbsp; 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">
@@ -318,16 +318,6 @@
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;
@@ -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' | 'CONTINGENT-LIAB' | 'GL-DR-CR' | 'MISC-EXP';
export type AcctReportCode = 'PNL' | 'BALANCE-SHEET' | 'COMPARATIVE-BS' | 'GL-DR-CR' | 'MISC-EXP';
export interface AcctReportsDialogData {
initialReport?: AcctReportCode;
@@ -47,11 +47,6 @@ interface GlLine {
closing: number;
}
interface ContingentLiabItem {
code: string;
label: string;
}
interface MiscExpRow {
txnDate: string;
branch: string;
@@ -86,7 +81,7 @@ export class AccountingReportsDialogComponent {
activeReport: AcctReportCode;
bank = {
name: 'CBSNexora Bank Ltd',
name: 'Bharat Urban Bank Ltd',
branch: 'Head Office',
asOnDate: '31-03-2026',
period: '01-04-2025 to 31-03-2026',
@@ -99,7 +94,7 @@ export class AccountingReportsDialogComponent {
poDesignation: 'Chief Finance Officer',
auditor: 'M/s Sharma & Associates, Chartered Accountants',
reportRefNo: '157/2',
userCode: 'CBSN01',
userCode: 'BUBL01',
branchCount: 15
};
@@ -125,13 +120,6 @@ 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',
@@ -148,192 +136,309 @@ export class AccountingReportsDialogComponent {
}
];
// -------------------- P&L Data (₹ in Lakhs) — FY 2025-26 audited figures --------------------
// -------------------- P&L Data (₹ in Lakhs) --------------------
pnl: PnlLine[] = [
{ 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-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-03',
group: 'INCOME',
subgroup: 'Other Income',
label: 'Commission, Exchange & Brokerage',
amount: 144.2
subgroup: 'Interest Earned',
label: 'Interest on Balances with RBI / Other Banks',
amount: 1240
},
{ code: 'I-04', group: 'INCOME', subgroup: 'Other Income', label: 'Dividend Income', amount: 4.11 },
{
code: 'I-05',
code: 'I-04',
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
label: 'Commission, Exchange & Brokerage',
amount: 1820
},
{ 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 Paid on NABARD Borrowings',
amount: 47083.71
label: 'Interest on RBI / Inter-Bank Borrowings',
amount: 980
},
{
code: 'E-03',
group: 'EXPENDITURE',
subgroup: 'Operating Expenses',
label: 'Operating Expenses (Staff, Admin, Establishment)',
amount: 14503.79
label: 'Payments to & Provisions for Employees',
amount: 8420
},
{
code: 'E-04',
group: 'EXPENDITURE',
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
subgroup: 'Operating Expenses',
label: 'Rent, Taxes & Lighting',
amount: 1240
},
{ code: 'E-05', group: 'EXPENDITURE', subgroup: 'Operating Expenses', label: 'Printing & Stationery', amount: 184 },
{
code: 'E-06',
group: 'EXPENDITURE',
subgroup: 'Provisions',
label: 'Bad Debts Written Off',
amount: 906.44
subgroup: 'Operating Expenses',
label: 'Depreciation on Bank Property',
amount: 642
},
{
code: 'E-07',
group: 'EXPENDITURE',
subgroup: 'Provisions',
label: 'Provision for Staff Training Reserve',
amount: 245.06
subgroup: 'Operating Expenses',
label: 'Auditors Fees & Expenses',
amount: 124
},
{ code: 'E-08', group: 'EXPENDITURE', subgroup: 'Provisions', label: 'Provision for Income Tax', amount: 4600 }
{
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
},
{ code: 'E-11', group: 'EXPENDITURE', subgroup: 'Provisions', label: 'Provisions for Tax', amount: 2120 }
];
// -------------------- Balance Sheet (₹ in Lakhs) — as on 31-03-2026, audited --------------------
// -------------------- Balance Sheet (₹ in Lakhs) --------------------
bs: BsLine[] = [
{
code: 'L-01',
side: 'CAPITAL-LIAB',
group: 'Capital & Reserves',
label: 'Capital (Paid-up Share Capital)',
amount: 92837.18,
prevAmount: 88842.87
amount: 22000,
prevAmount: 20800
},
{
code: 'L-02',
side: 'CAPITAL-LIAB',
group: 'Capital & Reserves',
label: 'Reserves & Surplus',
amount: 155847.97,
prevAmount: 145524.37
amount: 32400,
prevAmount: 28640
},
{
code: 'L-03',
side: 'CAPITAL-LIAB',
group: 'Deposits',
label: 'Deposits',
amount: 1154054.96,
prevAmount: 997239.51
label: 'Demand Deposits — Current Accounts',
amount: 84200,
prevAmount: 78420
},
{
code: 'L-04',
side: 'CAPITAL-LIAB',
group: 'Borrowings',
label: 'Borrowings (NABARD / Other Banks)',
amount: 1113954.48,
prevAmount: 993301.8
group: 'Deposits',
label: 'Savings Bank Deposits',
amount: 218600,
prevAmount: 198400
},
{
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: 121037.61,
prevAmount: 124706.3
amount: 5940,
prevAmount: 5420
},
{
code: 'A-01',
side: 'ASSETS',
group: 'Cash & Bank Balances',
label: 'Cash & Balances with RBI',
amount: 32627.16,
prevAmount: 33361.93
label: 'Cash on Hand',
amount: 8420,
prevAmount: 7820
},
{
code: 'A-02',
side: 'ASSETS',
group: 'Money at Call',
label: 'Balances with Other Banks & Money at Call/Short Notice',
amount: 604619.45,
prevAmount: 549599.47
group: 'Cash & Bank Balances',
label: 'Balances with RBI',
amount: 38400,
prevAmount: 34200
},
{
code: 'A-03',
side: 'ASSETS',
group: 'Investments',
label: 'Investments',
amount: 316953.61,
prevAmount: 248274.61
group: 'Cash & Bank Balances',
label: 'Balances with Other Banks — Current',
amount: 14800,
prevAmount: 12400
},
{
code: 'A-04',
side: 'ASSETS',
group: 'Advances',
label: 'Loans & Advances',
amount: 1627711.4,
prevAmount: 1466401.79
group: 'Cash & Bank Balances',
label: 'Balances with Other Banks — Term Deposits',
amount: 10000,
prevAmount: 9200
},
{
code: 'A-05',
side: 'ASSETS',
group: 'Fixed Assets',
label: 'Fixed Assets (Property, Plant & Equipment)',
amount: 33791.75,
prevAmount: 33727.66
group: 'Money at Call',
label: 'Money at Call & Short Notice',
amount: 12400,
prevAmount: 10800
},
{
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: 'Other Assets',
amount: 22028.83,
prevAmount: 18249.4
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
}
];
// -------------------- 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[] = [
{
@@ -903,7 +1008,6 @@ 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();
}
@@ -1051,49 +1155,6 @@ 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: 'CBSNexora Bank Ltd',
name: 'Bharat Urban 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: 'CBSN01',
userCode: 'BUBL01',
/** 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' | 'OSS-4' | 'OSS-5' | 'OSS-6' | 'OSS-7';
export type CimsFormCode = 'OSS-2' | 'PSA' | 'RAL' | 'RCP-UCB';
export interface CimsXbrlDialogData {
initialForm?: CimsFormCode;
@@ -64,20 +64,20 @@ export class CimsXbrlDialogComponent {
activeTab: 'filing' | 'data' | 'preview' = 'filing';
bank = {
name: 'CBSNexora Bank Ltd',
name: 'Bharat Urban Bank Ltd',
branch: 'Head Office',
bsrCode: 'CBSN0000123',
ifsc: 'CBSN0000123',
legalEntityId: 'CBSN-LEI-2025',
bsrCode: 'BUBL0000123',
ifsc: 'BUBL0000123',
legalEntityId: 'BUBL-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@cbsnexorabank.in',
poEmail: 'compliance@bharaturbanbank.in',
poMobile: '+91-9988002211',
cimsUserId: 'CBSN_CIMS_001',
cimsUserId: 'BUBL_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: 21.82,
value: 14.82,
unit: 'Percent',
type: 'percent'
},
@@ -105,7 +105,7 @@ export class CimsXbrlDialogComponent {
ref: 'oss2:CapAdq.Tier1',
section: 'Capital Adequacy',
label: 'Tier-I CRAR',
value: 20.51,
value: 12.18,
unit: 'Percent',
type: 'percent'
},
@@ -113,7 +113,7 @@ export class CimsXbrlDialogComponent {
ref: 'oss2:CapAdq.Tier2',
section: 'Capital Adequacy',
label: 'Tier-II CRAR',
value: 1.31,
value: 2.64,
unit: 'Percent',
type: 'percent'
},
@@ -121,7 +121,7 @@ export class CimsXbrlDialogComponent {
ref: 'oss2:Asset.Total',
section: 'Asset Classification',
label: 'Total Advances (₹ Lakh)',
value: 4112.04,
value: 416265,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -129,7 +129,7 @@ export class CimsXbrlDialogComponent {
ref: 'oss2:Asset.Standard',
section: 'Asset Classification',
label: 'Standard Assets (₹ Lakh)',
value: 3845.88,
value: 401820,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -137,7 +137,7 @@ export class CimsXbrlDialogComponent {
ref: 'oss2:Asset.SubStd',
section: 'Asset Classification',
label: 'Sub-Standard (₹ Lakh)',
value: 102.41,
value: 6220,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -145,7 +145,7 @@ export class CimsXbrlDialogComponent {
ref: 'oss2:Asset.Doubtful',
section: 'Asset Classification',
label: 'Doubtful (₹ Lakh)',
value: 163.75,
value: 5840,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -153,7 +153,7 @@ export class CimsXbrlDialogComponent {
ref: 'oss2:Asset.Loss',
section: 'Asset Classification',
label: 'Loss (₹ Lakh)',
value: 0,
value: 2385,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -161,7 +161,7 @@ export class CimsXbrlDialogComponent {
ref: 'oss2:Asset.GNPA',
section: 'NPA Position',
label: 'Gross NPA (₹ Lakh)',
value: 266.16,
value: 14445,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -169,7 +169,7 @@ export class CimsXbrlDialogComponent {
ref: 'oss2:Asset.NNPA',
section: 'NPA Position',
label: 'Net NPA (₹ Lakh)',
value: -96.33,
value: 5212,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -177,7 +177,7 @@ export class CimsXbrlDialogComponent {
ref: 'oss2:Asset.GNPAPct',
section: 'NPA Position',
label: 'GNPA %',
value: 6.47,
value: 3.47,
unit: 'Percent',
type: 'percent'
},
@@ -185,7 +185,7 @@ export class CimsXbrlDialogComponent {
ref: 'oss2:Asset.NNPAPct',
section: 'NPA Position',
label: 'NNPA %',
value: -2.6,
value: 1.27,
unit: 'Percent',
type: 'percent'
},
@@ -193,7 +193,7 @@ export class CimsXbrlDialogComponent {
ref: 'oss2:Asset.PCR',
section: 'NPA Position',
label: 'Provision Coverage Ratio',
value: 136.24,
value: 64.13,
unit: 'Percent',
type: 'percent'
},
@@ -201,7 +201,7 @@ export class CimsXbrlDialogComponent {
ref: 'oss2:Profit.NIM',
section: 'Profitability',
label: 'Net Interest Margin',
value: 0.51,
value: 3.21,
unit: 'Percent',
type: 'percent'
},
@@ -209,7 +209,7 @@ export class CimsXbrlDialogComponent {
ref: 'oss2:Profit.ROA',
section: 'Profitability',
label: 'Return on Assets',
value: 0.67,
value: 0.92,
unit: 'Percent',
type: 'percent'
},
@@ -217,7 +217,7 @@ export class CimsXbrlDialogComponent {
ref: 'oss2:Profit.ROE',
section: 'Profitability',
label: 'Return on Equity',
value: 5.38,
value: 11.48,
unit: 'Percent',
type: 'percent'
},
@@ -254,7 +254,7 @@ export class CimsXbrlDialogComponent {
ref: 'psa:Reference.ANBC',
section: 'Reference',
label: 'Adjusted Net Bank Credit (₹ Lakh)',
value: 3274.83,
value: 600000,
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: 0,
value: 25000,
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: 2485.67,
value: 241700,
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: 75.9,
value: 40.28,
unit: 'Percent',
type: 'percent'
},
@@ -286,7 +286,7 @@ export class CimsXbrlDialogComponent {
ref: 'psa:PSL.Agri',
section: 'Sub-targets',
label: 'Agriculture (₹ Lakh)',
value: 39.0,
value: 124000,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -294,7 +294,7 @@ export class CimsXbrlDialogComponent {
ref: 'psa:PSL.AgriPct',
section: 'Sub-targets',
label: 'Agriculture % of ANBC',
value: 1.19,
value: 20.67,
unit: 'Percent',
type: 'percent'
},
@@ -302,7 +302,7 @@ export class CimsXbrlDialogComponent {
ref: 'psa:PSL.SMF',
section: 'Sub-targets',
label: 'Small &amp; Marginal Farmers (₹ Lakh)',
value: 0,
value: 72000,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -310,23 +310,23 @@ export class CimsXbrlDialogComponent {
ref: 'psa:PSL.SMFPct',
section: 'Sub-targets',
label: 'SMF % of ANBC',
value: 0,
value: 12.0,
unit: 'Percent',
type: 'percent'
},
{
ref: 'psa:PSL.MicroEnt',
section: 'Sub-targets',
label: 'MSME Enterprises (₹ Lakh)',
value: 2313.83,
label: 'Micro Enterprises (₹ Lakh)',
value: 51000,
unit: 'INR-Lakh',
type: 'monetary'
},
{
ref: 'psa:PSL.MicroPct',
section: 'Sub-targets',
label: 'MSME Enterprises % of ANBC',
value: 70.65,
label: 'Micro Enterprises % of ANBC',
value: 8.5,
unit: 'Percent',
type: 'percent'
},
@@ -334,7 +334,7 @@ export class CimsXbrlDialogComponent {
ref: 'psa:PSL.Weaker',
section: 'Sub-targets',
label: 'Weaker Sections (₹ Lakh)',
value: 990.28,
value: 85500,
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: 30.22,
value: 14.25,
unit: 'Percent',
type: 'percent'
},
@@ -350,7 +350,7 @@ export class CimsXbrlDialogComponent {
ref: 'psa:PSL.Housing',
section: 'Other Categories',
label: 'Housing (₹ Lakh)',
value: 115.12,
value: 9200,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -358,7 +358,7 @@ export class CimsXbrlDialogComponent {
ref: 'psa:PSL.Education',
section: 'Other Categories',
label: 'Education (₹ Lakh)',
value: 17.32,
value: 4200,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -366,7 +366,7 @@ export class CimsXbrlDialogComponent {
ref: 'psa:PSL.Export',
section: 'Other Categories',
label: 'Export Credit (₹ Lakh)',
value: 0,
value: 3800,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -374,7 +374,7 @@ export class CimsXbrlDialogComponent {
ref: 'psa:PSL.RenEnergy',
section: 'Other Categories',
label: 'Renewable Energy (₹ Lakh)',
value: 0,
value: 2400,
unit: 'INR-Lakh',
type: 'monetary'
}
@@ -395,15 +395,15 @@ export class CimsXbrlDialogComponent {
ref: 'ral:Liab.Capital',
section: 'Liabilities',
label: 'Capital (₹ Lakh)',
value: 272.78,
value: 22000,
unit: 'INR-Lakh',
type: 'monetary'
},
{
ref: 'ral:Liab.Reserves',
section: 'Liabilities',
label: 'Reserves, Surplus &amp; Profit (₹ Lakh)',
value: 712.6,
label: 'Reserves &amp; Surplus (₹ Lakh)',
value: 32400,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -411,7 +411,7 @@ export class CimsXbrlDialogComponent {
ref: 'ral:Liab.DepDemand',
section: 'Liabilities',
label: 'Demand Deposits (₹ Lakh)',
value: 195.92,
value: 84200,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -419,7 +419,7 @@ export class CimsXbrlDialogComponent {
ref: 'ral:Liab.DepSavings',
section: 'Liabilities',
label: 'Savings Deposits (₹ Lakh)',
value: 2081.16,
value: 218600,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -427,7 +427,7 @@ export class CimsXbrlDialogComponent {
ref: 'ral:Liab.DepTerm',
section: 'Liabilities',
label: 'Term Deposits (₹ Lakh)',
value: 4066.01,
value: 462800,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -435,7 +435,7 @@ export class CimsXbrlDialogComponent {
ref: 'ral:Liab.Borrow',
section: 'Liabilities',
label: 'Borrowings (₹ Lakh)',
value: 0,
value: 24180,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -443,7 +443,7 @@ export class CimsXbrlDialogComponent {
ref: 'ral:Liab.Other',
section: 'Liabilities',
label: 'Other Liabilities &amp; Provisions (₹ Lakh)',
value: 780.73,
value: 18420,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -451,7 +451,7 @@ export class CimsXbrlDialogComponent {
ref: 'ral:Asset.Cash',
section: 'Assets',
label: 'Cash on Hand (₹ Lakh)',
value: 99.96,
value: 8420,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -459,15 +459,15 @@ export class CimsXbrlDialogComponent {
ref: 'ral:Asset.RBI',
section: 'Assets',
label: 'Balances with RBI (₹ Lakh)',
value: 0,
value: 38400,
unit: 'INR-Lakh',
type: 'monetary'
},
{
ref: 'ral:Asset.Banks',
section: 'Assets',
label: 'Balances with Banks &amp; Other Institutions (₹ Lakh)',
value: 1349.53,
label: 'Balances with Banks (₹ Lakh)',
value: 24800,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -475,7 +475,7 @@ export class CimsXbrlDialogComponent {
ref: 'ral:Asset.Money',
section: 'Assets',
label: 'Money at Call &amp; Short Notice (₹ Lakh)',
value: 0,
value: 12400,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -483,7 +483,7 @@ export class CimsXbrlDialogComponent {
ref: 'ral:Asset.Invest',
section: 'Assets',
label: 'Investments (₹ Lakh)',
value: 2342.97,
value: 184600,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -491,7 +491,7 @@ export class CimsXbrlDialogComponent {
ref: 'ral:Asset.Adv',
section: 'Assets',
label: 'Advances (₹ Lakh)',
value: 3912.88,
value: 416265,
unit: 'INR-Lakh',
type: 'monetary'
},
@@ -499,23 +499,23 @@ export class CimsXbrlDialogComponent {
ref: 'ral:Asset.Fixed',
section: 'Assets',
label: 'Fixed Assets (₹ Lakh)',
value: 49.48,
value: 14200,
unit: 'INR-Lakh',
type: 'monetary'
},
{
ref: 'ral:Asset.Other',
section: 'Assets',
label: 'Other Assets &amp; Interest Receivable (₹ Lakh)',
value: 354.38,
label: 'Other Assets (₹ Lakh)',
value: 12480,
unit: 'INR-Lakh',
type: 'monetary'
},
{
ref: 'ral:OBE.Guar',
section: 'Off-Balance Sheet',
label: 'Contingent Liabilities (₹ Lakh)',
value: 26.38,
label: 'Bank Guarantees (₹ Lakh)',
value: 18420,
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: 0,
value: 6580,
unit: 'INR-Lakh',
type: 'monetary'
}
@@ -653,293 +653,6 @@ export class CimsXbrlDialogComponent {
type: 'monetary'
}
]
},
'OSS-4': {
code: 'OSS-4',
name: 'OSS-4',
fullName: 'Connected Lending &amp; 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 &amp; 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 &gt; 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 &amp; 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 &amp; 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 &amp; 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 &gt; 30 Days',
value: 0,
unit: 'Number',
type: 'numeric'
}
]
}
};
@@ -947,11 +660,7 @@ export class CimsXbrlDialogComponent {
'OSS-2',
'PSA',
'RAL',
'RCP-UCB',
'OSS-4',
'OSS-5',
'OSS-6',
'OSS-7'
'RCP-UCB'
];
data = inject<CimsXbrlDialogData | null>(MAT_DIALOG_DATA, { optional: true });
@@ -76,7 +76,7 @@ export class CkycReportsDialogComponent {
activeReport: CkycReportCode;
bank = {
name: 'CBSNexora Bank Ltd',
name: 'Bharat Urban 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: 'CBSN01',
userCode: 'BUBL01',
fyStart: '01-04-2025',
fyEnd: '31-03-2026'
};
@@ -121,7 +121,7 @@ export class CkycReportsDialogComponent {
rekyc: RekycRow[] = [
{
ucode: 'CBSN000201',
ucode: 'BUBL000201',
name: 'Ravi Kumar Sharma',
customerType: 'INDIVIDUAL',
risk: 'LOW',
@@ -132,7 +132,7 @@ export class CkycReportsDialogComponent {
segment: 'BELOW-25-LACS'
},
{
ucode: 'CBSN000418',
ucode: 'BUBL000418',
name: 'Priya Patel',
customerType: 'INDIVIDUAL',
risk: 'MEDIUM',
@@ -143,7 +143,7 @@ export class CkycReportsDialogComponent {
segment: 'ABOVE-25-LACS'
},
{
ucode: 'CBSN000522',
ucode: 'BUBL000522',
name: 'Arjun Deshmukh',
customerType: 'INDIVIDUAL',
risk: 'LOW',
@@ -154,7 +154,7 @@ export class CkycReportsDialogComponent {
segment: 'BELOW-25-LACS'
},
{
ucode: 'CBSN000711',
ucode: 'BUBL000711',
name: 'Vikram Reddy',
customerType: 'INDIVIDUAL',
risk: 'HIGH',
@@ -165,7 +165,7 @@ export class CkycReportsDialogComponent {
segment: 'ABOVE-25-LACS'
},
{
ucode: 'CBSN000814',
ucode: 'BUBL000814',
name: 'Lakshmi Iyer',
customerType: 'INDIVIDUAL',
risk: 'LOW',
@@ -176,7 +176,7 @@ export class CkycReportsDialogComponent {
segment: 'BELOW-25-LACS'
},
{
ucode: 'CBSN001012',
ucode: 'BUBL001012',
name: 'Nirav Shah',
customerType: 'INDIVIDUAL',
risk: 'MEDIUM',
@@ -187,7 +187,7 @@ export class CkycReportsDialogComponent {
segment: 'BELOW-25-LACS'
},
{
ucode: 'CBSN001145',
ucode: 'BUBL001145',
name: 'Kavita Verma',
customerType: 'INDIVIDUAL',
risk: 'HIGH',
@@ -198,7 +198,7 @@ export class CkycReportsDialogComponent {
segment: 'ABOVE-25-LACS'
},
{
ucode: 'CBSN001458',
ucode: 'BUBL001458',
name: 'Sameer Kapoor',
customerType: 'INDIVIDUAL',
risk: 'MEDIUM',
@@ -209,7 +209,7 @@ export class CkycReportsDialogComponent {
segment: 'ABOVE-25-LACS'
},
{
ucode: 'CBSN001721',
ucode: 'BUBL001721',
name: 'Karthik Subramanian',
customerType: 'INDIVIDUAL',
risk: 'LOW',
@@ -220,7 +220,7 @@ export class CkycReportsDialogComponent {
segment: 'BELOW-25-LACS'
},
{
ucode: 'CBSN002012',
ucode: 'BUBL002012',
name: 'Abhishek Banerjee',
customerType: 'INDIVIDUAL',
risk: 'MEDIUM',
@@ -231,7 +231,7 @@ export class CkycReportsDialogComponent {
segment: 'ABOVE-25-LACS'
},
{
ucode: 'CBSN002314',
ucode: 'BUBL002314',
name: 'Gaurav Thakur',
customerType: 'INDIVIDUAL',
risk: 'LOW',
@@ -242,7 +242,7 @@ export class CkycReportsDialogComponent {
segment: 'BELOW-25-LACS'
},
{
ucode: 'CBSN002615',
ucode: 'BUBL002615',
name: 'Manmohan Singh Gill',
customerType: 'INDIVIDUAL',
risk: 'HIGH',
@@ -253,7 +253,7 @@ export class CkycReportsDialogComponent {
segment: 'BELOW-25-LACS'
},
{
ucode: 'CBSN002918',
ucode: 'BUBL002918',
name: 'Sunita Devi',
customerType: 'INDIVIDUAL',
risk: 'LOW',
@@ -264,7 +264,7 @@ export class CkycReportsDialogComponent {
segment: 'BELOW-25-LACS'
},
{
ucode: 'CBSN003221',
ucode: 'BUBL003221',
name: 'Deepak Tiwari',
customerType: 'INDIVIDUAL',
risk: 'MEDIUM',
@@ -275,7 +275,7 @@ export class CkycReportsDialogComponent {
segment: 'ABOVE-25-LACS'
},
{
ucode: 'CBSN003524',
ucode: 'BUBL003524',
name: 'Harpreet Kaur',
customerType: 'INDIVIDUAL',
risk: 'MEDIUM',
@@ -286,7 +286,7 @@ export class CkycReportsDialogComponent {
segment: 'ABOVE-25-LACS'
},
{
ucode: 'CBSN003812',
ucode: 'BUBL003812',
name: 'Saraswati Mahila Co-op Society',
customerType: 'SOCIETY',
risk: 'MEDIUM',
@@ -297,7 +297,7 @@ export class CkycReportsDialogComponent {
segment: 'ABOVE-25-LACS'
},
{
ucode: 'CBSN004115',
ucode: 'BUBL004115',
name: 'Aditya Enterprises',
customerType: 'FIRM',
risk: 'HIGH',
@@ -308,7 +308,7 @@ export class CkycReportsDialogComponent {
segment: 'ABOVE-25-LACS'
},
{
ucode: 'CBSN004318',
ucode: 'BUBL004318',
name: 'Vasudha Trading HUF',
customerType: 'HUF',
risk: 'MEDIUM',
@@ -332,7 +332,7 @@ export class CkycReportsDialogComponent {
branchCode: '01',
branch: 'Mumbai Main',
acType: 'SB',
clientId: 'CBSN000201',
clientId: 'BUBL000201',
customerName: 'Ravi Kumar Sharma',
oldRisk: 'LOW',
newRisk: 'LOW',
@@ -345,7 +345,7 @@ export class CkycReportsDialogComponent {
branchCode: '02',
branch: 'Pune FC Road',
acType: 'CA',
clientId: 'CBSN000418',
clientId: 'BUBL000418',
customerName: 'Priya Patel',
oldRisk: 'LOW',
newRisk: 'MEDIUM',
@@ -358,7 +358,7 @@ export class CkycReportsDialogComponent {
branchCode: '03',
branch: 'Nagpur Dharampeth',
acType: 'TL',
clientId: 'CBSN000522',
clientId: 'BUBL000522',
customerName: 'Arjun Deshmukh',
oldRisk: 'MEDIUM',
newRisk: 'LOW',
@@ -371,7 +371,7 @@ export class CkycReportsDialogComponent {
branchCode: '04',
branch: 'Bengaluru Jayanagar',
acType: 'OD',
clientId: 'CBSN000711',
clientId: 'BUBL000711',
customerName: 'Vikram Reddy',
oldRisk: 'MEDIUM',
newRisk: 'HIGH',
@@ -384,7 +384,7 @@ export class CkycReportsDialogComponent {
branchCode: '05',
branch: 'Bengaluru Indiranagar',
acType: 'SB',
clientId: 'CBSN000814',
clientId: 'BUBL000814',
customerName: 'Lakshmi Iyer',
oldRisk: 'LOW',
newRisk: 'LOW',
@@ -397,7 +397,7 @@ export class CkycReportsDialogComponent {
branchCode: '06',
branch: 'Ahmedabad Satellite',
acType: 'CC',
clientId: 'CBSN001012',
clientId: 'BUBL001012',
customerName: 'Nirav Shah',
oldRisk: 'LOW',
newRisk: 'MEDIUM',
@@ -410,7 +410,7 @@ export class CkycReportsDialogComponent {
branchCode: '07',
branch: 'Lucknow Gomti Nagar',
acType: 'CA',
clientId: 'CBSN001145',
clientId: 'BUBL001145',
customerName: 'Kavita Verma',
oldRisk: 'MEDIUM',
newRisk: 'HIGH',
@@ -423,7 +423,7 @@ export class CkycReportsDialogComponent {
branchCode: '08',
branch: 'Delhi Greater Kailash',
acType: 'OD',
clientId: 'CBSN001458',
clientId: 'BUBL001458',
customerName: 'Sameer Kapoor',
oldRisk: 'MEDIUM',
newRisk: 'MEDIUM',
@@ -436,7 +436,7 @@ export class CkycReportsDialogComponent {
branchCode: '09',
branch: 'Chennai Valasaravakkam',
acType: 'SB',
clientId: 'CBSN001721',
clientId: 'BUBL001721',
customerName: 'Karthik Subramanian',
oldRisk: 'LOW',
newRisk: 'LOW',
@@ -449,7 +449,7 @@ export class CkycReportsDialogComponent {
branchCode: '10',
branch: 'Kolkata Salt Lake',
acType: 'TL',
clientId: 'CBSN002012',
clientId: 'BUBL002012',
customerName: 'Abhishek Banerjee',
oldRisk: 'LOW',
newRisk: 'MEDIUM',
@@ -462,7 +462,7 @@ export class CkycReportsDialogComponent {
branchCode: '11',
branch: 'Shimla Sector 4',
acType: 'SB',
clientId: 'CBSN002314',
clientId: 'BUBL002314',
customerName: 'Gaurav Thakur',
oldRisk: 'LOW',
newRisk: 'LOW',
@@ -475,7 +475,7 @@ export class CkycReportsDialogComponent {
branchCode: '12',
branch: 'Amritsar Model Town',
acType: 'CC',
clientId: 'CBSN002615',
clientId: 'BUBL002615',
customerName: 'Manmohan Singh Gill',
oldRisk: 'HIGH',
newRisk: 'HIGH',
@@ -488,7 +488,7 @@ export class CkycReportsDialogComponent {
branchCode: '13',
branch: 'Bhopal Arera Colony',
acType: 'SB',
clientId: 'CBSN002918',
clientId: 'BUBL002918',
customerName: 'Sunita Devi',
oldRisk: 'LOW',
newRisk: 'LOW',
@@ -501,7 +501,7 @@ export class CkycReportsDialogComponent {
branchCode: '14',
branch: 'Indore Vijay Nagar',
acType: 'OD',
clientId: 'CBSN003221',
clientId: 'BUBL003221',
customerName: 'Deepak Tiwari',
oldRisk: 'MEDIUM',
newRisk: 'HIGH',
@@ -514,7 +514,7 @@ export class CkycReportsDialogComponent {
branchCode: '15',
branch: 'Ludhiana Sarabha Nagar',
acType: 'TL',
clientId: 'CBSN003524',
clientId: 'BUBL003524',
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">&#8377; 11,540.55 Cr</div>
<div class="da-bar"><div class="da-fill deposit-fill" style="width: 71%"></div></div>
<div class="da-value deposit">&#8377; 3,264.42 Cr</div>
<div class="da-bar"><div class="da-fill deposit-fill" style="width: 78%"></div></div>
</div>
<div class="da-item">
<div class="da-label">Total Advances</div>
<div class="da-value advance">&#8377; 16,277.11 Cr</div>
<div class="da-value advance">&#8377; 4,162.65 Cr</div>
<div class="da-bar"><div class="da-fill advance-fill" style="width: 100%"></div></div>
</div>
</div>
@@ -1,169 +0,0 @@
<!--
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>&nbsp;{{ bank.name }} </span>
<span class="fdrd-meta"> <fa-icon icon="map-marker-alt"></fa-icon>&nbsp;{{ bank.branch }} </span>
<button class="fdrd-close" (click)="close()" matTooltip="Close" aria-label="Close">&#10005;</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">&#8377; {{ 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">&#8377; {{ 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>&nbsp; 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>&nbsp; Reset
</button>
<button mat-stroked-button class="fdrd-btn-save" [disabled]="!isValid" (click)="save()">
<fa-icon icon="save"></fa-icon>&nbsp; Save
</button>
</div>
</div>
</div>
@@ -1,395 +0,0 @@
/**
* 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);
}
}
@@ -1,146 +0,0 @@
/**
* 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: 'CBSNexora Bank Ltd',
name: 'Bharat Urban 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
@@ -1,213 +0,0 @@
/**
* 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();
}
}
@@ -1,495 +0,0 @@
<!--
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>
@@ -1,562 +0,0 @@
/**
* 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);
}
}
@@ -1,216 +0,0 @@
<!--
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 &amp; 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>
@@ -1,249 +0,0 @@
/**
* 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
-89
View File
@@ -1,89 +0,0 @@
/**
* 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);
}
@@ -1,609 +0,0 @@
<!--
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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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>
@@ -1,760 +0,0 @@
/**
* 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);
}
}
@@ -1,562 +0,0 @@
<!--
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>

Some files were not shown because too many files have changed in this diff Show More