Compare commits

..
Author SHA1 Message Date
sonaliandClaude Sonnet 5 606d14b995 feat(loan-products): build out Loans A/C (EMI) stepper and wire it to the rate register
Adds the curated Loans A/C steps (security/collateral, amount & tenure,
repayment & EMI, interest, prepayment & closure) and a product-type
picker (loan-product-types.ts) shared by the create wizard, product
list and account picker. The interest step and a new register-rate-view
read the live Loan Interest Rate Register for a product's chart/CIBIL
rate instead of a hardcoded value, and a new loan-product-catalog
service + template card back the loan account stepper's product pick.

Also carries forward in-progress Loan Interest Rate Register UI work:
an inline Add Slab panel (replacing window.prompt) and a Submit Details
dialog for confirming name/effective dates before submitting for
approval.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-12 15:53:43 +05:30
sonaliandClaude Sonnet 5 afcd3af918 feat(delinquency): maker-checker UI for Delinquency Ranges and Buckets
Checker inbox now resolves DELINQUENCY_RANGE/DELINQUENCY_BUCKET entries
to readable diffs instead of raw JSON, and each bucket/range screen
shows its own pending-approval state via a new pending-requests panel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-12 15:52:11 +05:30
sonaliandClaude Sonnet 5 d61584f291 feat(loan-products): wire GL mapping, checker diff view, and approval flow for loan interest rate register
- Add GL account picker (gl-picker.component.ts) for mapping each product row to a GL account
- Extract shared diff logic into rate-register-diff.util.ts + loan-rate-register-diff.util.ts
- Add rate-register-diff component to render before/after changes for checker approval
- Rewire checker-inbox and view-checker-inbox to show loan interest rate register diffs
- Refactor rate-register.service.ts (mock-to-real HTTP wiring, GL-aware payload mapping)
- Simplify preview-register component, moving diff rendering into the shared diff component
- Add glAccountId field to RateChartRow model

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-08 17:22:59 +05:30
sonali a5a64d4efe Merge branch 'dev-latest' into feature/advanceLoanProduct 2026-10-07 15:44:36 +05:30
sonali 0ea57b3f82 Add interest loan related UI 2026-10-07 15:30:15 +05:30
keshav 6b0f218b70 Merge branch 'dev-latest' of https://git.lincpay.tech/CBSNexora/cbsnexora into dev-latest 2026-10-07 10:13:37 +05:30
keshav 6226682b36 feat: updates for cashier, tds reports, rbi reserve and dashboard 2026-10-07 10:13:28 +05:30
Sonali LikhitkarandClaude Sonnet 5.5 7c868ae073 feat(loan-products): add loan interest rate register UI
- Add Loan Interest Rate Register screen with CIBIL adjustment and edit-register views, backed by rate-register service and model
- Wire the new screen into products routing and the products menu
- Update loan product details and advance limit/tenure steps

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 16:20:36 +05:30
Sonali LikhitkarandClaude Sonnet 5.5 283ab2f733 feat(loan-products): add advance loan product stepper steps
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 18:06:08 +05:30
170 changed files with 16668 additions and 13531 deletions
-7
View File
@@ -31,8 +31,6 @@
"@angular/localize/init"
],
"assets": [
"src/favicon.ico",
"src/apple-touch-icon.png",
"src/assets/images/cbsnexora-favicon.svg",
"src/robots.txt",
"src/manifest.json",
@@ -41,11 +39,6 @@
"glob": "**/*",
"input": "node_modules/tinymce",
"output": "assets/tinymce"
},
{
"glob": "lottie.min.js",
"input": "node_modules/lottie-web/build/player",
"output": "assets/js"
}
],
"styles": [
-7
View File
@@ -41,7 +41,6 @@
"jspdf-autotable": "^5.0.2",
"lightgallery": "^2.9.0",
"lodash": "4.17.23",
"lottie-web": "^5.13.0",
"moment": "^2.29.4",
"ngx-image-cropper": "^9.1.6",
"ngx-mat-select-search": "^8.0.2",
@@ -18381,12 +18380,6 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/lottie-web": {
"version": "5.13.0",
"resolved": "https://registry.npmjs.org/lottie-web/-/lottie-web-5.13.0.tgz",
"integrity": "sha512-+gfBXl6sxXMPe8tKQm7qzLnUy5DUPJPKIyRHwtpCpyUEYjHYRJC/5gjUvdkuO2c3JllrPtHXH5UJJK8LRYl5yQ==",
"license": "MIT"
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
-1
View File
@@ -71,7 +71,6 @@
"jspdf-autotable": "^5.0.2",
"lightgallery": "^2.9.0",
"lodash": "4.17.23",
"lottie-web": "^5.13.0",
"moment": "^2.29.4",
"ngx-image-cropper": "^9.1.6",
"ngx-mat-select-search": "^8.0.2",
-2
View File
@@ -96,7 +96,6 @@ import { TokenInterceptor as ZitadelTokenInterceptor } from './zitadel/token.int
import { AuthService } from './zitadel/auth.service';
import { environment } from '../environments/environment';
import { CallbackComponent } from './zitadel/callback/callback.component';
import { LoadingScreenComponent } from './shared/loading-screen/loading-screen.component';
import { OAuthModule } from 'angular-oauth2-oidc';
export class CustomMissingTranslationHandler implements MissingTranslationHandler {
@@ -196,7 +195,6 @@ export function HttpLoaderFactory(http: HttpClient) {
AppRoutingModule,
NotFoundComponent,
CallbackComponent,
LoadingScreenComponent,
OAuthModule.forRoot()
],
providers: [
@@ -12,6 +12,7 @@ import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { catchError, of } from 'rxjs';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
interface RegisterRow {
accountId: number;
@@ -83,7 +84,7 @@ export class AccountRegisterComponent implements OnInit {
/** Local date — toISOString() would shift the day in any timezone behind UTC. */
private today(): string {
const d = new Date();
const d = businessToday();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
@@ -352,51 +352,6 @@
</div>
}
<!-- Interest waiting to be posted as this branch closes. Posting runs as part of the close, so
anything unapproved stops it: the branch sees here what it has to approve and where, rather
than finding out from a refusal after filling in the whole count. -->
@if (preview?.fdInterestDue?.length) {
<div class="bd-calc bd-calc--fd">
<div class="bd-calc__head">
<mat-icon>savings</mat-icon>
<strong>Fixed deposit interest due today</strong>
</div>
@for (d of preview.fdInterestDue; track d.productId) {
<div class="bd-calc__row">
<span>
{{ d.productName }} · {{ d.fdType }} · {{ d.accountCount }} deposit(s)
@if (d.reportStatus === 'APPROVED' || d.reportStatus === 'POSTED') {
<span class="bd-fd-ok">{{ d.reportStatus }}</span>
} @else {
<span class="bd-fd-pending">{{ d.reportStatus === 'NONE' ? 'NO REPORT' : d.reportStatus }}</span>
}
</span>
<span class="bd-calc__amt">₹{{ d.totalInterest | number: '1.2-2' }}</span>
</div>
}
</div>
}
@if (preview?.fdInterestPending?.length) {
<div class="bd-warn">
<mat-icon>warning</mat-icon>
<div>
<strong>The day cannot be closed yet.</strong> Fixed deposit interest is not approved for:
{{ preview.fdInterestPending.join(', ') }}. Approve it under Reports → FD Interest Posting Reports, then
close the day. The user who generated a report cannot approve it.
</div>
</div>
}
@if (preview?.savingsInterestPending?.length) {
<div class="bd-warn">
<mat-icon>warning</mat-icon>
<div>
<strong>The day cannot be closed yet.</strong> Savings interest is not approved for:
{{ preview.savingsInterestPending.join(', ') }}. Approve it under Reports → Interest Posting Reports, then
close the day.
</div>
</div>
}
<!-- What every cash point under this branch is actually holding right now, fetched
automatically — so closing the day doesn't mean separately checking Vault Position and
every cashier's own Cashier Position screen by hand. -->
@@ -195,75 +195,6 @@
font-size: 11px;
color: #94a3b8;
}
/* Names the block, for the interest panels that sit beside the cash arithmetic. */
&__head {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
padding-bottom: 6px;
border-bottom: 1px solid #e2e8f0;
font-size: 13px;
color: #0d1b2a;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: #1d4ed8;
}
}
}
/* Interest is money leaving as the day closes, not cash counted in the drawer. */
.bd-calc--fd {
border-left: 3px solid #1d4ed8;
}
.bd-fd-ok,
.bd-fd-pending {
display: inline-block;
margin-left: 6px;
padding: 1px 7px;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.02em;
}
.bd-fd-ok {
background: #dcfce7;
color: #15803d;
}
.bd-fd-pending {
background: #fef3c7;
color: #b45309;
}
/* Why the close will be refused, said before the count is filled in rather than after. */
.bd-warn {
display: flex;
align-items: flex-start;
gap: 8px;
max-width: 640px;
margin-bottom: 14px;
padding: 10px 14px;
background: #fffbeb;
border: 1px solid #fcd34d;
border-radius: 8px;
font-size: 12px;
line-height: 1.5;
color: #78350f;
mat-icon {
flex-shrink: 0;
font-size: 20px;
width: 20px;
height: 20px;
color: #b45309;
}
}
.bd-hint {
@@ -11,6 +11,7 @@ import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { AuthenticationService } from '../../core/authentication/authentication.service';
import { ExportSheetSpec, exportWorkbook } from '../cashier-export';
import { businessToday } from 'app/core/utils/business-today';
interface SummaryRow {
code: string;
@@ -1316,7 +1317,7 @@ export class CashClearingTransferSummaryComponent {
private dateTag = '2607';
constructor() {
const d = new Date();
const d = businessToday();
this.asOnDate = `${String(d.getDate()).padStart(2, '0')}-${String(d.getMonth() + 1).padStart(2, '0')}-${d.getFullYear()}`;
this.dateTag = `${String(d.getFullYear()).slice(-2)}${String(d.getMonth() + 1).padStart(2, '0')}`;
@@ -12,6 +12,7 @@ import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
interface Denom {
id: number;
@@ -63,7 +64,7 @@ export class CashExchangeEntryComponent implements OnInit {
}
ngOnInit(): void {
const d = new Date();
const d = businessToday();
this.exchangeDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service.getActiveDenominations().subscribe({
next: (list) => {
@@ -18,6 +18,7 @@ import { MatInputModule } from '@angular/material/input';
import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
type PageState = 'loading' | 'no-cashier' | 'needs-reconciliation' | 'already-settled' | 'form';
@@ -90,7 +91,7 @@ export class CashSettlementComponent implements OnInit {
saving = false;
ngOnInit(): void {
const d = new Date();
const d = businessToday();
this.settlementDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service
.getTellers()
@@ -17,6 +17,7 @@ import { MatInputModule } from '@angular/material/input';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
interface Denom {
id: number;
@@ -82,7 +83,7 @@ export class CashTransferComponent implements OnInit {
loadingVault = false;
ngOnInit(): void {
const d = new Date();
const d = businessToday();
this.transferDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service.getActiveDenominations().subscribe({
next: (list) => {
@@ -17,6 +17,7 @@ import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { AuthenticationService } from '../../core/authentication/authentication.service';
import { businessToday } from 'app/core/utils/business-today';
interface DenomRow {
denominationId: number;
@@ -71,7 +72,7 @@ export class CashierPositionComponent implements OnInit {
isCashierMode = false;
ngOnInit(): void {
const d = new Date();
const d = businessToday();
this.asOfDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.isCashierMode = this.authService.isCashierUser();
@@ -12,6 +12,7 @@ import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
/** Day-wise denomination-wise closing cash collected from deposits. */
@Component({
@@ -204,7 +205,7 @@ export class ClosingCashReportComponent implements OnInit {
}
ngOnInit(): void {
const d = new Date();
const d = businessToday();
this.date = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.load();
this.service.getTellers().subscribe({ next: (t: any[]) => (this.tellers = t || []), error: () => {} });
@@ -13,6 +13,7 @@ import { MatIconModule } from '@angular/material/icon';
import { Router } from '@angular/router';
import { catchError, of } from 'rxjs';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
/**
* Which part of the day's work a row came from. Set by the server, one per source register.
@@ -95,7 +96,7 @@ export class DayBookComponent implements OnInit {
/** Local date as yyyy-MM-dd — toISOString() would shift the day in any timezone behind UTC. */
private today(): string {
const d = new Date();
const d = businessToday();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
@@ -17,6 +17,7 @@ import { MatInputModule } from '@angular/material/input';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
interface Denom {
id: number;
@@ -79,7 +80,7 @@ export class InterCashierTransferComponent implements OnInit {
}
ngOnInit(): void {
const d = new Date();
const d = businessToday();
this.transferDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service.getActiveDenominations().subscribe({
next: (list) => {
+2 -1
View File
@@ -14,6 +14,7 @@ import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { catchError, finalize, of } from 'rxjs';
import { CashierService } from '../cashier.service';
import { VoucherDetailDialogComponent } from './voucher-detail-dialog.component';
import { businessToday } from 'app/core/utils/business-today';
/** What the signed-in user's part in a voucher was. */
export type MyRole = 'RAISED' | 'VERIFIED' | 'RAISED_AND_VERIFIED';
@@ -357,7 +358,7 @@ export class MyWorkComponent implements OnInit {
}
private todayIso(): string {
const d = new Date();
const d = businessToday();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
}
@@ -18,6 +18,7 @@ import { MatTabsModule } from '@angular/material/tabs';
import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
interface DenomRow {
denominationId: number;
@@ -85,7 +86,7 @@ export class ReconciliationComponent implements OnInit {
loadingHistory = false;
ngOnInit(): void {
const d = new Date();
const d = businessToday();
this.reconDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service
.getTellers()
@@ -33,6 +33,7 @@ import {
import { SponsorDDService } from '../../sponsor-dd/sponsor-dd.service';
import { OrganizationService } from '../../organization/organization.service';
import { CashierIssueDdDialogComponent } from './cashier-issue-dd-dialog.component';
import { businessToday, businessTodayIso } from 'app/core/utils/business-today';
interface TxnRow {
typ: string;
@@ -621,7 +622,7 @@ export class AccountStatementDialogComponent implements OnInit {
@ViewChild('statementContent') statementContent!: ElementRef;
today = new Date().toISOString().slice(0, 10);
today = businessTodayIso();
fromDate = '';
toDate = this.today;
@@ -1870,7 +1871,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
saving = false;
ngOnInit(): void {
const d = new Date();
const d = businessToday();
this.today = `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`;
this.addRow();
this.loadDenominations();
@@ -2103,7 +2104,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
private loadOpeningPosition(): void {
const d = new Date();
const d = businessToday();
const asOfDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.cashierService
.getCashierPositionForMe(asOfDate)
@@ -2837,7 +2838,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
fundingAccountId: number,
fundingClientId: number | null
): void {
const d = new Date();
const d = businessToday();
const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const isCurrent = row.acTyp === 'CA' || row.acTyp === 'CB';
const isGl = row.acTyp === 'GL';
@@ -2969,7 +2970,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
const d = new Date();
const d = businessToday();
const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const isCurrent = row.acTyp === 'CA' || row.acTyp === 'CB';
@@ -3068,7 +3069,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
const d = new Date();
const d = businessToday();
const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const payload = {
@@ -3391,7 +3392,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
const today = new Date();
const today = businessToday();
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
@@ -3512,7 +3513,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
const today = new Date();
const today = businessToday();
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
@@ -4173,7 +4174,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
): void {
const accountUrl = `${cfg.detail}/${accountId}${cfg.detailParams ?? '?associations=all'}`;
const todayStr = (() => {
const d = new Date();
const d = businessToday();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
})();
@@ -4549,7 +4550,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
get todayIso(): string {
const d = new Date();
const d = businessToday();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
@@ -5071,7 +5072,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.sameDayChequeClearing = 0;
return;
}
const today = new Date();
const today = businessToday();
const sameDay = (d: any) => {
if (!d) return false;
const t = Array.isArray(d) && d.length >= 3 ? new Date(d[0], d[1] - 1, d[2]) : new Date(d);
@@ -5959,7 +5960,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
const today = new Date();
const today = businessToday();
const txnDate = `${String(today.getDate()).padStart(2, '0')}/${String(today.getMonth() + 1).padStart(2, '0')}/${today.getFullYear()}`;
const payload: any = {
@@ -6154,7 +6155,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
/** Build the withdrawal-request payload from the current row + loaded account. */
private buildWithdrawalPayload(row: TxnRow, amount: number): any {
const today = new Date();
const today = businessToday();
const txnDateIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
const selectedPt = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
@@ -6259,7 +6260,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
/** Build the deposit-request payload from the current row + loaded account. */
private buildDepositPayload(row: TxnRow, amount: number): any {
const today = new Date();
const today = businessToday();
const txnDateIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
const selectedPt = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
// Send denominations only for cash payments — stale counts on a cheque
@@ -6370,7 +6371,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
const today = new Date();
const today = businessToday();
const txnDate = `${String(today.getDate()).padStart(2, '0')}/${String(today.getMonth() + 1).padStart(2, '0')}/${today.getFullYear()}`;
const txnDateIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
@@ -16,6 +16,7 @@ import { MatInputModule } from '@angular/material/input';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
interface DenomRow {
denominationId: number;
@@ -67,7 +68,7 @@ export class VaultPositionComponent implements OnInit {
errorMsg = '';
ngOnInit(): void {
const d = new Date();
const d = businessToday();
this.asOfDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service
@@ -13,6 +13,7 @@ import { ActivatedRoute } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { catchError, of } from 'rxjs';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
/**
* Which side of the register a screen shows.
@@ -227,7 +228,7 @@ export class VoucherReportComponent implements OnInit {
}
private todayIso(): string {
const now = new Date();
const now = businessToday();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
}
@@ -116,8 +116,8 @@
<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.
A declaration is on record for this year, but one or more deposits still carry a tax group. Those accounts
deduct on their own and ignore the declaration — remove the tax group from them.
</div>
</div>
}
@@ -192,20 +192,90 @@
}
</span>
</div>
@if (declarationOnRecord && declarationFy) {
@if (declarationRecord; as d) {
<div class="f121__kv-row">
<span>Declared for</span>
<span>FY {{ declarationFy }}</span>
<span>FY {{ d.fy }} · Form {{ d.formType }}</span>
</div>
}
@if (declarationOnRecord && !acceptanceDateKnown) {
<div class="f121__kv-row f121__kv-row--muted">
<span>Acceptance date</span>
<div class="f121__kv-row">
<span>Accepted on</span>
<span>
Not captured — the declaration is treated as covering the whole year. A mid-year acceptance would change
the figures below.
{{ d.acceptedOn | date: 'd MMM y' }} — no TDS on interest credited from this day to 31 March. Interest
credited before it is taxed as usual.
</span>
</div>
@if (d.uin) {
<div class="f121__kv-row">
<span>UIN</span>
<span class="f121__mono">{{ d.uin }}</span>
</div>
}
<div class="f121__kv-row">
<span></span>
<span>
<button type="button" class="f121__btn f121__btn--warn" (click)="revokeDeclaration()">
Withdraw declaration
</button>
</span>
</div>
} @else if (fy && !configMissing) {
<div class="f121__kv-row">
<span></span>
<span>
@if (!showDeclarationForm) {
<button type="button" class="f121__btn" (click)="openDeclarationForm()" [disabled]="!panWellFormed">
Record {{ formLabel }} for FY {{ fy }}
</button>
@if (!panWellFormed) {
<div class="muted">A valid PAN must be on the KYC first — a declaration without one is void.</div>
}
}
</span>
</div>
@if (showDeclarationForm) {
<div class="f121__decl-form">
<label>
<span>Accepted on *</span>
<input type="date" [(ngModel)]="newDeclaration.acceptedOn" />
</label>
<label>
<span>UIN</span>
<input type="text" [(ngModel)]="newDeclaration.uin" placeholder="Allotted on acceptance" />
</label>
<label>
<span>Estimated income for the year (₹)</span>
<input type="number" min="0" [(ngModel)]="newDeclaration.estimatedIncome" />
</label>
@if (declarationError) {
<div class="f121__note f121__note--warn">
<mat-icon>error_outline</mat-icon>
<div>{{ declarationError }}</div>
</div>
}
<div class="f121__decl-actions">
<button type="button" class="f121__btn" (click)="showDeclarationForm = false">Cancel</button>
<button
type="button"
class="f121__btn f121__btn--primary"
(click)="saveDeclaration()"
[disabled]="savingDeclaration"
>
{{ savingDeclaration ? 'Saving…' : 'Save declaration' }}
</button>
</div>
</div>
}
}
@for (old of declarations; track old.id) {
@if (old.revoked && old.fy === fy) {
<div class="f121__kv-row f121__kv-row--muted">
<span>Withdrawn</span>
<span>
Form {{ old.formType }} accepted {{ old.acceptedOn | date: 'd MMM y' }}, withdrawn
{{ old.revokedOn | date: 'd MMM y' }} — {{ old.revokeReason }}
</span>
</div>
}
}
@if (eligibility) {
@for (reason of eligibility.reasons; track reason) {
@@ -332,3 +332,61 @@
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-weight: 600;
}
/* ── Recording a declaration ── */
.f121__btn {
padding: 6px 14px;
border: 1px solid #d1d5db;
border-radius: 6px;
background: #fff;
font: inherit;
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
&:disabled {
opacity: 0.55;
cursor: not-allowed;
}
&--primary {
border-color: #1d4ed8;
background: #1d4ed8;
color: #fff;
}
&--warn {
border-color: #b45309;
color: #b45309;
}
}
.f121__decl-form {
display: grid;
gap: 10px;
padding: 12px 16px;
label {
display: grid;
gap: 4px;
font-size: 12px;
span {
color: #6b7280;
font-weight: 600;
}
input {
max-width: 280px;
padding: 6px 8px;
border: 1px solid #d1d5db;
border-radius: 6px;
font: inherit;
}
}
}
.f121__decl-actions {
display: flex;
gap: 8px;
}
@@ -23,6 +23,8 @@ 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';
import { TdsDeclarationRecord, TdsReportsService } from 'app/tds-reports/tds-reports.service';
import { TdsDeductee } from 'app/tds-reports/tds-reports-data';
/** Domain logic */
import {
@@ -111,6 +113,7 @@ export class Form121TabComponent implements OnInit, OnDestroy {
private savingsService = inject(SavingsService);
private fixedDepositsService = inject(FixedDepositsService);
private recurringDepositsService = inject(RecurringDepositsService);
private tdsService = inject(TdsReportsService);
private destroy$ = new Subject<void>();
@@ -124,23 +127,25 @@ export class Form121TabComponent implements OnInit, OnDestroy {
pan = '';
panWellFormed = false;
// ── Declaration on record ──
declarationOnRecord = false;
// ── Declarations on record ──
/**
* Every declaration the customer has given, each for one year and from the day the branch
* accepted it. The KYC record only ever held a type and a year, which made a September
* declaration shield interest back to April and let last year's form run on.
*/
declarations: TdsDeclarationRecord[] = [];
/**
* 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;
// ── Recording a declaration ──
showDeclarationForm = false;
savingDeclaration = false;
declarationError = '';
newDeclaration = { acceptedOn: '', uin: '', estimatedIncome: null as number | null };
eligibility: EligibilityResult | null = null;
@@ -157,9 +162,9 @@ export class Form121TabComponent implements OnInit, OnDestroy {
configMissing = false;
/** 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>();
private accountInputs: (TdsAccountInput & { meta: Form121AccountRow; taxLines: InterestCredit[] })[] = [];
/** This customer's FD/RD interest received in savings, and TDS taken there, per year. */
private platformByFy = new Map<string, TdsDeductee | null>();
ngOnInit(): void {
this.route.parent.params.pipe(takeUntil(this.destroy$)).subscribe((params: any) => {
@@ -182,13 +187,15 @@ export class Form121TabComponent implements OnInit, OnDestroy {
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)))
accounts: this.clientsService.getClientAccountData(this.clientId).pipe(catchError(() => of(null))),
declarations: this.tdsService.getDeclarations(Number(this.clientId)).pipe(catchError(() => of([])))
})
.pipe(
takeUntil(this.destroy$),
switchMap(({ client, kyc, accounts }) => {
switchMap(({ client, kyc, accounts, declarations }) => {
this.applyClient(client);
this.applyKyc(kyc);
this.declarations = declarations || [];
const deposits = this.depositAccountsOf(accounts);
return deposits.length ? forkJoin(deposits.map((a) => this.fetchAccount(a))) : of([] as any[]);
}),
@@ -212,12 +219,8 @@ export class Form121TabComponent implements OnInit, OnDestroy {
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 && FY_TAX_CONFIG[normaliseFy(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. */
@@ -251,7 +254,7 @@ export class Form121TabComponent implements OnInit, OnDestroy {
*/
private ingest(details: any[]): void {
this.accountInputs = [];
this.actualTdsByFy = new Map<string, number>();
this.platformByFy = new Map<string, TdsDeductee | null>();
const fys = new Set<string>();
const clientIdNum = Number(this.clientId);
@@ -272,6 +275,7 @@ export class Form121TabComponent implements OnInit, OnDestroy {
};
const credits: InterestCredit[] = [];
const taxLines: InterestCredit[] = [];
let tdsDeducted = 0;
for (const t of d.transactions ?? []) {
@@ -289,8 +293,7 @@ export class Form121TabComponent implements OnInit, OnDestroy {
} 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);
taxLines.push({ date: iso, amount, accountNo: ref.accountNo });
}
}
@@ -313,12 +316,13 @@ export class Form121TabComponent implements OnInit, OnDestroy {
deducteeClientId: deductee
};
this.accountInputs.push({ ...ref, credits, meta });
this.accountInputs.push({ ...ref, credits, taxLines, meta });
}
this.availableFys = [...fys].sort().reverse();
if (!this.availableFys.includes(this.fy)) {
this.fy = this.availableFys[0] ?? (this.declarationFy ? normaliseFy(this.declarationFy) : '');
this.fy =
this.availableFys[0] ?? this.declarations[0]?.fy ?? Object.keys(FY_TAX_CONFIG).sort().reverse()[0] ?? '';
}
}
@@ -338,11 +342,33 @@ export class Form121TabComponent implements OnInit, OnDestroy {
const year = normaliseFy(this.fy);
this.configMissing = !FY_TAX_CONFIG[year];
// TDS is tracked on FD/RD interest once it reaches savings, and deducted there. Those figures live
// on the platform, not in the deposit's own transactions, so they are fetched once per year and
// the computation waits for them.
if (!this.platformByFy.has(year)) {
this.loadPlatformYear(year);
return;
}
const platform = this.platformByFy.get(year);
for (const a of this.accountInputs) {
if (!a.meta.tdsApplicable) {
continue;
}
const match = platform?.accounts.find((p) => p.accountNo === a.accountNo);
a.credits = match?.credits ?? [];
a.taxLines = match?.deductions ?? [];
}
// 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);
// The selected year only - this used to total every year the account had ever been taxed in.
a.meta.tdsDeducted = a.meta.tdsApplicable
? a.taxLines.filter((c) => fyOf(c.date) === year).reduce((s, c) => s + c.amount, 0)
: 0;
}
this.actualTds = this.actualTdsByFy.get(year) ?? 0;
// What was actually debited for this customer as deductee, wherever the savings account sits.
this.actualTds = this.accountInputs.filter((a) => a.meta.tdsApplicable).reduce((s, a) => s + a.meta.tdsDeducted, 0);
if (!this.dateOfBirth || this.configMissing) {
return;
@@ -382,21 +408,95 @@ export class Form121TabComponent implements OnInit, OnDestroy {
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) {
private loadPlatformYear(year: string): void {
this.tdsService
.getDepositInterest(Number(year.slice(0, 4)), Number(this.clientId))
.pipe(
catchError(() => of([])),
takeUntil(this.destroy$)
)
.subscribe((rows) => {
this.platformByFy.set(year, rows.find((r) => `${r.clientId}` === `${this.clientId}`) ?? null);
this.recompute();
});
}
/** The live declaration recorded for the year being shown, if any. */
get declarationRecord(): TdsDeclarationRecord | null {
if (!this.fy) {
return null;
}
const year = normaliseFy(this.fy);
if (this.declarationFy && normaliseFy(this.declarationFy) !== year) {
return this.declarations.find((d) => d.fy === year && !d.revoked) ?? null;
}
get declarationOnRecord(): boolean {
return !!this.declarationRecord;
}
/** The declaration as the engine takes it — operative from the day it was accepted. */
private declarationForSelectedFy(): Declaration | null {
const d = this.declarationRecord;
if (!d) {
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
};
const kind = d.formType === '15H' ? '15H' : d.formType === '15G' ? '15G' : formKindFor(this.dateOfBirth, d.fy);
return { kind, acceptedOn: d.acceptedOn, fy: d.fy, uin: d.uin || undefined };
}
openDeclarationForm(): void {
this.newDeclaration = { acceptedOn: new Date().toISOString().slice(0, 10), uin: '', estimatedIncome: null };
this.declarationError = '';
this.showDeclarationForm = true;
}
saveDeclaration(): void {
if (!this.newDeclaration.acceptedOn) {
this.declarationError = 'Enter the date the branch accepted the form.';
return;
}
this.savingDeclaration = true;
this.declarationError = '';
this.tdsService
.createDeclaration({
clientId: Number(this.clientId),
fy: normaliseFy(this.fy),
formType: declarationFormFor(this.fy, this.dateOfBirth),
acceptedOn: this.newDeclaration.acceptedOn,
uin: this.newDeclaration.uin || undefined,
estimatedIncome: this.newDeclaration.estimatedIncome
})
.subscribe({
next: (rows) => {
this.savingDeclaration = false;
this.showDeclarationForm = false;
this.declarations = rows || [];
this.recompute();
},
error: (err: any) => {
this.savingDeclaration = false;
this.declarationError =
err?.error?.errors?.[0]?.defaultUserMessage || err?.error?.defaultUserMessage || 'Could not save.';
}
});
}
revokeDeclaration(): void {
const d = this.declarationRecord;
if (!d) {
return;
}
const reason = prompt('Why is this declaration being withdrawn? From today, TDS will be deducted again.');
if (!reason) {
return;
}
this.tdsService.revokeDeclaration(d.id, reason).subscribe({
next: (rows) => {
this.declarations = rows || [];
this.recompute();
},
error: (err: any) => alert(err?.error?.defaultUserMessage || 'Could not revoke the declaration.')
});
}
// ───────────────────────── Template helpers ─────────────────────────
@@ -474,11 +574,12 @@ export class Form121TabComponent implements OnInit, OnDestroy {
}
/**
* A declaration is on record but deposit accounts are still set to withhold —
* the branch has not switched them off.
* A declaration is on record but a deposit still carries a stock Fineract tax group. Those
* accounts withhold on their own rules and never consult the declaration record, so the form
* does not stop their deduction until the tax group is removed.
*/
get declarationNotAppliedToAccounts(): boolean {
return !!this.declarationForSelectedFy() && this.liableAccounts.some((a) => a.withHoldTax);
return !!this.declarationForSelectedFy() && this.liableAccounts.some((a) => !!a.taxGroup);
}
/**
+4
View File
@@ -36,6 +36,10 @@ export class CacheInterceptor implements HttpInterceptor {
'/rbireserveconfig',
'/tdsconfig',
'/tds/deposit-interest',
// Declarations and challans are entered on the very screens that read them back.
'/tds/declarations',
'/tds/challans',
'/tds/deducted',
'/savingsinterestpostingreports',
// Cash in vaults and drawers changes with every transaction; a cached teller list or position
// kept showing a branch's old tellers and figures at day close.
+275 -369
View File
@@ -503,8 +503,7 @@
[matMenuTriggerFor]="loanOriginationMenu"
*mifosxHasPermission="'MENU_LOAN_ORIGINATION'"
>
<fa-icon icon="clipboard-check" class="ni-ico"></fa-icon><span class="ni-full">Loan Origination</span
><span class="ni-short">Loans</span>
<fa-icon icon="clipboard-check" class="ni-ico"></fa-icon>Loan Origination
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a
@@ -513,8 +512,7 @@
(click)="glMenuOpen = !glMenuOpen; subLedgerOpen = false"
*mifosxHasPermission="'MENU_GENERAL_LEDGER'"
>
<fa-icon icon="book-open" class="ni-ico"></fa-icon><span class="ni-full">General Ledger</span
><span class="ni-short">GL</span>
<fa-icon icon="book-open" class="ni-ico"></fa-icon>General Ledger
<fa-icon [icon]="glMenuOpen ? 'caret-up' : 'caret-down'" class="ni-caret"></fa-icon>
</a>
<a
@@ -523,8 +521,7 @@
(click)="subLedgerOpen = !subLedgerOpen; glMenuOpen = false"
*mifosxHasPermission="'MENU_SUB_LEDGER'"
>
<fa-icon icon="book" class="ni-ico"></fa-icon><span class="ni-full">Sub Ledger</span
><span class="ni-short">Sub GL</span>
<fa-icon icon="book" class="ni-ico"></fa-icon>Sub Ledger
<fa-icon [icon]="subLedgerOpen ? 'caret-up' : 'caret-down'" class="ni-caret"></fa-icon>
</a>
</div>
@@ -586,16 +583,14 @@
[matMenuTriggerFor]="reconciliationMenu"
*mifosxHasPermission="'MENU_CONFIG_WIZARD'"
>
<fa-icon icon="sync-alt" class="ni-ico"></fa-icon><span class="ni-full">Reconciliation</span
><span class="ni-short">Recon</span>
<fa-icon icon="sync-alt" class="ni-ico"></fa-icon>Reconciliation
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
<a class="ni" tabindex="0" routerLink="/chequebook" *mifosxHasPermission="'MENU_CHEQUEBOOK'">
<fa-icon icon="book" class="ni-ico"></fa-icon>Cheque Book
</a>
<a class="ni" tabindex="0" [matMenuTriggerFor]="letterMgmtMenu" *mifosxHasPermission="'MENU_LETTER_MGMT'">
<fa-icon icon="envelope" class="ni-ico"></fa-icon><span class="ni-full">Documents Hub</span
><span class="ni-short">Docs Hub</span>
<fa-icon icon="envelope" class="ni-ico"></fa-icon>Documents Hub
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
</a>
</div>
@@ -622,89 +617,77 @@
</button>
</mat-menu>
<mat-menu #reportsMenu="matMenu" class="reports-mega-panel" [overlapTrigger]="false">
<div class="deposit-menu-grid reports-grid" (click)="$event.stopPropagation()">
<!-- Core Reports -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Core Reports</div>
<button mat-menu-item [routerLink]="['/branch-ops/account-register']" tabindex="0">
<i class="fa fa-book menu-item-icon"></i> Account Register
</button>
<button mat-menu-item [routerLink]="['/reports']" tabindex="0">
<i class="fa fa-th-list menu-item-icon"></i> All Reports
</button>
<button mat-menu-item [routerLink]="['/reports', 'Client']" tabindex="0">
<i class="fa fa-user menu-item-icon"></i> Clients
</button>
<button mat-menu-item [routerLink]="['/reports', 'Loan']" tabindex="0">
<i class="fa fa-credit-card menu-item-icon"></i> Loans
</button>
<button mat-menu-item [routerLink]="['/reports', 'Savings']" tabindex="0">
<i class="fa fa-piggy-bank menu-item-icon"></i> Savings
</button>
<button mat-menu-item [routerLink]="['/reports', 'Fund']" tabindex="0">
<i class="fa fa-hand-holding-usd menu-item-icon"></i> Funds
</button>
<button mat-menu-item [routerLink]="['/reports', 'Accounting']" tabindex="0">
<i class="fa fa-calculator menu-item-icon"></i> Accounting
</button>
</div>
<!-- Operations & Interest -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Operations &amp; Interest</div>
<button mat-menu-item routerLink="/branch-ops/day-book" tabindex="0">
<i class="fa fa-book menu-item-icon"></i> Day Book &amp; Officer Verification
</button>
<button mat-menu-item routerLink="/branch-ops/end-of-day" tabindex="0">
<i class="fa fa-clipboard-check menu-item-icon"></i> End of the Day Report
</button>
<button mat-menu-item routerLink="/reports/savings-interest" tabindex="0">
<i class="fa fa-percentage menu-item-icon"></i> Savings Interest Posted
</button>
<button mat-menu-item routerLink="/reports/interest-posting" tabindex="0">
<i class="fa fa-clipboard-check menu-item-icon"></i> Interest Posting Reports
</button>
<button mat-menu-item routerLink="/reports/fd-interest-posting" tabindex="0">
<i class="fa fa-clipboard-check menu-item-icon"></i> FD Interest Posting Reports
</button>
<button mat-menu-item routerLink="/reports/interest-posting-summary" tabindex="0">
<i class="fa fa-table menu-item-icon"></i> Interest Posting Summary (Branch-wise)
</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>
</div>
<!-- Compliance & Registers -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Compliance &amp; Registers</div>
<button mat-menu-item routerLink="/reports/form-97-98" tabindex="0">
<i class="fa fa-file-invoice menu-item-icon"></i> Form 97/98 Transaction Report
</button>
<button mat-menu-item routerLink="/reports/risk-review" tabindex="0">
<i class="fa fa-user-shield menu-item-icon"></i> Risk Review Report
</button>
<button mat-menu-item routerLink="/reports/kyc-expiry" tabindex="0">
<i class="fa fa-id-card menu-item-icon"></i> Re-KYC Expiry &amp; Outreach
</button>
<button mat-menu-item routerLink="/reports/dormancy" tabindex="0">
<i class="fa fa-hourglass-half menu-item-icon"></i> Inactive, Dormant &amp; DEAF
</button>
<button mat-menu-item routerLink="/reports/sanctions-logs" tabindex="0">
<i class="fa fa-shield-alt menu-item-icon"></i> Sanctions Screening &amp; Alerts
</button>
<button mat-menu-item routerLink="/reports/dip-lookups" tabindex="0">
<i class="fa fa-satellite-dish menu-item-icon"></i> DIP / MNRL Mobile Lookups
</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>
<button mat-menu-item routerLink="/reports/bor" tabindex="0">
<i class="fa fa-gavel menu-item-icon"></i> BOR Register &mdash; Share Capital
</button>
</div>
</div>
<mat-menu #reportsMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
<button mat-menu-item [routerLink]="['/branch-ops/account-register']" tabindex="0">
<i class="fa fa-book menu-item-icon"></i> Account Register
</button>
<button mat-menu-item [routerLink]="['/reports']" tabindex="0">
<i class="fa fa-th-list menu-item-icon"></i> All Reports
</button>
<button mat-menu-item [routerLink]="['/reports', 'Client']" tabindex="0">
<i class="fa fa-user menu-item-icon"></i> Clients
</button>
<button mat-menu-item [routerLink]="['/reports', 'Loan']" tabindex="0">
<i class="fa fa-credit-card menu-item-icon"></i> Loans
</button>
<button mat-menu-item [routerLink]="['/reports', 'Savings']" tabindex="0">
<i class="fa fa-piggy-bank menu-item-icon"></i> Savings
</button>
<button mat-menu-item [routerLink]="['/reports', 'Fund']" tabindex="0">
<i class="fa fa-hand-holding-usd menu-item-icon"></i> Funds
</button>
<button mat-menu-item [routerLink]="['/reports', 'Accounting']" tabindex="0">
<i class="fa fa-calculator menu-item-icon"></i> Accounting
</button>
<mat-divider></mat-divider>
<!-- Listed here as well as under Branch Ops: an officer looking for the day's figures reaches
for Reports, and the same screen answers both. -->
<button mat-menu-item routerLink="/branch-ops/day-book" tabindex="0">
<i class="fa fa-book menu-item-icon"></i> Day Book &amp; Officer Verification
</button>
<button mat-menu-item routerLink="/branch-ops/end-of-day" tabindex="0">
<i class="fa fa-clipboard-check menu-item-icon"></i> End of the Day Report
</button>
<button mat-menu-item routerLink="/reports/form-97-98" tabindex="0">
<i class="fa fa-file-invoice menu-item-icon"></i> Form 97/98 Transaction Report
</button>
<button mat-menu-item routerLink="/reports/risk-review" tabindex="0">
<i class="fa fa-user-shield menu-item-icon"></i> Risk Review Report
</button>
<button mat-menu-item routerLink="/reports/kyc-expiry" tabindex="0">
<i class="fa fa-id-card menu-item-icon"></i> Re-KYC Expiry &amp; Outreach
</button>
<button mat-menu-item routerLink="/reports/dormancy" tabindex="0">
<i class="fa fa-hourglass-half menu-item-icon"></i> Inactive, Dormant &amp; DEAF
</button>
<button mat-menu-item routerLink="/reports/savings-interest" tabindex="0">
<i class="fa fa-percentage menu-item-icon"></i> Savings Interest Posted
</button>
<button mat-menu-item routerLink="/reports/interest-posting" tabindex="0">
<i class="fa fa-clipboard-check menu-item-icon"></i> Interest Posting Reports
</button>
<button mat-menu-item routerLink="/reports/interest-posting-summary" tabindex="0">
<i class="fa fa-table menu-item-icon"></i> Interest Posting Summary (Branch-wise)
</button>
<!--
The Principal Officer clears flagged UNSC screenings here. It is not a runnable report,
so it never shows up under the report categories above and had no nav entry at all.
-->
<button mat-menu-item routerLink="/reports/sanctions-logs" tabindex="0">
<i class="fa fa-shield-alt menu-item-icon"></i> Sanctions Screening &amp; Alerts
</button>
<button mat-menu-item routerLink="/reports/dip-lookups" tabindex="0">
<i class="fa fa-satellite-dish menu-item-icon"></i> DIP / MNRL Mobile Lookups
</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>
<button mat-menu-item routerLink="/reports/bor" tabindex="0">
<i class="fa fa-gavel menu-item-icon"></i> BOR Register &mdash; Share Capital
</button>
</mat-menu>
<!-- Admin Mega Menu — 3-column rectangular panel grouping Setup,
@@ -1077,146 +1060,121 @@
<!-- Sub Menu Dropdowns -->
<!-- Branch-level operations. Starts with the day register; other branch-wide controls belong
here rather than under Cashier, which is one role's workspace. -->
<mat-menu #branchOpsMenu="matMenu" class="branch-ops-mega-panel" [overlapTrigger]="false">
<div class="deposit-menu-grid branch-ops-grid" (click)="$event.stopPropagation()">
<div class="deposit-menu-group">
<div class="deposit-group-title">Day Operations</div>
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/day-register']">
<fa-icon icon="calendar-check" class="menu-item-icon"></fa-icon> Day Open / Day Close
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/missed-close']">
<fa-icon icon="exclamation-triangle" class="menu-item-icon"></fa-icon> Missed Close Register
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/vault-position']">
<fa-icon icon="lock" class="menu-item-icon"></fa-icon> Vault Position
</button>
</div>
<div class="deposit-menu-group">
<div class="deposit-group-title">Verification &amp; Reports</div>
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/day-book']">
<fa-icon icon="book" class="menu-item-icon"></fa-icon> Day Book &amp; Verification
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/end-of-day']">
<fa-icon icon="clipboard-check" class="menu-item-icon"></fa-icon> End of the Day Report
</button>
</div>
</div>
<mat-menu #branchOpsMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/day-register']">
<fa-icon icon="calendar-check" class="menu-item-icon"></fa-icon> Day Open / Day Close
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/missed-close']">
<fa-icon icon="exclamation-triangle" class="menu-item-icon"></fa-icon> Missed Close Register
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/vault-position']">
<fa-icon icon="lock" class="menu-item-icon"></fa-icon> Vault Position
</button>
<!-- The day book carries the Officer Verification Sheet — each person's credit, debit and net
for the day. It was reachable only from the Clerk and Cashier menus, which is the one
place a principal officer signing the day off does not look. -->
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/day-book']">
<fa-icon icon="book" class="menu-item-icon"></fa-icon> Day Book &amp; Verification
</button>
<!-- The signed sheet, as against the day book's working list: the day's modes, the drawer
against the books, and what is still open. This is what goes in the file. -->
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/end-of-day']">
<fa-icon icon="clipboard-check" class="menu-item-icon"></fa-icon> End of the Day Report
</button>
</mat-menu>
<!-- Clerk Desk -->
<mat-menu #clerkMenu="matMenu" class="clerk-mega-panel" [overlapTrigger]="false">
<div class="deposit-menu-grid clerk-grid" (click)="$event.stopPropagation()">
<!-- Clerk Desk -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Clerk Desk</div>
<button mat-menu-item tabindex="0" routerLink="/clerk/dashboard" (click)="hideShellLayout = true">
<fa-icon icon="id-card" class="menu-item-icon"></fa-icon> Clerk Dashboard
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/withdrawal-requests']">
<fa-icon icon="money-bill-wave" class="menu-item-icon"></fa-icon> My Withdrawal Requests
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/my-disbursement-requests']">
<fa-icon icon="exchange" class="menu-item-icon"></fa-icon> Disbursements &amp; Transfer
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/dividend-dashboard']">
<fa-icon icon="chart-pie" class="menu-item-icon"></fa-icon> Dividend Dashboard
</button>
</div>
<!-- Vouchers -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Vouchers</div>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/my-work']">
<fa-icon icon="user-check" class="menu-item-icon"></fa-icon> My Vouchers
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/system-vouchers']">
<fa-icon icon="robot" class="menu-item-icon"></fa-icon> System Vouchers
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/received-report']">
<fa-icon icon="receipt" class="menu-item-icon"></fa-icon> Received Vouchers
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/payment-report']">
<fa-icon icon="receipt" class="menu-item-icon"></fa-icon> Payment Vouchers
</button>
</div>
<!-- Counter & Reports -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Counter &amp; Reports</div>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cashier-position']">
<fa-icon icon="wallet" class="menu-item-icon"></fa-icon> Cashier Position
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/closing-report']">
<fa-icon icon="file-invoice" class="menu-item-icon"></fa-icon> Closing Cash Report
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/end-of-day']">
<fa-icon icon="clipboard-check" class="menu-item-icon"></fa-icon> End of the Day Report
</button>
</div>
</div>
<!-- Clerk was a bare link straight into the fullscreen dashboard, which left the clerk's other
screens reachable only from inside that fullscreen nav. Anything a clerk needs from the
normal shell — an open voucher exception above all — belongs here. -->
<mat-menu #clerkMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
<button mat-menu-item tabindex="0" routerLink="/clerk/dashboard" (click)="hideShellLayout = true">
<fa-icon icon="id-card" class="menu-item-icon"></fa-icon> Clerk Dashboard
</button>
<!-- The clerk reads the day as a sheet to sign off, not as a working list of vouchers: the
day's summary, the counter counted against the books, and what is still open. The day
book's own row-by-row view stays reachable from Branch Ops and from the Cashier menu. -->
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/end-of-day']">
<fa-icon icon="clipboard-check" class="menu-item-icon"></fa-icon> End of the Day Report
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/withdrawal-requests']">
<fa-icon icon="money-bill-wave" class="menu-item-icon"></fa-icon> My Withdrawal Requests
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/my-disbursement-requests']">
<fa-icon icon="exchange" class="menu-item-icon"></fa-icon> Disbursements &amp; Transfer
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/dividend-dashboard']">
<fa-icon icon="chart-pie" class="menu-item-icon"></fa-icon> Dividend Dashboard
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/my-work']">
<fa-icon icon="user-check" class="menu-item-icon"></fa-icon> My Vouchers
</button>
<!-- Sits under My Vouchers because it answers the same question for the one worker who has no
own list: the system. A nightly run belongs to nobody, so it appears on no person's page. -->
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/system-vouchers']">
<fa-icon icon="robot" class="menu-item-icon"></fa-icon> System Vouchers
</button>
<!-- A clerk supervises the counter, so they need to see what each cashier is holding and what
went through their drawer. Both screens already return every cashier for a non-cashier
caller (CashierRoleHelper scopes only a cashier to themselves) — they were simply not
reachable from this menu. -->
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cashier-position']">
<fa-icon icon="wallet" class="menu-item-icon"></fa-icon> Cashier Position
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/closing-report']">
<fa-icon icon="file-invoice" class="menu-item-icon"></fa-icon> Closing Cash Report
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/received-report']">
<fa-icon icon="receipt" class="menu-item-icon"></fa-icon> Received Vouchers
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/payment-report']">
<fa-icon icon="receipt" class="menu-item-icon"></fa-icon> Payment Vouchers
</button>
</mat-menu>
<!-- Cashier Desk -->
<mat-menu #cashierMenu="matMenu" class="cashier-mega-panel" [overlapTrigger]="false">
<div class="deposit-menu-grid cashier-grid" (click)="$event.stopPropagation()">
<!-- Cash Entries & Exchange -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Cash &amp; Counter</div>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-exchange']">
<fa-icon icon="exchange" class="menu-item-icon"></fa-icon> Cash Exchange Entry
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/exchange-detail']">
<fa-icon icon="globe" class="menu-item-icon"></fa-icon> Currency Exchange Detail
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/transaction-entry']">
<fa-icon icon="keyboard" class="menu-item-icon"></fa-icon> Transaction Entry
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/system/denomination-master']">
<fa-icon icon="coins" class="menu-item-icon"></fa-icon> Cash Denomination
</button>
</div>
<!-- Transfers & Settlement -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Transfers &amp; Settlement</div>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-transfer']">
<fa-icon icon="random" class="menu-item-icon"></fa-icon> Cash Transfer
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/inter-cashier']">
<fa-icon icon="exchange-alt" class="menu-item-icon"></fa-icon> Inter-Cashier Transfer
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-settlement']">
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Cash Settlement
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cashier-position']">
<fa-icon icon="wallet" class="menu-item-icon"></fa-icon> Cashier Position
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/day-book']">
<fa-icon icon="book" class="menu-item-icon"></fa-icon> Day Book
</button>
</div>
<!-- Reports & Audit -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Reports &amp; Audit</div>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/closing-report']">
<fa-icon icon="file-alt" class="menu-item-icon"></fa-icon> Closing Cash Report
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/excess-report']">
<fa-icon icon="exclamation-circle" class="menu-item-icon"></fa-icon> Excess Transaction Report
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/reconciliation']">
<fa-icon icon="balance-scale" class="menu-item-icon"></fa-icon> Reconciliation
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-history']">
<fa-icon icon="history" class="menu-item-icon"></fa-icon> Cash History
</button>
</div>
</div>
<mat-menu #cashierMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-exchange']">
<fa-icon icon="exchange" class="menu-item-icon"></fa-icon> Cash Exchange Entry
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/system/denomination-master']">
<fa-icon icon="coins" class="menu-item-icon"></fa-icon> Cash Denomination
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/closing-report']">
<fa-icon icon="file-alt" class="menu-item-icon"></fa-icon> Closing Cash Report
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/exchange-detail']">
<fa-icon icon="globe" class="menu-item-icon"></fa-icon> Currency Exchange Detail
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-transfer']">
<fa-icon icon="random" class="menu-item-icon"></fa-icon> Cash Transfer
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/excess-report']">
<fa-icon icon="exclamation-circle" class="menu-item-icon"></fa-icon> Excess Transaction Report
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-settlement']">
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Cash Settlement
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/inter-cashier']">
<fa-icon icon="exchange-alt" class="menu-item-icon"></fa-icon> Inter-Cashier Transfer
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cashier-position']">
<fa-icon icon="wallet" class="menu-item-icon"></fa-icon> Cashier Position
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/reconciliation']">
<fa-icon icon="balance-scale" class="menu-item-icon"></fa-icon> Reconciliation
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-history']">
<fa-icon icon="history" class="menu-item-icon"></fa-icon> Cash History
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/day-book']">
<fa-icon icon="book" class="menu-item-icon"></fa-icon> Day Book
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/transaction-entry']">
<fa-icon icon="keyboard" class="menu-item-icon"></fa-icon> Transaction Entry
</button>
</mat-menu>
<mat-menu #depositsMenu="matMenu" class="deposits-mega-panel" [overlapTrigger]="false">
<div class="deposit-menu-grid deposits-grid" (click)="$event.stopPropagation()">
<div class="deposit-menu-grid" (click)="$event.stopPropagation()">
<div class="deposit-menu-group">
<div class="deposit-group-title">Accounts</div>
<button mat-menu-item tabindex="0" [routerLink]="['/accounts/savings']">
@@ -1278,54 +1236,34 @@
</div>
</mat-menu>
<mat-menu #clearingMenu="matMenu" class="clearing-mega-panel" [overlapTrigger]="false">
<div class="deposit-menu-grid clearing-grid" (click)="$event.stopPropagation()">
<!-- CTS Clearing -->
<div class="deposit-menu-group">
<div class="deposit-group-title">CTS Clearing</div>
<button mat-menu-item tabindex="0" [routerLink]="['/ctsclearing/inward']">
<fa-icon icon="arrow-left" class="menu-item-icon"></fa-icon> Inward Clearing (CTS)
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/ctsclearing/outward']">
<fa-icon icon="arrow-right" class="menu-item-icon"></fa-icon> Outward Clearing (CTS)
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/ctsclearing/positive-pay']">
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Positive Pay (PPS)
</button>
</div>
<!-- Settlement & Recon -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Settlement &amp; Recon</div>
<button mat-menu-item tabindex="0" [routerLink]="['/ctsclearing/settlement']">
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Settlement
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/ctsclearing/reconciliation']">
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Reconciliation
</button>
<button mat-menu-item [routerLink]="['/sponsor-dd/inventory']" tabindex="0">
<fa-icon icon="receipt" class="menu-item-icon"></fa-icon> Sponsor DD Inventory
</button>
</div>
<!-- NACH & Other -->
<div class="deposit-menu-group">
<div class="deposit-group-title">NACH &amp; Direct</div>
<button mat-menu-item tabindex="0" [routerLink]="['/nachclearing']">
<fa-icon icon="money-check" class="menu-item-icon"></fa-icon> NACH Clearing
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="arrow-right" class="menu-item-icon"></fa-icon> Outward
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="arrow-left" class="menu-item-icon"></fa-icon> Inward
</button>
</div>
</div>
<mat-menu #clearingMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
<button mat-menu-item tabindex="0" [routerLink]="['/ctsclearing/inward']">
<fa-icon icon="arrow-left" class="menu-item-icon"></fa-icon> Inward Clearing (CTS)
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/ctsclearing/outward']">
<fa-icon icon="arrow-right" class="menu-item-icon"></fa-icon> Outward Clearing (CTS)
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/ctsclearing/positive-pay']">
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Positive Pay (PPS)
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/ctsclearing/settlement']">
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Settlement
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/ctsclearing/reconciliation']">
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Reconciliation
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/nachclearing']">
<fa-icon icon="money-check" class="menu-item-icon"></fa-icon> NACH Clearing
</button>
<button mat-menu-item tabindex="0"><fa-icon icon="arrow-right" class="menu-item-icon"></fa-icon> Outward</button>
<button mat-menu-item tabindex="0"><fa-icon icon="arrow-left" class="menu-item-icon"></fa-icon> Inward</button>
<button mat-menu-item [routerLink]="['/sponsor-dd/inventory']" tabindex="0">
<fa-icon icon="receipt" class="menu-item-icon"></fa-icon> Sponsor DD Inventory
</button>
</mat-menu>
<mat-menu #hrmMenu="matMenu" class="hrm-mega-panel" [overlapTrigger]="false">
<div class="deposit-menu-grid hrm-grid" (click)="$event.stopPropagation()">
<div class="deposit-menu-grid" (click)="$event.stopPropagation()">
<!-- Payroll -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Payroll</div>
@@ -1632,33 +1570,23 @@
</div>
</mat-menu>
<mat-menu #ckycMenu="matMenu" class="ckyc-mega-panel" [overlapTrigger]="false">
<div class="deposit-menu-grid ckyc-grid" (click)="$event.stopPropagation()">
<!-- KYC Operations -->
<div class="deposit-menu-group">
<div class="deposit-group-title">KYC Operations</div>
<button mat-menu-item routerLink="/ckyc" tabindex="0">
<fa-icon icon="id-card" class="menu-item-icon"></fa-icon> CKYC Management
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="database" class="menu-item-icon" style="color: #be185d"></fa-icon> Dump KYC &amp; Loan Data
</button>
</div>
<!-- Reports & Screening -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Reports &amp; Screening</div>
<button mat-menu-item tabindex="0" (click)="openCkycReportsDialog('REKYC')">
<fa-icon icon="sync-alt" class="menu-item-icon" style="color: #be185d"></fa-icon> Re-KYC Report
</button>
<button mat-menu-item tabindex="0" (click)="openCkycReportsDialog('RISK-REVIEW')">
<fa-icon icon="history" class="menu-item-icon" style="color: #be185d"></fa-icon> Risk Review
</button>
<button mat-menu-item tabindex="0" (click)="openCkycReportsDialog('CERSAI')">
<fa-icon icon="shield-alt" class="menu-item-icon" style="color: #be185d"></fa-icon> CERSAI Report
</button>
</div>
</div>
<mat-menu #ckycMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
<button mat-menu-item routerLink="/ckyc" tabindex="0">
<fa-icon icon="id-card" class="menu-item-icon"></fa-icon> CKYC Management
</button>
<button mat-menu-item tabindex="0" (click)="openCkycReportsDialog('REKYC')">
<fa-icon icon="sync-alt" class="menu-item-icon" style="color: #be185d"></fa-icon> Re-KYC Report
</button>
<button mat-menu-item tabindex="0" (click)="openCkycReportsDialog('RISK-REVIEW')">
<fa-icon icon="history" class="menu-item-icon" style="color: #be185d"></fa-icon> Risk Review
</button>
<button mat-menu-item tabindex="0" (click)="openCkycReportsDialog('CERSAI')">
<fa-icon icon="shield-alt" class="menu-item-icon" style="color: #be185d"></fa-icon> CERSAI Report
</button>
<mat-divider></mat-divider>
<button mat-menu-item tabindex="0">
<fa-icon icon="database" class="menu-item-icon" style="color: #be185d"></fa-icon> Dump KYC &amp; Loan Data
</button>
</mat-menu>
<!-- CFCFRMS — single landing page (CfcfrmsShell) hosts the full
@@ -1676,11 +1604,11 @@
</button>
<button mat-menu-item tabindex="0" routerLink="/reconciliation/sundry-dr-cr">
<fa-icon icon="random" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Sundry Dr / Cr
Sundry Dr / Cr Reconciliation
</button>
<button mat-menu-item tabindex="0" routerLink="/reconciliation/pnl-impact">
<fa-icon icon="chart-line" class="menu-item-icon" style="color: #0d9488"></fa-icon>
P&amp;L Impact
P&amp;L Impact Reconciliation
</button>
<button mat-menu-item tabindex="0" routerLink="/reconciliation/gl-reconciliation">
<fa-icon icon="book-open" class="menu-item-icon" style="color: #0d9488"></fa-icon>
@@ -1688,11 +1616,11 @@
</button>
<button mat-menu-item tabindex="0" routerLink="/reconciliation/fixed-assets">
<fa-icon icon="cube" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Fixed Assets
Fixed Assets Reconciliation
</button>
<button mat-menu-item tabindex="0" routerLink="/reconciliation/inter-company">
<fa-icon icon="sitemap" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Inter-Company
Inter-Company Reconciliation
</button>
</div>
@@ -1705,15 +1633,15 @@
</button>
<button mat-menu-item tabindex="0" routerLink="/reconciliation/api-bank-reco">
<fa-icon icon="code-branch" class="menu-item-icon" style="color: #0d9488"></fa-icon>
API Bank Reco
API Based Bank Reconciliation
</button>
<button mat-menu-item tabindex="0" routerLink="/reconciliation/neft-rtgs">
<fa-icon icon="exchange" class="menu-item-icon" style="color: #0d9488"></fa-icon>
NEFT / RTGS Reco
NEFT / RTGS Reconciliation
</button>
<button mat-menu-item tabindex="0" routerLink="/reconciliation/clearing-reco">
<fa-icon icon="hand-holding-usd" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Clearing Reco
Clearing Reconciliation
</button>
</div>
@@ -1726,11 +1654,11 @@
</button>
<button mat-menu-item tabindex="0" routerLink="/reconciliation/three-way-digital">
<fa-icon icon="project-diagram" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Three-way Digital
Three-way Digital Reconciliation
</button>
<button mat-menu-item tabindex="0" routerLink="/reconciliation/nach-reco">
<fa-icon icon="sync-alt" class="menu-item-icon" style="color: #0d9488"></fa-icon>
NACH Reco
NACH Reconciliation
</button>
</div>
@@ -1739,11 +1667,11 @@
<div class="deposit-group-title">Instruments &amp; Members</div>
<button mat-menu-item [routerLink]="['/sponsor-dd-reconciliation']" tabindex="0">
<fa-icon icon="money-check-alt" class="menu-item-icon" style="color: #0d9488"></fa-icon>
DD / Pay Order
DD / Pay Order Reconciliation
</button>
<button mat-menu-item tabindex="0" routerLink="/reconciliation/share-member">
<fa-icon icon="coins" class="menu-item-icon" style="color: #0d9488"></fa-icon>
Share Member
Share Member Reconciliation
</button>
</div>
@@ -1919,68 +1847,46 @@
</div>
</mat-menu>
<mat-menu #insuranceMenu="matMenu" class="insurance-mega-panel" [overlapTrigger]="false">
<div class="deposit-menu-grid insurance-grid" (click)="$event.stopPropagation()">
<!-- Social & Policy Schemes -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Schemes &amp; Social</div>
<button mat-menu-item routerLink="/dicgc" tabindex="0">
<fa-icon icon="building" class="menu-item-icon" style="color: #c2410c"></fa-icon> DICGC
</button>
<button mat-menu-item tabindex="0"><fa-icon icon="heart" class="menu-item-icon"></fa-icon> PMJBY</button>
<button mat-menu-item tabindex="0"><fa-icon icon="heart" class="menu-item-icon"></fa-icon> PMSBY</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="hand-holding-heart" class="menu-item-icon"></fa-icon> Indemnity
</button>
</div>
<!-- Institutional & Loans -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Institutional &amp; Loans</div>
<button mat-menu-item tabindex="0">
<fa-icon icon="file-invoice" class="menu-item-icon"></fa-icon> Loan Insurance
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="home" class="menu-item-icon"></fa-icon> Assets Insurance
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="laptop" class="menu-item-icon"></fa-icon> Cyber Insurance
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="university" class="menu-item-icon"></fa-icon> Bank Insurance
</button>
</div>
</div>
<mat-menu #insuranceMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
<button mat-menu-item routerLink="/dicgc" tabindex="0">
<fa-icon icon="building" class="menu-item-icon" style="color: #c2410c"></fa-icon> DICGC
</button>
<button mat-menu-item tabindex="0"><fa-icon icon="heart" class="menu-item-icon"></fa-icon> PMJBY</button>
<button mat-menu-item tabindex="0"><fa-icon icon="heart" class="menu-item-icon"></fa-icon> PMSBY</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="hand-holding-heart" class="menu-item-icon"></fa-icon> Indemnity
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="home" class="menu-item-icon"></fa-icon> Assets Insurance
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="laptop" class="menu-item-icon"></fa-icon> Cyber Insurance
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="university" class="menu-item-icon"></fa-icon> Bank Insurance
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="file-invoice" class="menu-item-icon"></fa-icon> Loan Insurance
</button>
</mat-menu>
<mat-menu #digitalMenu="matMenu" class="digital-mega-panel" [overlapTrigger]="false">
<div class="deposit-menu-grid digital-grid" (click)="$event.stopPropagation()">
<!-- Payment Systems -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Payments</div>
<button mat-menu-item routerLink="/digital/neft-rtgs-report" tabindex="0">
<fa-icon icon="exchange" class="menu-item-icon"></fa-icon> NEFT / RTGS
</button>
<button mat-menu-item routerLink="/digital/imps-report" tabindex="0">
<fa-icon icon="bolt" class="menu-item-icon"></fa-icon> IMPS
</button>
<button mat-menu-item routerLink="/digital/upi-report" tabindex="0">
<fa-icon icon="mobile-alt" class="menu-item-icon"></fa-icon> UPI Reports
</button>
</div>
<!-- Cards & Mandates -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Cards &amp; Mandates</div>
<button mat-menu-item routerLink="/digital/atm-pos-ecom" tabindex="0">
<fa-icon icon="credit-card" class="menu-item-icon"></fa-icon> ATM / POS / ECOM Reports
</button>
<button mat-menu-item tabindex="0"><fa-icon icon="sync" class="menu-item-icon"></fa-icon> NACH</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="file-signature" class="menu-item-icon"></fa-icon> Mandate
</button>
</div>
</div>
<mat-menu #digitalMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
<button mat-menu-item routerLink="/digital/neft-rtgs-report" tabindex="0">
<fa-icon icon="exchange" class="menu-item-icon"></fa-icon> NEFT / RTGS
</button>
<button mat-menu-item routerLink="/digital/imps-report" tabindex="0">
<fa-icon icon="bolt" class="menu-item-icon"></fa-icon> IMPS
</button>
<button mat-menu-item routerLink="/digital/upi-report" tabindex="0">
<fa-icon icon="mobile-alt" class="menu-item-icon"></fa-icon> UPI Reports
</button>
<button mat-menu-item routerLink="/digital/atm-pos-ecom" tabindex="0">
<fa-icon icon="credit-card" class="menu-item-icon"></fa-icon> ATM / POS / ECOM Reports
</button>
<button mat-menu-item tabindex="0"><fa-icon icon="sync" class="menu-item-icon"></fa-icon> NACH</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="file-signature" class="menu-item-icon"></fa-icon> Mandate
</button>
</mat-menu>
<!-- RBI Mega Menu -->
+123 -196
View File
@@ -266,7 +266,7 @@ $color: #e2e4ec;
max-width: 100%;
overflow-x: hidden;
box-sizing: border-box;
padding: 10px 18px;
padding: 16px 28px;
}
}
@@ -282,15 +282,21 @@ $gray-700: #334155;
/* ============================================================
Main header (top 2-row navigation)
--------------------------------------------------------------
Compact, modern enterprise SaaS navigation:
• Height reduced to 32px per row (64px total) to save screen space.
• Smooth horizontal scrolling prevents menu items from getting cut off.
• Compact typography, icons and paddings fit all banking modules cleanly.
Redesigned 2026 — clean, modern enterprise SaaS feel:
• Both rows live on the same white surface separated only by a
hairline; r2 has a barely-there blue-50 wash so frequent vs
secondary menus read at a glance without dark/light contrast.
• Items are now equal-width "tabs" with NO vertical dividers
between them (the old cell borders made it look spreadsheet-y).
• Hover / active = a 3 px brand-coloured TOP bar + brand text +
coloured icon. Reads instantly without colour-flooding the row.
• Icons larger (12.5 px) and full-opacity always — easier to scan
during demos. Caret is subtle and rotates on open.
============================================================ */
.main-header {
background: #fff;
border-bottom: 2px solid $brand;
box-shadow: 0 1px 3px rgb(0 0 0 / 6%);
border-bottom: 2.5px solid $brand;
box-shadow: 0 1px 4px rgb(0 0 0 / 7%);
position: sticky;
/* Sit directly under the sticky toolbar (32px tall) so both stay visible. */
@@ -300,19 +306,19 @@ $gray-700: #334155;
}
.nav-row {
height: 32px;
height: 44px;
display: flex;
align-items: stretch;
overflow: auto visible;
overflow: visible;
position: relative;
/* Outer frame; vertical dividers between cells are drawn as `border-right`
on each `.ni` below. Switched away from the equal-column grid so each
cell can size to its own label — long names (General Ledger, Documents
Hub) no longer overflow and short ones (Approvals, HRM) no longer waste
space. */
border: 1px solid $gray-200;
background: #fff;
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
}
.r2.nav-row {
@@ -328,17 +334,25 @@ $gray-700: #334155;
background: #f8fafc;
}
// ===== Nav Item — compact modern tab style =====
// ===== Nav Item — modern tab style =====
// Typography spec — same family / size / weight / color for BOTH rows.
.ni {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 2.5px;
gap: 7px;
height: 100%;
/* Each cell sizes to its own label (min-width: max-content) and grows
to share any leftover row width. Short labels stay compact, long ones
get the room they need — no overlap, no wasted gap. */
flex: 1 0 auto;
min-width: 0;
padding: 0 4px;
min-width: max-content;
padding: 0 14px;
/* Vertical divider between adjacent cells; outer frame is the row's
own border, and the last cell drops its right border below. */
border-right: 1px solid $gray-200;
cursor: pointer;
white-space: nowrap;
@@ -346,11 +360,11 @@ $gray-700: #334155;
'IBM Plex Sans',
-apple-system,
sans-serif;
font-size: 10px;
font-size: 12px;
font-weight: 500;
color: #0a1a2e;
line-height: 1.2;
letter-spacing: -0.015em;
line-height: 1.3;
letter-spacing: 0.01em;
text-decoration: none;
user-select: none;
transition:
@@ -359,15 +373,17 @@ $gray-700: #334155;
box-shadow 0.15s ease;
border-radius: 0;
/* 3px accent strip that fills on hover / active (uses ::before so we
don't reflow the row when toggling). */
&::before {
content: '';
position: absolute;
left: 2px;
right: 2px;
left: 4px;
right: 4px;
top: 0;
height: 2px;
height: 3px;
background: transparent;
border-radius: 0 0 2px 2px;
border-radius: 0 0 3px 3px;
transition: background 0.15s ease;
}
@@ -394,19 +410,17 @@ $gray-700: #334155;
outline-offset: -2px;
}
/* Last cell in a row sits flush against the row's own right edge, so
drop its divider to avoid a double 2 px line. */
&:last-child {
border-right: none;
}
.ni-short {
display: none;
}
}
.ni-ico {
width: 9.5px;
height: 9.5px;
font-size: 9.5px;
width: 12px;
height: 12px;
font-size: 12px;
color: $gray-500;
flex-shrink: 0;
transition: color 0.15s ease;
@@ -418,12 +432,11 @@ $gray-700: #334155;
}
.ni-caret {
width: 5px;
height: 5px;
font-size: 5px;
width: 7px;
height: 7px;
font-size: 7px;
color: $gray-400;
flex-shrink: 0;
margin-left: 0.5px;
transition:
transform 0.18s ease,
color 0.18s ease;
@@ -434,89 +447,17 @@ $gray-700: #334155;
}
}
@media (width >= 1400px) {
.ni {
padding: 0 6px;
gap: 3.5px;
font-size: 10.8px;
letter-spacing: 0;
}
.ni-ico {
width: 10px;
height: 10px;
font-size: 10px;
}
.ni-caret {
width: 5.5px;
height: 5.5px;
font-size: 5.5px;
margin-left: 1px;
}
}
@media (width <= 1200px) {
.ni {
padding: 0 3px;
gap: 2px;
font-size: 9.8px;
letter-spacing: -0.02em;
}
.ni-ico {
width: 9px;
height: 9px;
font-size: 9px;
}
.ni-caret {
width: 4.5px;
height: 4.5px;
font-size: 4.5px;
padding: 0 8px;
gap: 5px;
}
}
@media (width <= 1080px) {
@media (width <= 980px) {
.ni {
padding: 0 2.5px;
gap: 1.5px;
font-size: 9.4px;
letter-spacing: -0.025em;
.ni-full {
display: none;
}
.ni-short {
display: inline;
}
}
.ni-ico {
width: 8.5px;
height: 8.5px;
font-size: 8.5px;
}
.ni-caret {
width: 4px;
height: 4px;
font-size: 4px;
}
}
@media (width <= 880px) {
.nav-row {
overflow-x: auto;
scrollbar-width: thin;
}
.ni {
flex: 0 0 auto;
padding: 0 6px;
gap: 3px;
font-size: 10px;
gap: 4px;
}
}
@@ -758,29 +699,21 @@ $gray-700: #334155;
// ===== Nav Dropdown Menus =====
::ng-deep .nav-dropdown {
border-radius: 6px !important;
border-radius: 8px !important;
border: 1px solid $gray-200 !important;
box-shadow: 0 4px 16px rgb(0 0 0 / 12%) !important;
min-width: 170px !important;
padding: 3px 0 !important;
margin-top: 1px !important;
box-shadow: 0 6px 24px rgb(0 0 0 / 10%) !important;
min-width: 180px !important;
padding: 4px 0 !important;
margin-top: 2px !important;
.mat-mdc-menu-item {
font-size: 10.5px !important;
font-size: 12.5px !important;
font-weight: 500 !important;
height: 25px !important;
min-height: 25px !important;
padding: 0 6px !important;
line-height: 25px !important;
height: 34px !important;
min-height: 34px !important;
padding: 0 14px !important;
color: $gray-700 !important;
font-family: 'IBM Plex Sans', Roboto, sans-serif !important;
white-space: nowrap !important;
.menu-item-icon {
font-size: 10.5px !important;
margin-right: 5px !important;
opacity: 0.8;
}
font-family: 'DM Sans', Roboto, sans-serif !important;
&:hover {
background: $brand-hover !important;
@@ -795,44 +728,39 @@ $gray-700: #334155;
}
/* Deposits Mega Panel (inside mat-menu overlay) */
::ng-deep .deposit-menu-grid {
.deposit-menu-grid {
display: grid;
gap: 4px 12px;
padding: 6px 10px;
min-width: 0;
width: 100%;
box-sizing: border-box;
overflow-x: hidden;
grid-template-columns: repeat(3, 1fr);
gap: 8px 24px;
padding: 8px 12px;
min-width: 600px;
}
::ng-deep .deposit-menu-group {
.deposit-menu-group {
.deposit-group-title {
font-weight: 700;
font-size: 9.5px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
letter-spacing: 0.6px;
color: #1074b9;
padding: 2px 6px 3px;
padding: 4px 16px 6px;
margin-bottom: 2px;
border-bottom: 1.5px solid #1074b9;
white-space: nowrap;
border-bottom: 2px solid #1074b9;
}
}
/* NPA Mega Panel — 3 columns */
::ng-deep .npa-grid {
grid-template-columns: repeat(3, minmax(210px, 1fr));
min-width: 0;
max-width: 100%;
overflow-x: hidden;
/* 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 */
::ng-deep .reconciliation-grid {
grid-template-columns: repeat(5, minmax(170px, 1fr));
min-width: 0;
max-width: 100%;
overflow-x: hidden;
/* Reconciliation Mega Panel — 5 columns, teal accent to match the icon palette */
.reconciliation-grid {
grid-template-columns: repeat(5, minmax(190px, 1fr));
min-width: 1040px;
max-width: 96vw;
.deposit-group-title {
color: #0d9488;
@@ -852,49 +780,47 @@ $gray-700: #334155;
.gl-mega-menu {
position: absolute;
top: 96px;
top: 100px;
left: 50%;
transform: translateX(-50%);
background: #fff;
width: 760px;
max-width: 94vw;
max-height: 72vh;
overflow-y: auto;
padding: 12px 16px;
box-shadow: 0 6px 20px rgb(0 0 0 / 18%);
border-radius: 0 0 8px 8px;
width: 860px;
max-width: 95vw;
padding: 20px 24px;
box-shadow: 0 6px 24px rgb(0 0 0 / 22%);
border-radius: 0 0 10px 10px;
z-index: 1000;
border-top: 2.5px solid #0d47a1;
border-top: 3px solid #0d47a1;
}
.gl-mega-menu-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
padding-bottom: 6px;
gap: 10px;
margin-bottom: 14px;
padding-bottom: 12px;
border-bottom: 1px solid #e5e7eb;
span {
font-size: 14px;
font-size: 18px;
font-weight: 700;
color: #0d47a1;
letter-spacing: 0.01em;
letter-spacing: 0.02em;
}
}
.gl-mega-menu-header-icon {
font-size: 14px;
font-size: 18px;
color: #0d47a1;
}
.gl-search {
width: 100%;
padding: 6px 10px;
margin-bottom: 10px;
padding: 10px 14px;
margin-bottom: 16px;
border: 1px solid #d0d5dd;
border-radius: 5px;
font-size: 12px;
border-radius: 6px;
font-size: 14px;
box-sizing: border-box;
background: #f9fafb;
@@ -902,37 +828,36 @@ $gray-700: #334155;
outline: none;
border-color: #0d47a1;
background: #fff;
box-shadow: 0 0 0 2px rgb(13 71 161 / 10%);
box-shadow: 0 0 0 3px rgb(13 71 161 / 10%);
}
}
.gl-columns {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px 16px;
gap: 24px;
}
.gl-group-title {
font-weight: 700;
font-size: 10px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.6px;
letter-spacing: 0.8px;
color: #0d47a1;
margin-bottom: 5px;
padding-bottom: 3px;
border-bottom: 1.5px solid #0d47a1;
margin-bottom: 10px;
padding-bottom: 6px;
border-bottom: 2px solid #0d47a1;
}
.gl-link {
display: flex;
align-items: center;
padding: 3.5px 5px;
padding: 7px 6px;
text-decoration: none;
color: #344054;
font-size: 11.5px;
line-height: 1.3;
font-size: 13px;
cursor: pointer;
border-radius: 4px;
border-radius: 5px;
transition:
background 0.15s,
color 0.15s;
@@ -948,21 +873,23 @@ $gray-700: #334155;
}
.gl-icon {
width: 14px;
margin-right: 5px;
width: 18px;
margin-right: 8px;
color: #667085;
font-size: 11px;
flex-shrink: 0;
font-size: 13px;
.gl-link:hover & {
color: #0d47a1;
}
}
// 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: 5px;
height: 5px;
width: 6px;
height: 6px;
margin-left: auto;
flex-shrink: 0;
border-radius: 50%;
@@ -972,9 +899,9 @@ $gray-700: #334155;
/* RBI Mega Menu */
.rbi-mega-menu {
width: 880px;
max-width: 94vw;
max-height: 72vh;
width: 1100px;
max-width: 97vw;
max-height: 80vh;
overflow-y: auto;
border-top-color: #b71c1c;
}
@@ -982,7 +909,7 @@ $gray-700: #334155;
.rbi-columns {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px 16px;
gap: 20px;
}
@keyframes move {
@@ -20,7 +20,9 @@
<div class="t-live"></div>
&nbsp;LIVE
</div>
<div class="t-chip">Branch:&nbsp;<strong>HO</strong></div>
<div class="t-chip">
Branch:&nbsp;<strong>{{ officeLabel }}</strong>
</div>
<div class="t-chip">
Date:&nbsp;<strong>{{ today | date: 'dd MMM yyyy' }}</strong>
</div>
@@ -522,7 +524,7 @@
</mat-menu>
<!-- Application User Help Menu -->
<mat-menu #applicationMenu="matMenu" [overlapTrigger]="false" class="t-user-menu" xPosition="before">
<mat-menu #applicationMenu="matMenu" [overlapTrigger]="false">
<button mat-menu-item (click)="help()" tabindex="0">
<mat-icon matListIcon>
<fa-icon icon="question-circle" size="sm"></fa-icon>
+58 -186
View File
@@ -390,103 +390,6 @@ $icon-hover-border: rgb(255 255 255 / 28%);
max-height: 80vh;
overflow-y: auto;
}
// Compact card per AI module
.ai-card.mat-mdc-menu-item {
display: flex !important;
align-items: flex-start !important;
gap: 10px !important;
padding: 10px !important;
min-height: 52px !important;
height: auto !important;
border-radius: 9px !important;
font-family: inherit !important;
line-height: 1.3 !important;
white-space: normal !important;
overflow: visible !important;
transition: background 0.16s ease !important;
.mat-mdc-menu-item-text,
.mdc-list-item__primary-text,
.mdc-list-item__content {
display: flex !important;
align-items: flex-start !important;
gap: 10px !important;
width: 100% !important;
white-space: normal !important;
overflow: visible !important;
}
.ai-card-ic {
width: 32px !important;
height: 32px !important;
min-width: 32px !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
border-radius: 8px !important;
font-size: 13px !important;
flex-shrink: 0 !important;
background: var(--c-bg) !important;
color: var(--c-color) !important;
margin: 0 !important;
opacity: 1 !important;
transition:
background 0.16s ease,
color 0.16s ease,
transform 0.16s ease !important;
fa-icon {
font-size: 13px !important;
width: auto !important;
min-width: auto !important;
margin: 0 !important;
color: inherit !important;
opacity: 1 !important;
}
}
.ai-card-body {
display: flex !important;
flex-direction: column !important;
flex: 1 !important;
min-width: 0 !important;
text-align: left !important;
white-space: normal !important;
}
.ai-card-t {
font-size: 12.5px !important;
font-weight: 600 !important;
color: #0f172a !important;
line-height: 1.25 !important;
white-space: normal !important;
display: block !important;
}
.ai-card-d {
font-size: 10.5px !important;
color: #64748b !important;
margin-top: 3px !important;
line-height: 1.35 !important;
white-space: normal !important;
display: block !important;
}
&:hover {
background: var(--c-bg) !important;
.ai-card-ic {
background: var(--c-hover) !important;
color: #fff !important;
transform: scale(1.06) !important;
}
.ai-card-t {
color: var(--c-color) !important;
}
}
}
}
.ai-menu-header {
@@ -585,101 +488,70 @@ $icon-hover-border: rgb(255 255 255 / 28%);
font-size: 11px;
}
// ===== User Account Dropdown Menu =====
::ng-deep .t-user-menu.mat-mdc-menu-panel {
min-width: 190px !important;
border-radius: 10px !important;
box-shadow:
0 10px 25px rgb(15 23 42 / 12%),
0 2px 6px rgb(15 23 42 / 6%) !important;
border: 1px solid #e2e8f0 !important;
padding: 6px 0 !important;
background: #fff !important;
// Compact card per AI module
.ai-card.mat-mdc-menu-item {
display: flex !important;
align-items: flex-start;
gap: 10px;
padding: 9px 10px;
min-height: 0;
height: auto;
border-radius: 9px;
font-family: inherit;
line-height: 1.3;
white-space: normal;
transition: background 0.16s ease;
.mat-mdc-menu-content {
padding: 4px 0 !important;
.ai-card-ic {
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 8px;
font-size: 13px;
flex-shrink: 0;
background: var(--c-bg);
color: var(--c-color);
transition:
background 0.16s ease,
color 0.16s ease,
transform 0.16s ease;
}
.mat-mdc-menu-item {
height: 38px !important;
min-height: 38px !important;
line-height: 38px !important;
padding: 0 14px !important;
display: flex !important;
align-items: center !important;
font-size: 13px !important;
font-weight: 500 !important;
color: #334155 !important;
transition:
background-color 0.15s ease,
color 0.15s ease !important;
.ai-card-body {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
text-align: left;
}
.mat-mdc-menu-item-text {
display: inline-flex !important;
align-items: center !important;
width: 100% !important;
font-size: 13px !important;
color: inherit !important;
font-weight: 500 !important;
.ai-card-t {
font-size: 12.5px;
font-weight: 600;
color: #0f172a;
line-height: 1.25;
}
.ai-card-d {
font-size: 10.5px;
color: #64748b;
margin-top: 2px;
line-height: 1.35;
}
&:hover {
background: var(--c-bg);
.ai-card-ic {
background: var(--c-hover);
color: #fff;
transform: scale(1.06);
}
mat-icon {
margin-right: 10px !important;
width: 18px !important;
height: 18px !important;
min-width: 18px !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
color: #64748b !important;
font-size: 14px !important;
flex-shrink: 0 !important;
fa-icon {
font-size: 14px !important;
width: 14px !important;
min-width: 14px !important;
text-align: center !important;
margin-right: 0 !important;
color: inherit !important;
opacity: 1 !important;
}
}
span {
font-size: 13px !important;
color: inherit !important;
font-weight: 500 !important;
white-space: nowrap !important;
}
&:hover {
background-color: #f1f5f9 !important;
color: #0d47a1 !important;
mat-icon {
color: #0d47a1 !important;
}
}
&#logout {
color: #dc2626 !important;
border-top: 1px solid #f1f5f9 !important;
margin-top: 4px !important;
padding-top: 2px !important;
mat-icon {
color: #dc2626 !important;
}
&:hover {
background-color: #fef2f2 !important;
color: #b91c1c !important;
mat-icon {
color: #b91c1c !important;
}
}
.ai-card-t {
color: var(--c-color);
}
}
}
@@ -49,6 +49,7 @@ import { MatIcon } from '@angular/material/icon';
import { NotificationsTrayComponent as NotificationsTrayComponent_1 } from '../../../shared/notifications-tray/notifications-tray.component';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { DocumentationLinksService } from 'app/shared/services/documentation-links.service';
import { businessToday } from 'app/core/utils/business-today';
/**
* Toolbar component.
@@ -92,6 +93,14 @@ export class ToolbarComponent implements OnInit, AfterViewInit, AfterContentChec
@ViewChild('templateAppMenu') templateAppMenu: TemplateRef<any>;
@ViewChild('notificationsTray') notificationsTray: NotificationsTrayComponent;
/** The signed-in user's office — this chip read "HO" for every login, branch staff included. */
get officeLabel(): string {
if (this.authenticationService.isHeadOfficeUser()) {
return 'HO';
}
return this.authenticationService.getCredentials()?.officeName || '—';
}
/** Subscription to breakpoint observer for handset. */
isHandset$: Observable<boolean> = this.breakpointObserver
.observe(Breakpoints.Handset)
@@ -100,8 +109,10 @@ export class ToolbarComponent implements OnInit, AfterViewInit, AfterContentChec
/** Sets the initial state of sidenav as collapsed. Not collapsed if false. */
sidenavCollapsed = true;
/** Current date for top bar display */
today = new Date();
/** The bank's "today" for the top bar — the business date when enabled, else the server date. */
get today(): Date {
return businessToday();
}
/** Last login time display */
lastLoginTime = this.formatLastLogin();
+30
View File
@@ -0,0 +1,30 @@
/**
* 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/.
*/
/**
* The bank's "today": the business date while `enable-business-date` is on, the server's own date
* while it is off. The server decides which (GET /branchday/today); the web app stores the answer
* in `mifosXServerDate` at login and whenever the business date changes. Before that has happened
* the browser clock is the only date there is, so it is the fallback.
*/
export function businessToday(): Date {
const stored = localStorage.getItem('mifosXServerDate');
const match = stored ? /^(\d{4})-(\d{2})-(\d{2})$/.exec(stored) : null;
if (!match) {
return new Date();
}
// Built from parts so it is local midnight — new Date('yyyy-MM-dd') would be UTC midnight and
// land on the previous day in any timezone behind UTC.
return new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
}
/** {@link businessToday} as yyyy-MM-dd. */
export function businessTodayIso(): string {
const d = businessToday();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
@@ -1,646 +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">
<div class="fd-head">
<div>
<h2 class="fd-head-t"><fa-icon icon="percentage"></fa-icon> FD Interest Posting Reports</h2>
<div class="fd-head-s">
Each branch gets its own report for each fixed deposit scheme, generated automatically at 12:00 noon on the
posting date. One user puts the figures up and a second approves them — whoever generated a report cannot
approve it. Approved interest posts when the branch closes its day, and it cannot close until then.
</div>
</div>
<div class="fd-head-a">
<label class="fd-date">
Posting date
<input type="date" [(ngModel)]="postingDate" (change)="load()" />
</label>
<button class="fd-btn" (click)="load()" [disabled]="loading"><fa-icon icon="undo"></fa-icon>Reload</button>
<button class="fd-btn fd-btn--primary" (click)="openPreview()" [disabled]="previewLoading">
<fa-icon icon="eye"></fa-icon>Preview interest (today)
</button>
<button class="fd-btn" (click)="openOutsideApproval()" [disabled]="outsideApprovalLoading">
<fa-icon icon="bolt"></fa-icon>Posted outside approval
</button>
@if (isHeadOffice) {
<button class="fd-btn" (click)="openProductSummary()" [disabled]="productSummaryLoading">
<fa-icon icon="table"></fa-icon>Product summary
</button>
}
</div>
</div>
@if (!approvalEnabled) {
<div class="fd-alert fd-alert--info">
<fa-icon icon="info-circle"></fa-icon>
<div>
Fixed deposit interest is currently posted directly by the nightly job, so there is nothing to approve here.
Turn on <strong>fd-interest-posting-approval</strong> under System &rarr; Configurations to put deposits behind
this report.
</div>
</div>
}
@if (isHeadOffice) {
<div class="fd-tabs">
<button class="fd-tab" [class.is-active]="tab === 'own'" (click)="switchTab('own')">
<fa-icon icon="building"></fa-icon>My branch{{ myBranch ? ' — ' + myBranch : '' }}
</button>
<button class="fd-tab" [class.is-active]="tab === 'others'" (click)="switchTab('others')">
<fa-icon icon="sitemap"></fa-icon>Other branches
</button>
</div>
@if (tab === 'others') {
<div class="fd-alert fd-alert--info">
<fa-icon icon="info-circle"></fa-icon>
<div>
Each branch generates and approves its own reports. The head office can act here only for a branch that has
not opened the day, and only once every other open branch has closed. Anything it approves is marked (HO).
</div>
</div>
}
}
@if (errorMessage) {
<div class="fd-alert fd-alert--error">
<fa-icon icon="exclamation-triangle"></fa-icon>
<div>{{ errorMessage }}</div>
</div>
}
@if (notice) {
<div class="fd-alert fd-alert--ok">
<fa-icon icon="check"></fa-icon>
<div>{{ notice }}</div>
</div>
}
@if (pendingCount > 0) {
<div class="fd-alert fd-alert--warn">
<fa-icon icon="clock"></fa-icon>
<div>
<strong>{{ pendingCount }} report(s) waiting for approval.</strong> The day cannot be closed until they are
approved, and the user who generated a report cannot be the one to approve it.
</div>
</div>
}
@if (due.length > 0) {
<div class="fd-alert fd-alert--warn">
<fa-icon icon="exclamation-triangle"></fa-icon>
<div class="fd-due-body">
<div>Fixed deposit interest is due but no report has been generated yet for:</div>
@for (d of due; track d.productId + '-' + d.officeId) {
<div class="fd-due-row">
<strong>{{ d.productName }}</strong>
<span class="fd-chip" [ngClass]="fdTypeClass(d.fdType)">{{ fdTypeShort(d.fdType) }}</span>
<span>
&middot; {{ d.officeName }} &middot; {{ d.postingDate }} &middot; {{ d.accountCount }} deposit(s) &middot;
&#8377;{{ d.totalInterest | number: '1.2-2' }}
</span>
@if (d.canAct) {
<button class="fd-btn fd-btn--primary fd-btn--slim" (click)="generate(d)" [disabled]="busy">
<fa-icon icon="sync"></fa-icon>{{ tab === 'others' ? 'Generate on behalf' : 'Generate report' }}
</button>
} @else if (d.blockedReason) {
<span class="fd-blocked">{{ d.blockedReason }}</span>
}
</div>
}
</div>
</div>
}
<!-- Summary strip -->
<div class="fd-summary">
<div class="fd-stat">
<div class="fd-stat__label">Interest due, no report</div>
<div class="fd-stat__value">&#8377;{{ dueTotal | number: '1.2-2' }}</div>
<div class="fd-stat__sub">{{ dueAccountTotal }} deposit(s) &middot; {{ due.length }} product(s)</div>
</div>
<div class="fd-stat fd-stat--warn">
<div class="fd-stat__label">Awaiting approval</div>
<div class="fd-stat__value">&#8377;{{ awaitingTotal | number: '1.2-2' }}</div>
<div class="fd-stat__sub">{{ awaitingReports.length }} report(s) &middot; blocking day close</div>
</div>
<div class="fd-stat fd-stat--ok">
<div class="fd-stat__label">Approved &mdash; posts at close</div>
<div class="fd-stat__value">&#8377;{{ approvedTotal | number: '1.2-2' }}</div>
<div class="fd-stat__sub">{{ approvedReports.length }} report(s)</div>
</div>
<div class="fd-stat fd-stat--posted">
<div class="fd-stat__label">Posted today</div>
<div class="fd-stat__value">&#8377;{{ postedTotal | number: '1.2-2' }}</div>
<div class="fd-stat__sub">{{ postedReports.length }} report(s)</div>
</div>
</div>
@if (showPreview) {
<div class="fd-card">
<div class="fd-detail-head">
<div>
<h3>FD interest preview &mdash; earned up to {{ previewAsOf }}</h3>
<div class="muted">
What each deposit would be paid if interest were posted today. View only &mdash; nothing is saved, approved
or posted.
</div>
</div>
<div class="fd-total">
<span>Total so far</span>
<strong>&#8377;{{ previewTotal | number: '1.2-2' }}</strong>
<span class="muted">{{ previewRows.length }} deposit(s)</span>
</div>
</div>
<div
style="
display: flex;
flex-wrap: wrap;
gap: 10px;
padding: 14px 20px;
border-bottom: 1px solid var(--fd-border-soft);
"
>
<button class="fd-btn fd-btn--slim" (click)="openPreview()" [disabled]="previewLoading">
<fa-icon icon="undo"></fa-icon>Refresh
</button>
<button
class="fd-btn fd-btn--slim"
(click)="exportPreview()"
[disabled]="previewLoading || previewRows.length === 0"
>
<fa-icon icon="file-excel"></fa-icon>Export Excel
</button>
<button class="fd-btn fd-btn--slim" (click)="closePreview()"><fa-icon icon="times"></fa-icon>Close</button>
</div>
@if (previewLoading) {
<div class="fd-empty">Working out what each deposit has earned up to today&hellip;</div>
} @else {
<div class="fd-table-wrap">
<table class="fd-table">
<thead>
<tr>
<th>Branch</th>
<th>Product</th>
<th>Scheme</th>
<th class="num">Deposits</th>
<th class="num">Interest so far</th>
</tr>
</thead>
<tbody>
@for (g of previewGroups; track g.officeName + g.productName) {
<tr>
<td>{{ g.officeName }}</td>
<td>
<strong>{{ g.productName }}</strong>
</td>
<td>
<span class="fd-chip" [ngClass]="fdTypeClass(g.fdType)">{{ fdTypeShort(g.fdType) }}</span>
</td>
<td class="num">{{ g.accounts }}</td>
<td class="num">&#8377;{{ g.interest | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="5" class="fd-empty">No fixed deposit has interest waiting to be posted.</td>
</tr>
}
</tbody>
</table>
</div>
<div class="fd-table-wrap">
<table class="fd-table">
<thead>
<tr>
<th>Account no</th>
<th>Customer</th>
<th>Scheme</th>
<th>Interest for</th>
<th class="num">Deposit</th>
<th class="num">Rate %</th>
<th>Maturity</th>
<th>Pays into</th>
<th class="num">Interest so far</th>
</tr>
</thead>
<tbody>
@for (r of previewRows; track r.fdAccountId + '-' + r.interestPostingDate) {
<tr>
<td class="fd-mono">{{ r.accountNo }}</td>
<td>{{ r.clientName }}</td>
<td>
<span class="fd-chip" [ngClass]="fdTypeClass(r.fdType)">{{ fdTypeShort(r.fdType) }}</span>
</td>
<td class="fd-mono">{{ r.interestPeriodFrom || '—' }} &rarr; {{ r.interestPostingDate }}</td>
<td class="num">&#8377;{{ r.depositAmount | number: '1.2-2' }}</td>
<td class="num">{{ r.interestRate | number: '1.2-2' }}</td>
<td class="fd-mono">{{ r.maturityDate || '—' }}</td>
<td class="fd-sub">{{ r.payoutAccountNo || 'Stays in the deposit' }}</td>
<td class="num">&#8377;{{ r.interest | number: '1.2-2' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="8"><strong>Total</strong></td>
<td class="num">
<strong>&#8377;{{ previewTotal | number: '1.2-2' }}</strong>
</td>
</tr>
</tfoot>
</table>
</div>
}
</div>
}
@if (showOutsideApproval) {
<div class="fd-card">
<div class="fd-detail-head">
<div>
<h3>Fixed deposit interest posted outside this report</h3>
<div class="muted">
A deposit that matured before its own posting cycle completed has its last period settled directly, the
moment it matures &mdash; before any report here could be generated or approved for it. Listed here so
nothing paid to a customer is invisible, approved or not.
</div>
</div>
<div class="fd-total">
<span>Total</span>
<strong>&#8377;{{ outsideApprovalTotal | number: '1.2-2' }}</strong>
<span class="muted">{{ outsideApprovalRows.length }} posting(s)</span>
</div>
</div>
<div
style="
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 10px;
padding: 14px 20px;
border-bottom: 1px solid var(--fd-border-soft);
"
>
<label class="fd-date">
From
<input type="date" [(ngModel)]="outsideApprovalFrom" (change)="loadOutsideApproval()" />
</label>
<label class="fd-date">
To
<input type="date" [(ngModel)]="outsideApprovalTo" (change)="loadOutsideApproval()" />
</label>
<button class="fd-btn fd-btn--slim" (click)="loadOutsideApproval()" [disabled]="outsideApprovalLoading">
<fa-icon icon="undo"></fa-icon>Refresh
</button>
<button class="fd-btn fd-btn--slim" (click)="closeOutsideApproval()">
<fa-icon icon="times"></fa-icon>Close
</button>
</div>
@if (outsideApprovalLoading) {
<div class="fd-empty">Looking for deposit interest posted outside this report&hellip;</div>
} @else {
<div class="fd-table-wrap">
<table class="fd-table">
<thead>
<tr>
<th>Product</th>
<th>Scheme</th>
<th class="num">Deposits</th>
<th class="num">Interest</th>
</tr>
</thead>
<tbody>
@for (g of outsideApprovalGroups; track g.productName) {
<tr>
<td>
<strong>{{ g.productName }}</strong>
</td>
<td>
<span class="fd-chip" [ngClass]="fdTypeClass(g.fdType)">{{ fdTypeShort(g.fdType) }}</span>
</td>
<td class="num">{{ g.accounts }}</td>
<td class="num">&#8377;{{ g.interest | number: '1.2-2' }}</td>
</tr>
} @empty {
<tr>
<td colspan="4" class="fd-empty">
No fixed deposit interest was posted outside this report in this period.
</td>
</tr>
}
</tbody>
</table>
</div>
@if (outsideApprovalRows.length > 0) {
<div class="fd-table-wrap">
<table class="fd-table">
<thead>
<tr>
<th>Account no</th>
<th>Customer</th>
<th>Product</th>
<th>Scheme</th>
<th>Interest for</th>
<th class="num">Interest</th>
<th>Posted at</th>
</tr>
</thead>
<tbody>
@for (r of outsideApprovalRows; track r.transactionId) {
<tr>
<td class="fd-mono">{{ r.accountNo }}</td>
<td>{{ r.clientName }}</td>
<td>{{ r.productName }}</td>
<td>
<span class="fd-chip" [ngClass]="fdTypeClass(r.fdType)">{{ fdTypeShort(r.fdType) }}</span>
</td>
<td class="fd-mono">{{ r.interestFor }}</td>
<td class="num">&#8377;{{ r.interestAmount | number: '1.2-2' }}</td>
<td class="fd-sub">{{ r.postedAt | date: 'dd MMM yyyy, HH:mm' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="5"><strong>Total</strong></td>
<td class="num">
<strong>&#8377;{{ outsideApprovalTotal | number: '1.2-2' }}</strong>
</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
}
}
</div>
}
@if (showProductSummary) {
<div class="fd-card">
<div class="fd-detail-head">
<div>
<h3>Interest by product</h3>
<div class="muted">
Posted through this report, still awaiting approval or posting, and posted directly at maturity instead —
added up to what the product has paid out in total over the period.
</div>
</div>
<div class="fd-total">
<span>Total, all products</span>
<strong>&#8377;{{ productSummaryGrandTotal.total | number: '1.2-2' }}</strong>
</div>
</div>
<div
style="
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 10px;
padding: 14px 20px;
border-bottom: 1px solid var(--fd-border-soft);
"
>
<label class="fd-date">
From
<input type="date" [(ngModel)]="productSummaryFrom" (change)="loadProductSummary()" />
</label>
<label class="fd-date">
To
<input type="date" [(ngModel)]="productSummaryTo" (change)="loadProductSummary()" />
</label>
<button class="fd-btn fd-btn--slim" (click)="loadProductSummary()" [disabled]="productSummaryLoading">
<fa-icon icon="undo"></fa-icon>Refresh
</button>
<button class="fd-btn fd-btn--slim" (click)="closeProductSummary()">
<fa-icon icon="times"></fa-icon>Close
</button>
</div>
@if (productSummaryLoading) {
<div class="fd-empty">Adding up interest by product&hellip;</div>
} @else {
<div class="fd-table-wrap">
<table class="fd-table">
<thead>
<tr>
<th>Product</th>
<th>Scheme</th>
<th>Posting period</th>
<th class="num">Posted (report)</th>
<th class="num">Pending approval</th>
<th class="num">Posted at maturity</th>
<th class="num">Total</th>
</tr>
</thead>
<tbody>
@for (row of productSummaryRows; track row.productId) {
<tr>
<td>
<strong>{{ row.productName }}</strong>
</td>
<td>
<span class="fd-chip" [ngClass]="fdTypeClass(row.fdType)">{{ fdTypeShort(row.fdType) }}</span>
</td>
<td class="fd-sub">{{ row.postingPeriodLabel || '—' }}</td>
<td class="num">&#8377;{{ row.posted | number: '1.2-2' }}</td>
<td class="num">&#8377;{{ row.pending | number: '1.2-2' }}</td>
<td class="num">&#8377;{{ row.maturityPosted | number: '1.2-2' }}</td>
<td class="num">
<strong>&#8377;{{ row.total | number: '1.2-2' }}</strong>
</td>
</tr>
} @empty {
<tr>
<td colspan="7" class="fd-empty">No fixed deposit interest in this period.</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="3"><strong>Total</strong></td>
<td class="num">
<strong>&#8377;{{ productSummaryGrandTotal.posted | number: '1.2-2' }}</strong>
</td>
<td class="num">
<strong>&#8377;{{ productSummaryGrandTotal.pending | number: '1.2-2' }}</strong>
</td>
<td class="num">
<strong>&#8377;{{ productSummaryGrandTotal.maturityPosted | number: '1.2-2' }}</strong>
</td>
<td class="num">
<strong>&#8377;{{ productSummaryGrandTotal.total | number: '1.2-2' }}</strong>
</td>
</tr>
</tfoot>
</table>
</div>
}
</div>
}
<div class="fd-card">
<div class="fd-card-h">
<h2>Reports{{ postingDate ? ' for ' + postingDate : '' }}</h2>
<span class="fd-card-sub">One report per scheme, per branch</span>
</div>
<div class="fd-table-wrap">
<table class="fd-table">
<thead>
<tr>
<th>Branch</th>
<th>Scheme</th>
<th>Type</th>
<th>Posting date</th>
<th class="num">Deposits</th>
<th class="num">Total interest</th>
<th>Status</th>
<th>Maker</th>
<th>Checker</th>
<th></th>
</tr>
</thead>
<tbody>
@for (r of reports; track r.id) {
<tr class="is-clickable" [class.is-selected]="selected?.id === r.id" (click)="open(r)">
<td>{{ r.officeName }}</td>
<td>
<strong>{{ r.productName }}</strong>
</td>
<td [title]="fdTypeLabel(r.fdType)">
<span class="fd-chip" [ngClass]="fdTypeClass(r.fdType)">{{ fdTypeShort(r.fdType) }}</span>
</td>
<td class="fd-mono">{{ r.postingDate }}</td>
<td class="num">{{ r.accountCount }}</td>
<td class="num">&#8377;{{ r.totalInterest | number: '1.2-2' }}</td>
<td>
<span class="fd-badge" [ngClass]="statusClass(r.status)">{{ r.status }}</span>
@if (r.failureReason) {
<div class="fd-fail" [title]="r.failureReason">Posting failed: {{ r.failureReason }}</div>
}
</td>
<td class="fd-sub">
{{ r.generatedBy || 'System' }} &middot; {{ r.generationMode }}<br />{{
r.generatedAt | date: 'dd MMM, HH:mm'
}}
</td>
<td class="fd-sub">
@if (r.approvedAt) {
{{ r.approvedBy }}
@if (r.approvedOnBehalf) {
<span class="fd-behalf">(HO)</span>
}
<br />{{ r.approvedAt | date: 'dd MMM, HH:mm' }}
} @else {
&mdash;
}
</td>
<td (click)="$event.stopPropagation()">
<div style="display: flex; flex-wrap: wrap; gap: 6px; justify-content: flex-end">
@if (canRegenerate(r)) {
<button class="fd-btn fd-btn--slim" (click)="regenerate(r)" [disabled]="busy">
<fa-icon icon="sync"></fa-icon>Regenerate
</button>
}
@if (canApprove(r)) {
<button class="fd-btn fd-btn--primary fd-btn--slim" (click)="approve(r)" [disabled]="busy">
<fa-icon icon="check"></fa-icon>{{ tab === 'others' ? 'Approve on behalf' : 'Approve' }}
</button>
} @else if (r.status === 'GENERATED' && r.approveBlockedReason) {
<span class="fd-blocked">{{ r.approveBlockedReason }}</span>
}
</div>
</td>
</tr>
} @empty {
<tr>
<td colspan="10" class="fd-empty">
@if (loading) {
Loading&hellip;
} @else {
No fixed deposit interest posting report for {{ postingDate || 'this date' }}. Reports are generated
at 12:00 noon on a product's posting date.
}
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
@if (selected) {
<div class="fd-card">
<div class="fd-detail-head">
<div>
<h3>
{{ selected.officeName }} &middot; {{ selected.productName }} &mdash; {{ selected.postingDate }}
<span class="fd-chip" [ngClass]="fdTypeClass(selected.fdType)">{{ fdTypeShort(selected.fdType) }}</span>
<span class="fd-badge fd-badge--muted">{{ selected.postingPeriodLabel }}</span>
<span class="fd-badge" [ngClass]="statusClass(selected.status)">{{ selected.status }}</span>
</h3>
<div class="muted">{{ fdTypeLabel(selected.fdType) }}</div>
<div class="muted">
Dr Interest Expense &rarr; Cr {{ selected.payableGlCode }} {{ selected.payableGlName }}; Dr
{{ selected.payableGlCode }} &rarr; Cr {{ selected.paidGlCode }} {{ selected.paidGlName }}; then Dr
{{ selected.paidGlCode }} &rarr; Cr each deposit below
</div>
</div>
<div class="fd-total">
<span>Total interest</span>
<strong>&#8377;{{ selected.totalInterest | number: '1.2-2' }}</strong>
<span class="muted">{{ selected.accountCount }} deposit(s)</span>
</div>
</div>
<div class="fd-table-wrap">
<table class="fd-table">
<thead>
<tr>
<th>Account no</th>
<th>Account holder</th>
<th>Interest for</th>
<th class="num">Deposit</th>
<th class="num">Rate %</th>
<th>Maturity</th>
<th class="num">Interest</th>
<th class="num">Balance before</th>
<th class="num">Balance after</th>
<th>Pays into</th>
<th>Posted txn</th>
</tr>
</thead>
<tbody>
@for (l of selected.lines; track l.fdAccountId + '-' + l.interestPostingDate) {
<tr>
<td class="fd-mono">{{ l.accountNo }}</td>
<td>{{ l.clientName }}</td>
<td class="fd-mono">{{ l.interestPeriodFrom || '—' }} &rarr; {{ l.interestPostingDate }}</td>
<td class="num">&#8377;{{ l.depositAmount | number: '1.2-2' }}</td>
<td class="num">{{ l.interestRate | number: '1.2-2' }}</td>
<td class="fd-mono">{{ l.maturityDate || '—' }}</td>
<td class="num">&#8377;{{ l.interestAmount | number: '1.2-2' }}</td>
<td class="num">&#8377;{{ l.balanceBefore | number: '1.2-2' }}</td>
<td class="num">&#8377;{{ l.balanceAfter | number: '1.2-2' }}</td>
<td class="fd-sub">{{ l.payoutAccountNo || 'Stays in the deposit' }}</td>
<td class="fd-sub">{{ l.fdTransactionId ? '#' + l.fdTransactionId : '—' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="6"><strong>Total</strong></td>
<td class="num">
<strong>&#8377;{{ selected.totalInterest | number: '1.2-2' }}</strong>
</td>
<td colspan="4"></td>
</tr>
</tfoot>
</table>
</div>
</div>
}
</div>
@@ -1,559 +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 {
--fd-navy: #101723;
--fd-accent: #1b4d8f;
--fd-accent-dark: #123c70;
--fd-ground: #f7f8fa;
--fd-border: #dfe4eb;
--fd-border-soft: #edf0f4;
--fd-muted: #5b6676;
--fd-text: #3c4757;
--fd-amber: #6b4600;
--fd-amber-dark: #5a3b00;
--fd-amber-tint: #fdf3dc;
--fd-amber-border: #efd9a3;
--fd-amber-wash: #fffcf4;
--fd-green: #185e38;
--fd-green-dark: #1d6f42;
--fd-green-tint: #e2f3e8;
--fd-green-wash: #f3faf5;
--fd-blue-tint: #e6eef8;
--fd-slate-tint: #edf0f4;
display: block;
font-family: 'IBM Plex Sans', system-ui, sans-serif;
color: var(--fd-navy);
}
.fd {
max-width: 1320px;
margin: 0 auto;
padding: 20px 4px 56px;
}
// ── Header ──────────────────────────────────────────────────────────────
.fd-head {
display: flex;
flex-wrap: wrap;
gap: 24px;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 22px;
}
.fd-head-t {
margin: 0;
font-family: 'IBM Plex Serif', serif;
font-weight: 600;
font-size: 26px;
letter-spacing: -0.01em;
display: flex;
align-items: center;
gap: 10px;
fa-icon {
color: var(--fd-accent);
font-size: 20px;
}
}
.fd-head-s {
max-width: 720px;
margin-top: 8px;
font-size: 13.5px;
line-height: 1.6;
color: var(--fd-muted);
}
.fd-head-a {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 10px;
}
.fd-date {
display: flex;
flex-direction: column;
gap: 5px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--fd-muted);
input {
font-family: 'IBM Plex Mono', monospace;
font-size: 13px;
padding: 9px 11px;
min-height: 44px;
border: 1px solid #cdd5df;
border-radius: 7px;
background: #fff;
color: var(--fd-navy);
}
}
// ── Buttons ─────────────────────────────────────────────────────────────
.fd-btn {
font-family: inherit;
font-size: 12.5px;
font-weight: 500;
padding: 8px 14px;
min-height: 44px;
border-radius: 7px;
border: 1px solid #cdd5df;
background: #fff;
color: var(--fd-navy);
cursor: pointer;
white-space: nowrap;
fa-icon {
margin-right: 6px;
}
&:hover:not([disabled]) {
background: #f2f5f9;
}
&[disabled] {
opacity: 0.5;
cursor: not-allowed;
}
&--primary {
background: var(--fd-accent);
border-color: var(--fd-accent);
color: #fff;
&:hover:not([disabled]) {
background: var(--fd-accent-dark);
}
}
&--slim {
min-height: 34px;
padding: 6px 11px;
font-size: 12px;
}
}
// ── Tabs ────────────────────────────────────────────────────────────────
.fd-tabs {
display: flex;
gap: 4px;
margin-bottom: 20px;
border-bottom: 1px solid var(--fd-border);
}
.fd-tab {
font-family: inherit;
font-size: 13px;
font-weight: 500;
padding: 10px 6px;
min-height: 44px;
border: 0;
border-bottom: 2px solid transparent;
background: transparent;
color: var(--fd-muted);
cursor: pointer;
fa-icon {
margin-right: 7px;
}
&.is-active {
border-bottom-color: var(--fd-accent);
color: var(--fd-accent);
font-weight: 600;
}
& + .fd-tab {
margin-left: 18px;
}
}
// ── Alerts ──────────────────────────────────────────────────────────────
.fd-alert {
display: flex;
gap: 12px;
align-items: flex-start;
margin-bottom: 18px;
padding: 14px 18px;
border-radius: 10px;
font-size: 13px;
line-height: 1.6;
fa-icon {
margin-top: 2px;
flex-shrink: 0;
}
&--info {
background: #eef3fa;
border: 1px solid #c8d8ec;
color: #1f3a5c;
}
&--warn {
background: var(--fd-amber-tint);
border: 1px solid var(--fd-amber-border);
color: var(--fd-amber-dark);
strong {
color: var(--fd-amber-dark);
}
}
&--ok {
background: var(--fd-green-wash);
border: 1px solid #bfe3cc;
color: var(--fd-green);
}
&--error {
background: #fdecea;
border: 1px solid #f3b8b1;
color: #8a2b22;
}
}
.fd-due-body {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
}
.fd-due-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 8px 0;
& + .fd-due-row {
border-top: 1px dashed var(--fd-amber-border);
}
}
.fd-blocked {
font-size: 11.5px;
color: var(--fd-muted);
}
// ── Summary strip ───────────────────────────────────────────────────────
.fd-summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
margin-bottom: 20px;
@media (width <= 860px) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.fd-stat {
background: #fff;
border: 1px solid var(--fd-border);
border-radius: 12px;
padding: 18px 20px;
&__label {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--fd-muted);
}
&__value {
font-family: 'IBM Plex Mono', monospace;
font-variant-numeric: tabular-nums;
font-size: 25px;
font-weight: 500;
letter-spacing: -0.02em;
margin-top: 9px;
}
&__sub {
font-size: 11.5px;
color: var(--fd-muted);
margin-top: 4px;
}
&--warn {
border-color: var(--fd-amber-border);
.fd-stat__label,
.fd-stat__sub {
color: var(--fd-amber);
}
.fd-stat__value {
color: var(--fd-amber-dark);
}
}
&--ok {
.fd-stat__label {
color: var(--fd-green);
}
.fd-stat__value {
color: var(--fd-green);
}
}
&--posted {
.fd-stat__label {
color: var(--fd-accent);
}
.fd-stat__value {
color: var(--fd-accent);
}
}
}
// ── Cards ───────────────────────────────────────────────────────────────
.fd-card {
background: #fff;
border: 1px solid var(--fd-border);
border-radius: 12px;
margin-bottom: 20px;
overflow: hidden;
}
.fd-card-h {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: baseline;
padding: 16px 20px;
border-bottom: 1px solid var(--fd-border);
h2,
h3 {
margin: 0;
font-size: 15px;
font-weight: 600;
}
}
.fd-card-sub {
font-size: 12px;
color: var(--fd-muted);
}
// ── Tables ──────────────────────────────────────────────────────────────
.fd-table-wrap {
overflow-x: auto;
}
.fd-table {
border-collapse: collapse;
width: 100%;
th {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--fd-muted);
text-align: left;
padding: 10px 14px;
border-bottom: 1px solid var(--fd-border);
white-space: nowrap;
}
td {
font-size: 13px;
padding: 12px 14px;
border-bottom: 1px solid var(--fd-border-soft);
vertical-align: top;
}
tbody tr:last-child td {
border-bottom: 0;
}
tbody tr.is-clickable {
cursor: pointer;
&:hover td {
background: #fafbfd;
}
}
tbody tr.is-selected td {
background: var(--fd-blue-tint);
}
tfoot tr {
background: var(--fd-ground);
}
.num {
text-align: right;
font-family: 'IBM Plex Mono', monospace;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
}
.fd-mono {
font-family: 'IBM Plex Mono', monospace;
font-variant-numeric: tabular-nums;
}
.fd-sub {
font-size: 11.5px;
color: var(--fd-muted);
margin-top: 3px;
}
.fd-empty {
text-align: center;
padding: 30px 16px;
color: var(--fd-muted);
font-size: 13px;
}
// ── Chips & badges ──────────────────────────────────────────────────────
.fd-chip {
display: inline-block;
padding: 2px 9px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
white-space: nowrap;
&--single {
background: var(--fd-slate-tint);
color: var(--fd-text);
}
&--double {
background: var(--fd-blue-tint);
color: var(--fd-accent);
}
&--special {
background: var(--fd-amber-tint);
color: var(--fd-amber);
}
}
.fd-badge {
display: inline-block;
padding: 2px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.02em;
white-space: nowrap;
&--pending {
background: var(--fd-amber-tint);
color: var(--fd-amber);
}
&--approved {
background: var(--fd-green-tint);
color: var(--fd-green);
}
&--posted {
background: var(--fd-blue-tint);
color: var(--fd-accent);
}
&--muted {
background: var(--fd-slate-tint);
color: var(--fd-muted);
}
}
.fd-behalf {
font-size: 10.5px;
font-weight: 700;
color: var(--fd-accent);
margin-left: 4px;
}
.fd-fail {
margin-top: 4px;
font-size: 11px;
color: #a32b22;
}
// ── Detail panel ────────────────────────────────────────────────────────
.fd-detail-head {
display: flex;
flex-wrap: wrap;
gap: 20px;
justify-content: space-between;
align-items: flex-start;
padding: 22px 20px;
border-bottom: 1px solid var(--fd-border-soft);
background: #fbfcfd;
h3 {
margin: 0;
font-family: 'IBM Plex Serif', serif;
font-weight: 600;
font-size: 19px;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 9px;
}
}
.fd-total {
text-align: right;
span:first-child {
display: block;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--fd-muted);
}
strong {
display: block;
font-family: 'IBM Plex Mono', monospace;
font-variant-numeric: tabular-nums;
font-size: 26px;
font-weight: 500;
margin-top: 6px;
letter-spacing: -0.02em;
}
}
.muted {
color: var(--fd-muted);
font-size: 12.5px;
margin-top: 4px;
}
@@ -1,686 +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 { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { SettingsService } from 'app/settings/settings.service';
import { exportWorkbook } from '../cashier/cashier-export';
import {
FdInterestDue,
FdInterestPreviewRow,
FdInterestReport,
FdInterestReportDetail,
FdInterestPostingReportService,
FdInterestSummaryRow,
FdOutsideApprovalRow,
FdReportScope,
FdType
} from './fd-interest-posting-report.service';
/**
* Fixed deposit interest posting reports.
*
* On a product's posting date the system generates, at noon, the list of what each deposit will be
* paid. One user of the branch reviews it and a second approves it — the maker cannot be the checker,
* because posting interest moves money to customers and, for a special deposit, straight out to their
* savings account. The approved amounts are posted when the branch closes the day, and until then it
* cannot close. Regenerating replaces the report and needs approving again.
*/
@Component({
selector: 'mifosx-fd-interest-posting-report',
templateUrl: './fd-interest-posting-report.component.html',
styleUrls: ['./fd-interest-posting-report.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule,
FaIconComponent
]
})
export class FdInterestPostingReportComponent implements OnInit {
private reportService = inject(FdInterestPostingReportService);
private settingsService = inject(SettingsService);
postingDate = '';
loading = false;
busy = false;
errorMessage = '';
notice = '';
reports: FdInterestReport[] = [];
selected: FdInterestReportDetail | null = null;
/** Products with interest due and no report yet — the noon job ran before they had anything due, or failed. */
due: FdInterestDue[] = [];
/** 'own' — the user's own branch; 'others' — every other branch, a head-office-only tab. */
tab: FdReportScope = 'own';
isHeadOffice = false;
myBranch = '';
/** False where the bank has deposits on the old direct-posting path; every list here is then empty by design. */
approvalEnabled = true;
ngOnInit(): void {
this.postingDate = this.businessDateIso();
this.reportService.context().subscribe({
next: (ctx) => {
this.isHeadOffice = !!ctx?.headOffice;
this.myBranch = ctx?.officeName ?? '';
this.approvalEnabled = ctx?.approvalEnabled !== false;
},
error: () => (this.isHeadOffice = false)
});
this.load();
}
switchTab(tab: FdReportScope): void {
if (this.tab === tab) {
return;
}
this.tab = tab;
this.selected = null;
this.notice = '';
this.load();
}
/** Acting for another branch is the head office's exception, so it is spelt out before it happens. */
private confirmOnBehalf(officeName: string, action: string): boolean {
if (this.tab !== 'others') {
return true;
}
return confirm(
`${officeName} has not opened today. You are about to ${action} its fixed deposit interest posting report on its ` +
'behalf, from the head office. Continue?'
);
}
load(): void {
this.loading = true;
this.errorMessage = '';
this.reportService.list(this.postingDate || undefined, undefined, undefined, this.tab).subscribe({
next: (reports) => {
this.reports = reports ?? [];
this.loading = false;
const stillThere = this.selected && this.reports.find((r) => r.id === this.selected?.id);
if (stillThere) {
this.open(stillThere);
} else {
this.selected = null;
}
},
error: (err) => {
this.loading = false;
this.errorMessage = this.messageOf(err, 'Could not load the fixed deposit interest posting reports.');
}
});
this.reportService.dueWithoutReport(this.postingDate || undefined, this.tab).subscribe({
next: (due) => (this.due = due ?? []),
error: () => (this.due = [])
});
}
generate(item: FdInterestDue): void {
if (!this.confirmOnBehalf(item.officeName, 'generate')) {
return;
}
this.busy = true;
this.notice = '';
this.errorMessage = '';
this.reportService.regenerate(item.productId, item.officeId, item.postingDate).subscribe({
next: (res) => {
this.busy = false;
this.notice = `Report generated for ${item.productName} — ${item.officeName}. Another user of the branch has to approve it.`;
this.load();
this.reportService.detail(res.reportId).subscribe((detail) => (this.selected = detail));
},
error: (err) => {
this.busy = false;
this.errorMessage = this.messageOf(err, 'Could not generate the report.');
}
});
}
open(report: FdInterestReport): void {
this.errorMessage = '';
this.reportService.detail(report.id).subscribe({
next: (detail) => (this.selected = detail),
error: (err) => (this.errorMessage = this.messageOf(err, 'Could not load the report.'))
});
}
regenerate(report: FdInterestReport): void {
if (
!confirm(
`Regenerate the fixed deposit interest posting report for ${report.productName} — ${report.officeName}? ` +
'It will need approving again.'
) ||
!this.confirmOnBehalf(report.officeName, 'regenerate')
) {
return;
}
this.busy = true;
this.notice = '';
this.errorMessage = '';
this.reportService.regenerate(report.productId, report.officeId, report.postingDate).subscribe({
next: (res) => {
this.busy = false;
this.notice = `Report regenerated for ${report.productName}. Another user of the branch has to approve it.`;
this.selected = null;
this.load();
this.reportService.detail(res.reportId).subscribe((detail) => (this.selected = detail));
},
error: (err) => {
this.busy = false;
this.errorMessage = this.messageOf(err, 'Could not regenerate the report.');
}
});
}
approve(report: FdInterestReport): void {
const payout =
report.fdType === 'SPECIAL'
? ' This is a monthly-income product, so the interest is paid out to the customers’ linked savings accounts.'
: '';
if (
!confirm(
`Approve interest of ₹${report.totalInterest} across ${report.accountCount} deposit(s) for ${report.productName}?` +
`${payout} It will be posted when the branch closes the day.`
) ||
!this.confirmOnBehalf(report.officeName, 'approve')
) {
return;
}
this.busy = true;
this.notice = '';
this.errorMessage = '';
this.reportService.approve(report.id).subscribe({
next: () => {
this.busy = false;
this.notice = `Approved. Interest for ${report.productName} — ${report.officeName} will be posted when the branch closes the day.`;
this.load();
},
error: (err) => {
this.busy = false;
this.errorMessage = this.messageOf(err, 'Could not approve the report.');
}
});
}
canRegenerate(report: FdInterestReport): boolean {
return report.canAct && (report.status === 'GENERATED' || report.status === 'APPROVED');
}
/**
* The server decides this, not the screen: it alone knows who generated the report, and the maker
* cannot be the checker.
*/
canApprove(report: FdInterestReport): boolean {
return !!report.canApprove;
}
statusClass(status: string): string {
return (
{
GENERATED: 'fd-badge--pending',
APPROVED: 'fd-badge--approved',
POSTED: 'fd-badge--posted',
SUPERSEDED: 'fd-badge--muted'
}[status] ?? 'fd-badge--muted'
);
}
/** What the scheme means for the customer, spelt out rather than left as a code. */
fdTypeLabel(fdType: FdType | string): string {
return (
{
SINGLE: 'Single — interest paid at maturity',
DOUBLE: 'Double — interest compounded into the deposit',
SPECIAL: 'Special — interest paid monthly to linked savings'
}[fdType] ?? String(fdType ?? '')
);
}
fdTypeShort(fdType: FdType | string): string {
return (
{
SINGLE: 'Single',
DOUBLE: 'Double',
SPECIAL: 'Special'
}[fdType] ?? String(fdType ?? '')
);
}
fdTypeClass(fdType: FdType | string): string {
return (
{
SINGLE: 'fd-chip--single',
DOUBLE: 'fd-chip--double',
SPECIAL: 'fd-chip--special'
}[fdType] ?? 'fd-chip--single'
);
}
/** Pending reports that block this user's own day close; another branch's pending reports block only that branch. */
get pendingCount(): number {
return this.tab === 'own' ? this.reports.filter((r) => r.status === 'GENERATED').length : 0;
}
// ── Summary strip: the day's reports, at a glance ──
get dueTotal(): number {
return this.due.reduce((sum, d) => sum + Number(d.totalInterest || 0), 0);
}
get dueAccountTotal(): number {
return this.due.reduce((sum, d) => sum + Number(d.accountCount || 0), 0);
}
private reportsWithStatus(status: string): FdInterestReport[] {
return this.reports.filter((r) => r.status === status);
}
private totalOf(list: FdInterestReport[]): number {
return list.reduce((sum, r) => sum + Number(r.totalInterest || 0), 0);
}
get awaitingReports(): FdInterestReport[] {
return this.reportsWithStatus('GENERATED');
}
get approvedReports(): FdInterestReport[] {
return this.reportsWithStatus('APPROVED');
}
get postedReports(): FdInterestReport[] {
return this.reportsWithStatus('POSTED');
}
get awaitingTotal(): number {
return this.totalOf(this.awaitingReports);
}
get approvedTotal(): number {
return this.totalOf(this.approvedReports);
}
get postedTotal(): number {
return this.totalOf(this.postedReports);
}
// ── Preview: what each deposit has earned so far, as if posted today. View only. ──
showPreview = false;
previewLoading = false;
previewRows: FdInterestPreviewRow[] = [];
previewAsOf = '';
openPreview(): void {
this.showPreview = true;
this.previewLoading = true;
this.errorMessage = '';
this.previewAsOf = this.businessDateIso();
this.reportService.preview().subscribe({
next: (rows) => {
this.previewRows = rows ?? [];
this.previewLoading = false;
},
error: (err) => {
this.previewLoading = false;
this.previewRows = [];
this.errorMessage = this.messageOf(err, 'Could not load the fixed deposit interest preview.');
}
});
}
closePreview(): void {
this.showPreview = false;
}
get previewTotal(): number {
return this.previewRows.reduce((sum, r) => sum + Number(r.interest || 0), 0);
}
/** Totals by branch, product and scheme, for the summary above the deposit list and the export. */
get previewGroups(): {
officeName: string;
productName: string;
fdType: string;
accounts: number;
interest: number;
}[] {
const map = new Map<
string,
{ officeName: string; productName: string; fdType: string; accounts: number; interest: number }
>();
this.previewRows.forEach((r) => {
const key = `${r.officeName}|${r.productName}`;
const g = map.get(key) ?? {
officeName: r.officeName,
productName: r.productName,
fdType: r.fdType,
accounts: 0,
interest: 0
};
g.accounts += 1;
g.interest += Number(r.interest || 0);
map.set(key, g);
});
return [...map.values()];
}
exportPreview(): void {
const stamp = `Fixed deposit interest earned to ${this.previewAsOf} — preview, not posted`;
exportWorkbook(
[
{
sheetName: 'By deposit',
title: 'Fixed Deposit Interest Preview',
subtitle: stamp,
headers: [
'Branch',
'Product',
'Scheme',
'Account No',
'Customer',
'Interest For',
'Deposit',
'Rate %',
'Maturity',
'Balance',
'Pays Into',
'Interest So Far'
],
widths: [
18,
22,
10,
20,
26,
12,
16,
8,
12,
16,
18,
16
],
rows: [
...this.previewRows.map((r) => [
r.officeName,
r.productName,
this.fdTypeShort(r.fdType),
r.accountNo,
r.clientName,
r.interestPostingDate ?? '',
Number(r.depositAmount || 0),
Number(r.interestRate || 0),
r.maturityDate ?? '',
Number(r.balance || 0),
r.payoutAccountNo ?? '',
Number(r.interest || 0)
]),
[
'TOTAL',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
this.previewTotal
]
]
},
{
sheetName: 'By branch & product',
title: 'Fixed Deposit Interest Preview — Totals',
subtitle: stamp,
headers: [
'Branch',
'Product',
'Scheme',
'Deposits',
'Interest So Far'
],
widths: [
18,
22,
10,
10,
16
],
rows: [
...this.previewGroups.map((g) => [
g.officeName,
g.productName,
this.fdTypeShort(g.fdType),
g.accounts,
g.interest
]),
[
'TOTAL',
'',
'',
this.previewRows.length,
this.previewTotal
]
]
}
],
`fd-interest-preview-${this.previewAsOf}`
);
}
// ── Outside approval: deposit interest posted without this report, chiefly at maturity ──
showOutsideApproval = false;
outsideApprovalLoading = false;
outsideApprovalRows: FdOutsideApprovalRow[] = [];
outsideApprovalFrom = '';
outsideApprovalTo = '';
openOutsideApproval(): void {
this.showOutsideApproval = true;
if (!this.outsideApprovalTo) {
this.outsideApprovalTo = this.businessDateIso();
this.outsideApprovalFrom = `${this.outsideApprovalTo.slice(0, 7)}-01`;
}
this.loadOutsideApproval();
}
loadOutsideApproval(): void {
this.outsideApprovalLoading = true;
this.errorMessage = '';
this.reportService
.outsideApproval(this.outsideApprovalFrom || undefined, this.outsideApprovalTo || undefined, undefined, this.tab)
.subscribe({
next: (rows) => {
this.outsideApprovalRows = rows ?? [];
this.outsideApprovalLoading = false;
},
error: (err) => {
this.outsideApprovalLoading = false;
this.outsideApprovalRows = [];
this.errorMessage = this.messageOf(err, 'Could not load deposit interest posted outside this report.');
}
});
}
closeOutsideApproval(): void {
this.showOutsideApproval = false;
}
get outsideApprovalTotal(): number {
return this.outsideApprovalRows.reduce((sum, r) => sum + Number(r.interestAmount || 0), 0);
}
/** Totals by product and scheme, so a branch can see at a glance where its unapproved interest sits. */
get outsideApprovalGroups(): { productName: string; fdType: string; accounts: number; interest: number }[] {
const map = new Map<string, { productName: string; fdType: string; accounts: number; interest: number }>();
this.outsideApprovalRows.forEach((r) => {
const g = map.get(r.productName) ?? { productName: r.productName, fdType: r.fdType, accounts: 0, interest: 0 };
g.accounts += 1;
g.interest += Number(r.interestAmount || 0);
map.set(r.productName, g);
});
return [...map.values()];
}
// ── Product summary: due for the product vs posted through this report vs posted at maturity ──
// Head office only, same as the branch-wise summary it reuses — a branch reads its own due and
// posted totals off the strip and tables above, which already cover it.
showProductSummary = false;
productSummaryLoading = false;
productSummaryFrom = '';
productSummaryTo = '';
private summaryRows: FdInterestSummaryRow[] = [];
private productOutsideRows: FdOutsideApprovalRow[] = [];
openProductSummary(): void {
this.showProductSummary = true;
if (!this.productSummaryTo) {
this.productSummaryTo = this.businessDateIso();
this.productSummaryFrom = `${this.productSummaryTo.slice(0, 7)}-01`;
}
this.loadProductSummary();
}
loadProductSummary(): void {
this.productSummaryLoading = true;
this.errorMessage = '';
let pending = 2;
const done = () => {
pending -= 1;
if (pending === 0) this.productSummaryLoading = false;
};
this.reportService.summary(this.productSummaryFrom || undefined, this.productSummaryTo || undefined).subscribe({
next: (rows) => {
this.summaryRows = rows ?? [];
done();
},
error: (err) => {
this.summaryRows = [];
this.errorMessage = this.messageOf(err, 'Could not load the product summary.');
done();
}
});
this.reportService
.outsideApproval(this.productSummaryFrom || undefined, this.productSummaryTo || undefined)
.subscribe({
next: (rows) => {
this.productOutsideRows = rows ?? [];
done();
},
error: () => {
this.productOutsideRows = [];
done();
}
});
}
closeProductSummary(): void {
this.showProductSummary = false;
}
/**
* One row per product and scheme: what posted through this report (and what is still pending
* approval or posting), plus what posted outside it — chiefly a deposit's last interest, paid the
* moment it matured rather than through a periodic report.
*/
get productSummaryRows(): {
productId: number;
productName: string;
fdType: string;
postingPeriodLabel: string;
posted: number;
pending: number;
maturityPosted: number;
total: number;
}[] {
const map = new Map<
number,
{
productId: number;
productName: string;
fdType: string;
postingPeriodLabel: string;
posted: number;
pending: number;
maturityPosted: number;
total: number;
}
>();
const get = (productId: number, productName: string, fdType: string, label: string) => {
const existing = map.get(productId);
if (existing) return existing;
const created = {
productId,
productName,
fdType,
postingPeriodLabel: label,
posted: 0,
pending: 0,
maturityPosted: 0,
total: 0
};
map.set(productId, created);
return created;
};
this.summaryRows.forEach((r) => {
const row = get(r.productId, r.productName, r.fdType, r.postingPeriodLabel);
const amount = Number(r.totalInterest || 0);
if (r.status === 'POSTED') {
row.posted += amount;
} else if (r.status === 'GENERATED' || r.status === 'APPROVED') {
row.pending += amount;
}
});
this.productOutsideRows.forEach((r) => {
const row = get(r.productId, r.productName, r.fdType, '');
row.maturityPosted += Number(r.interestAmount || 0);
});
map.forEach((row) => {
row.total = row.posted + row.pending + row.maturityPosted;
});
return [...map.values()].sort((a, b) => a.productName.localeCompare(b.productName));
}
get productSummaryGrandTotal(): { posted: number; pending: number; maturityPosted: number; total: number } {
return this.productSummaryRows.reduce(
(sum, r) => ({
posted: sum.posted + r.posted,
pending: sum.pending + r.pending,
maturityPosted: sum.maturityPosted + r.maturityPosted,
total: sum.total + r.total
}),
{ posted: 0, pending: 0, maturityPosted: 0, total: 0 }
);
}
private businessDateIso(): string {
const businessDate = this.settingsService.businessDate as any;
const parsed = businessDate ? new Date(businessDate) : new Date();
if (isNaN(parsed.getTime())) {
return new Date().toISOString().slice(0, 10);
}
const month = String(parsed.getMonth() + 1).padStart(2, '0');
const day = String(parsed.getDate()).padStart(2, '0');
return `${parsed.getFullYear()}-${month}-${day}`;
}
private messageOf(err: any, fallback: string): string {
return (
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.defaultUserMessage ?? err?.error?.message ?? fallback
);
}
}
@@ -1,280 +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 { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
export type FdInterestReportStatus = 'GENERATED' | 'APPROVED' | 'POSTED' | 'SUPERSEDED';
/** Which branches a list covers: the user's own, or (head office only) every other branch. */
export type FdReportScope = 'own' | 'others';
/** The deposit scheme, which decides where a posting of interest ends up. */
export type FdType = 'SINGLE' | 'DOUBLE' | 'SPECIAL';
export interface FdInterestReport {
id: number;
productId: number;
productName: string;
fdType: FdType;
officeId: number;
officeName: string;
postingDate: string;
status: FdInterestReportStatus;
totalInterest: number;
accountCount: number;
generationMode: 'AUTO' | 'MANUAL';
generatedAt: string;
generatedBy: string | null;
approvedAt: string | null;
approvedBy: string | null;
approvedOnBehalf: boolean;
postedAt: string | null;
failureReason: string | null;
/** The product's posting cycle in words — Monthly, Quarterly, Half-yearly, Yearly. */
postingPeriodLabel: string;
/** Whether this user may regenerate it: their own branch, or the head office for a branch that did not open. */
canAct: boolean;
blockedReason: string | null;
/** Whether this user may approve it — false for the user who generated it, who cannot be its checker. */
canApprove: boolean;
approveBlockedReason: string | null;
}
export interface FdInterestReportLine {
fdAccountId: number;
accountNo: string;
clientName: string;
/**
* The day after the deposit's last paid period ended, or its activation date for a first-ever
* posting — null for a line posted before this was tracked. Paired with interestPostingDate, it is
* the span the interest was earned over.
*/
interestPeriodFrom: string | null;
/**
* The period this interest is for, as the interest engine dated it — not the day the branch closes.
* A deposit owed two unpaid periods appears on two lines, one per date.
*/
interestPostingDate: string;
interestAmount: number;
balanceBefore: number;
balanceAfter: number;
depositAmount: number | null;
interestRate: number | null;
maturityDate: string | null;
/** SPECIAL only: the linked savings account this interest is swept to after posting. */
payoutSavingsAccountId: number | null;
payoutAccountNo: string | null;
payoutTransactionId: number | null;
fdTransactionId: number | null;
}
export interface FdInterestReportDetail extends FdInterestReport {
payableGlCode: string | null;
payableGlName: string | null;
paidGlCode: string | null;
paidGlName: string | null;
lines: FdInterestReportLine[];
}
export interface FdInterestDue {
productId: number;
productName: string;
fdType: FdType;
officeId: number;
officeName: string;
postingDate: string;
accountCount: number;
totalInterest: number;
canAct: boolean;
blockedReason: string | null;
}
export interface FdInterestSummaryRow {
id: number;
postingDate: string;
officeId: number;
officeName: string;
productId: number;
productName: string;
fdType: FdType;
accountCount: number;
totalInterest: number;
status: FdInterestReportStatus;
approvedBy: string | null;
approvedOnBehalf: boolean;
approvedAt: string | null;
postedAt: string | null;
postingPeriodLabel: string;
}
/**
* One fixed deposit interest transaction that reached the customer's account without this report's
* approval — chiefly a deposit that matured before its own posting cycle completed, whose last period
* is settled directly by the maturity job. View only, for visibility.
*/
export interface FdOutsideApprovalRow {
transactionId: number;
fdAccountId: number;
accountNo: string;
clientName: string;
officeId: number;
officeName: string;
productId: number;
productName: string;
fdType: FdType;
interestFor: string;
interestAmount: number;
/** When the posting actually reached the account — not the period it is for. */
postedAt: string;
}
export interface FdInterestPreviewRow {
fdAccountId: number;
accountNo: string;
clientName: string;
officeId: number;
officeName: string;
productId: number;
productName: string;
fdType: FdType;
/** The day after the deposit's last paid period ended, or its activation date for the first one. */
interestPeriodFrom: string | null;
/** The period this interest belongs to, as the engine dated it. */
interestPostingDate: string;
depositAmount: number | null;
interestRate: number | null;
maturityDate: string | null;
balance: number;
interest: number;
payoutAccountNo: string | null;
}
/** One product's deposits due on the day being closed, as the day-close screen shows them. */
export interface FdDayCloseDueRow {
productId: number;
productName: string;
fdType: FdType;
accountCount: number;
totalInterest: number;
reportStatus: FdInterestReportStatus | 'NONE';
}
export interface FdDayCloseCheck {
businessDate: string;
canClose: boolean;
pendingProducts: string[];
due: FdDayCloseDueRow[];
}
/**
* The fixed deposit interest posting reports, one per branch and product: generated at noon on a
* posting date, approved by a second user of the branch, posted when the branch closes the day.
*/
@Injectable({ providedIn: 'root' })
export class FdInterestPostingReportService {
private http = inject(HttpClient);
private readonly base = '/fixeddepositinterestpostingreports';
/** scope 'own' is the user's own branch; 'others' (head office only) every other branch. */
list(
postingDate?: string,
status?: string,
officeId?: number,
scope?: FdReportScope
): Observable<FdInterestReport[]> {
let params = new HttpParams();
if (scope) params = params.set('scope', scope);
if (postingDate) params = params.set('postingDate', postingDate);
if (status) params = params.set('status', status);
if (officeId) params = params.set('officeId', String(officeId));
return this.http.get<FdInterestReport[]>(this.base, { params });
}
/** Branch and product pairs with deposit interest due by the date and no report yet. */
dueWithoutReport(postingDate?: string, scope?: FdReportScope): Observable<FdInterestDue[]> {
let params = new HttpParams();
if (scope) params = params.set('scope', scope);
if (postingDate) params = params.set('postingDate', postingDate);
return this.http.get<FdInterestDue[]>(`${this.base}/due-without-report`, { params });
}
/**
* The signed-in user's branch, whether it is the head office, and whether deposits are on the
* approval path at all — with that switched off these screens are empty by design.
*/
context(): Observable<{ officeId: number; officeName: string; headOffice: boolean; approvalEnabled: boolean }> {
return this.http.get<{ officeId: number; officeName: string; headOffice: boolean; approvalEnabled: boolean }>(
`${this.base}/context`
);
}
detail(reportId: number): Observable<FdInterestReportDetail> {
return this.http.get<FdInterestReportDetail>(`${this.base}/${reportId}`);
}
regenerate(
productId: number,
officeId: number,
postingDate: string
): Observable<{ reportId: number; status: string }> {
return this.http.post<{ reportId: number; status: string }>(
this.base,
{ productId, officeId, postingDate },
{ params: new HttpParams().set('command', 'regenerate') }
);
}
approve(reportId: number): Observable<{ reportId: number; status: string }> {
return this.http.post<{ reportId: number; status: string }>(
`${this.base}/${reportId}`,
{},
{ params: new HttpParams().set('command', 'approve') }
);
}
/** What each deposit has earned and not been paid, as if posted today. View only. */
preview(officeId?: number): Observable<FdInterestPreviewRow[]> {
let params = new HttpParams();
if (officeId) params = params.set('officeId', String(officeId));
return this.http.get<FdInterestPreviewRow[]>(`${this.base}/preview`, { params });
}
/** What stops a branch closing its day, and the deposits behind it. */
dayCloseCheck(date?: string, officeId?: number): Observable<FdDayCloseCheck> {
let params = new HttpParams();
if (date) params = params.set('date', date);
if (officeId) params = params.set('officeId', String(officeId));
return this.http.get<FdDayCloseCheck>(`${this.base}/day-close-check`, { params });
}
/** The head office's branch-wise view over a date range. */
summary(fromDate?: string, toDate?: string, productId?: number): Observable<FdInterestSummaryRow[]> {
let params = new HttpParams();
if (fromDate) params = params.set('fromDate', fromDate);
if (toDate) params = params.set('toDate', toDate);
if (productId) params = params.set('productId', String(productId));
return this.http.get<FdInterestSummaryRow[]>(`${this.base}/summary`, { params });
}
/** Deposit interest posted without this report's approval — a deposit maturing mid-cycle, chiefly. */
outsideApproval(
fromDate?: string,
toDate?: string,
officeId?: number,
scope?: FdReportScope
): Observable<FdOutsideApprovalRow[]> {
let params = new HttpParams();
if (scope) params = params.set('scope', scope);
if (fromDate) params = params.set('fromDate', fromDate);
if (toDate) params = params.set('toDate', toDate);
if (officeId) params = params.set('officeId', String(officeId));
return this.http.get<FdOutsideApprovalRow[]>(`${this.base}/outside-approval`, { params });
}
}
@@ -18,9 +18,16 @@
<p class="dialog-sub">NDTL &middot; CRR/SLR Computation Base &middot; As per RBI Master Circular</p>
</div>
<div class="header-right">
<!-- The close being reported, and under it the close the ratios are charged on. Two dates,
because they are not the same day and the requirement belongs to the earlier one. -->
<div class="as-of-box">
<strong>{{ reportingDateStr }}</strong>
<span>Reporting Fortnight (45-day lag)</span>
<strong>{{ reportingDateStr || '—' }}</strong>
<span>
Fortnight close
@if (baseDateStr) {
&middot; charged on {{ baseDateStr }}
}
</span>
</div>
<a
class="rbi-circular-btn"
@@ -42,31 +49,42 @@
<div class="summary-row">
<div class="summary-card sc-demand">
<div class="sc-label">Total Demand Liabilities</div>
<div class="sc-value">&#8377;12,843<span class="cr">Cr</span></div>
<div class="sc-change up">&#8593; 3.2% vs prev fortnight</div>
<div class="sc-value">{{ crore(totalDemand) }}<span class="cr" *ngIf="totalDemand !== null">Cr</span></div>
<div class="sc-sub">Payable on demand</div>
</div>
<div class="summary-card sc-time">
<div class="sc-label">Total Time Liabilities</div>
<div class="sc-value">&#8377;64,618<span class="cr">Cr</span></div>
<div class="sc-change up">&#8593; 1.8% vs prev fortnight</div>
<div class="sc-value">{{ crore(totalTime) }}<span class="cr" *ngIf="totalTime !== null">Cr</span></div>
<div class="sc-sub">Payable on maturity</div>
</div>
<div class="summary-card sc-asset">
<div class="sc-label">Assets with Banking System</div>
<div class="sc-value">&#8377;8,240<span class="cr">Cr</span></div>
<div class="sc-value">
{{ crore(totalBankingAssets) }}<span class="cr" *ngIf="totalBankingAssets !== null">Cr</span>
</div>
<div class="sc-change dn">&#8595; Deducted from NDTL</div>
</div>
<div class="summary-card sc-ndtl">
<div class="sc-label">Net Demand &amp; Time Liabilities (NDTL)</div>
<div class="sc-value">&#8377;69,221<span class="cr">Cr</span></div>
<div class="sc-value">{{ crore(ndtl) }}<span class="cr" *ngIf="ndtl !== null">Cr</span></div>
<div class="sc-sub">After deducting banking assets</div>
</div>
<div class="summary-card sc-crr">
<div class="sc-label">CRR Required ({{ crrRate }}%)</div>
<div class="sc-value">&#8377;{{ crrRequired | number: '1.0-0' }}<span class="cr">Cr</span></div>
<div class="sc-sub">SLR Req: &#8377;{{ slrRequired | number: '1.0-0' }} Cr ({{ slrRate }}%)</div>
<!-- The rate is named from the configuration, not fixed here: a card headed "CRR Required
(3%)" while Admin holds 4% states a rate the bank is not on. -->
<div class="sc-label">CRR Required{{ crrRate === null ? '' : ' (' + crrRate + '%)' }}</div>
<div class="sc-value">{{ crore(crrRequired) }}<span class="cr" *ngIf="crrRequired !== null">Cr</span></div>
<div class="sc-sub">SLR Req: {{ crore(slrRequired) }}{{ slrRate === null ? '' : ' Cr (' + slrRate + '%)' }}</div>
</div>
</div>
<!-- Why the figures are not there, in the return's own words. A dashboard that simply shows
dashes sends an administrator looking for a fault in the dashboard. -->
<div class="unavailable-note" *ngIf="unavailable">
<fa-icon icon="exclamation-triangle"></fa-icon>
{{ unavailable }}
</div>
<!-- Tab Navigation -->
<div class="tab-nav">
<button class="tab-btn" [class.active]="activeTab === 'demand'" (click)="activeTab = 'demand'">
@@ -123,14 +141,23 @@
</span>
</td>
</tr>
} @empty {
<tr class="empty-row">
<td colspan="7">
No accounts are mapped to this head, so the return reports nothing under it. Map them in Admin &rarr;
RBI Reserve Configuration.
</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="3"><strong>Total Demand Liabilities (All 16 Items)</strong></td>
<td class="right foot-demand">12,114.75</td>
<td class="right foot-demand">12,843.50</td>
<td class="right up">+728.75</td>
<td colspan="3">
<strong>Total Demand Liabilities ({{ demandLiabilities.length }} accounts)</strong>
</td>
<td class="right foot-demand">{{ totalOf(demandLiabilities, 'openingValue') }}</td>
<td class="right foot-demand">{{ totalOf(demandLiabilities, 'currentValue') }}</td>
<td class="right">{{ totalChange(demandLiabilities) }}</td>
<td></td>
</tr>
</tfoot>
@@ -178,14 +205,23 @@
</span>
</td>
</tr>
} @empty {
<tr class="empty-row">
<td colspan="7">
No accounts are mapped to this head, so the return reports nothing under it. Map them in Admin &rarr;
RBI Reserve Configuration.
</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="3"><strong>Total Time Liabilities (All 13 Items)</strong></td>
<td class="right foot-time">62,613.10</td>
<td class="right foot-time">64,618.00</td>
<td class="right up">+2,004.90</td>
<td colspan="3">
<strong>Total Time Liabilities ({{ timeLiabilities.length }} accounts)</strong>
</td>
<td class="right foot-time">{{ totalOf(timeLiabilities, 'openingValue') }}</td>
<td class="right foot-time">{{ totalOf(timeLiabilities, 'currentValue') }}</td>
<td class="right">{{ totalChange(timeLiabilities) }}</td>
<td></td>
</tr>
</tfoot>
@@ -227,6 +263,13 @@
<span class="status-chip chip-active">{{ item.status }}</span>
</td>
</tr>
} @empty {
<tr class="empty-row">
<td colspan="7">
No accounts are mapped to this head, so the return reports nothing under it. Map them in Admin &rarr;
RBI Reserve Configuration.
</td>
</tr>
}
</tbody>
<tfoot>
@@ -234,9 +277,9 @@
<td colspan="3">
<strong>Total Assets with Banking System</strong> <span class="deduct-tag">( Deducted from NDTL )</span>
</td>
<td class="right foot-asset">7,600.70</td>
<td class="right foot-asset">8,240.20</td>
<td class="right up">+639.50</td>
<td class="right foot-asset">{{ totalOf(bankingAssets, 'openingValue') }}</td>
<td class="right foot-asset">{{ totalOf(bankingAssets, 'currentValue') }}</td>
<td class="right">{{ totalChange(bankingAssets) }}</td>
<td></td>
</tr>
</tfoot>
@@ -284,14 +327,21 @@
<span class="status-chip chip-active">{{ item.status }}</span>
</td>
</tr>
} @empty {
<tr class="empty-row">
<td colspan="7">
No accounts are mapped to this head, so the return reports nothing under it. Map them in Admin &rarr;
RBI Reserve Configuration.
</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="4"><strong>Total Liquid Assets (SLR Eligible)</strong></td>
<td class="right foot-slr">12,511.70</td>
<td class="right foot-slr">13,238.80</td>
<td class="right up">+727.10</td>
<td class="right foot-slr">{{ totalOf(liquidAssets, 'openingValue') }}</td>
<td class="right foot-slr">{{ totalOf(liquidAssets, 'currentValue') }}</td>
<td class="right">{{ totalChange(liquidAssets) }}</td>
<td></td>
</tr>
</tfoot>
@@ -842,3 +842,30 @@ $slr-b: #bae6fd;
padding: 8px 16px;
}
}
/* Why the figures are missing, in the return's own words — an administrator reading only dashes
goes looking for a fault in this screen rather than in the configuration that actually caused it. */
.unavailable-note {
margin: 0 0 16px;
padding: 10px 14px;
background: #fffbeb;
color: #92400e;
border-radius: 8px;
font-size: 12.5px;
line-height: 1.6;
fa-icon {
margin-right: 8px;
}
}
/* A head with nothing mapped. Said in the table rather than left as an empty body, and it names the
screen where the mapping is made — an empty table otherwise reads as a fault in this one. */
.empty-row td {
padding: 22px 16px;
text-align: center;
color: #6b7280;
font-size: 12.5px;
line-height: 1.6;
background: #fafafa;
}
@@ -6,13 +6,30 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, inject } from '@angular/core';
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule, DecimalPipe } from '@angular/common';
import { MatDialogRef, MatDialogModule } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatTabsModule } from '@angular/material/tabs';
import { MatTooltipModule } from '@angular/material/tooltip';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { RbiCircularsService } from '../../../rbi-circulars/rbi-circulars.service';
/** One account under a head, at the base date and the reporting date. */
interface CrrSlrRow {
sl: string;
name: string;
sub: string;
code: string;
opening: string;
current: string;
/** The figures themselves, kept so totals add from values rather than from formatted text. */
openingValue: number | null;
currentValue: number | null;
change: string;
changeDir: 'up' | 'dn' | 'flat';
status: string;
}
@Component({
selector: 'mifosx-crr-slr-dialog',
@@ -29,477 +46,228 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
FaIconComponent
]
})
export class CrrSlrDialogComponent {
export class CrrSlrDialogComponent implements OnInit {
private dialogRef = inject(MatDialogRef<CrrSlrDialogComponent>);
private rbi = inject(RbiCircularsService);
activeTab: 'demand' | 'time' | 'assets' | 'slr' = 'demand';
/** Reporting fortnight date — 45 days before current date */
today = new Date();
reportingDate: Date;
reportingDateStr: string;
/** The date the figures were struck at, as the return itself reports it. */
reportingDateStr = '';
/** NDTL & Reserve computations */
readonly totalDemand = 12843.5;
readonly totalTime = 64618.0;
readonly totalBankingAssets = 8240.2;
readonly ndtl = 69221.3; // Demand + Time - Banking Assets
readonly crrRate = 3; // Current RBI CRR rate (%)
readonly slrRate = 18; // Current RBI SLR rate (%)
readonly crrRequired = 69221.3 * 0.03; // ₹2,076.64 Cr
readonly slrRequired = 69221.3 * 0.18; // ₹12,459.83 Cr
readonly totalLiquidAssets = 13238.8;
readonly slrActualPercent = +((13238.8 / 69221.3) * 100).toFixed(2); // 19.12%
readonly slrSurplus = +(13238.8 - 69221.3 * 0.18).toFixed(2); // 778.97
readonly totalReserve = +(69221.3 * 0.03 + 69221.3 * 0.18).toFixed(2); // 14,536.47
/**
* The close the ratios are charged on — the second preceding fortnight, not the one reported.
*
* Named on screen because the gap between the two is the whole of how the rules work: a bank
* cannot hold a reserve against liabilities it does not yet know, so the requirement is fixed on
* a position already struck and reported. A screen showing one date invites a reader to think the
* requirement was worked out on the day it is shown against.
*/
baseDateStr = '';
constructor() {
// CRR/SLR uses data from 45 days ago
const lagDays = 45;
this.reportingDate = new Date(this.today);
this.reportingDate.setDate(this.reportingDate.getDate() - lagDays);
this.reportingDateStr = this.reportingDate.toLocaleDateString('en-IN', {
day: '2-digit',
month: 'short',
year: 'numeric'
loading = true;
/** Set when the return could not be computed — and why, in the return's own words. */
unavailable = '';
/**
* The NDTL computation, read from the return rather than stated here.
*
* These were fixed numbers until now — a demonstration screen that showed ₹69,221 Cr whatever the
* ledger held. The figures are the bank's own regulatory position, so a plausible-looking number
* is worse than no number: it reads as a reported figure and cannot be told apart from one.
*
* Null until the return answers, and left null where it cannot compute. The template shows a dash
* for a null, which says "not reported" where a zero would say "reported as nil".
*/
totalDemand: number | null = null;
totalTime: number | null = null;
totalBankingAssets: number | null = null;
ndtl: number | null = null;
crrRate: number | null = null;
slrRate: number | null = null;
crrRequired: number | null = null;
slrRequired: number | null = null;
totalLiquidAssets: number | null = null;
slrActualPercent: number | null = null;
slrSurplus: number | null = null;
totalReserve: number | null = null;
/**
* A figure for display, or a dash where the return did not carry one.
*
* A dash and a zero say different things — "not reported" against "reported as nil" — and on a
* regulatory screen the difference decides whether anyone goes looking for the missing mapping.
*/
crore(value: number | null): string {
if (value === null) {
return '—';
}
return `₹${value.toLocaleString('en-IN', { maximumFractionDigits: 0 })}`;
}
ngOnInit(): void {
// The return is reported in rupees; this screen reads in crore, as a dashboard tile does.
const toCrore = (v: number | null | undefined): number | null =>
v === null || v === undefined ? null : +(v / 1e7).toFixed(2);
// The statutory Form 1, not the working report. Form 1 is filed twice a month — at the 15th and
// at the month end — and this screen should show the close the bank last filed, not a position
// struck 45 days back by a lag rule that belongs to the other report. Those two disagree by
// weeks, and the 45-day one lands mid-fortnight on a date the bank never reported.
this.rbi.getForm1Statutory().subscribe({
next: (data: any) => {
this.loading = false;
// The later of the two closes the return reports — the one the bank most recently filed.
const columns: string[] = data?.columns ?? [];
const latest = columns.length - 1;
this.reportingDateStr = columns[latest] ?? '';
this.baseDateStr = data?.baseColumns?.[latest] ?? '';
this.crrRate = data?.crrPercentage ?? null;
this.slrRate = data?.slrPercentage ?? null;
if (!columns.length) {
this.unavailable =
data?.unmetRequirements?.[0] ?? 'Form 1 could not be computed. Check the CRR/SLR configuration in Admin.';
return;
}
// The return computes but says something would make it wrong to file — a rate never set, a
// base date the ledger cannot answer. Shown rather than swallowed: the reason is the thing
// an administrator needs in order to fix it.
this.unavailable = data?.unmetRequirements?.[0] ?? '';
// Part A's own lines, read by the Roman numeral each carries. Read from the return rather
// than recomputed, so this screen and the filed form cannot come to disagree.
const lines: any[] = data?.partA?.lines ?? [];
const at = (startsWith: string): number | null => {
const line = lines.find((l: any) => String(l?.label ?? '').startsWith(startsWith));
return line?.values?.[latest] ?? null;
};
this.totalDemand = toCrore(at('(A) Demand liabilities'));
this.totalTime = toCrore(at('(B) Time liabilities'));
this.totalBankingAssets = toCrore(at('TOTAL OF III'));
// IV(b) is the base the ratios are actually charged on — the figure behind the requirement,
// not the bank's liabilities on the day being reported.
this.ndtl = toCrore(at('IV. b)'));
this.crrRequired = toCrore(data?.partB?.required?.[latest]);
this.slrRequired = toCrore(data?.partC?.required?.[latest]);
this.totalLiquidAssets = toCrore(data?.partC?.maintained?.[latest]);
if (this.slrRequired !== null && this.totalLiquidAssets !== null) {
this.slrSurplus = +(this.totalLiquidAssets - this.slrRequired).toFixed(2);
}
if (this.ndtl) {
this.slrActualPercent =
this.totalLiquidAssets === null ? null : +((this.totalLiquidAssets / this.ndtl) * 100).toFixed(2);
}
if (this.crrRequired !== null && this.slrRequired !== null) {
this.totalReserve = +(this.crrRequired + this.slrRequired).toFixed(2);
}
this.fillTabs(lines, latest);
},
error: () => {
this.loading = false;
this.unavailable = 'The CRR/SLR position could not be read. The return service did not respond.';
}
});
}
demandLiabilities = [
{
sl: '01',
name: 'Current Account Deposits',
sub: 'Corporate & individual CA balances',
code: '2001-CA',
opening: '3,842.50',
current: '4,120.75',
change: '+278.25',
changeDir: 'up',
status: 'Active'
},
{
sl: '02',
name: 'Savings Bank Deposits (Withdrawable)',
sub: 'Demand portion — 10% of SB balance',
code: '2002-SB',
opening: '1,240.00',
current: '1,318.40',
change: '+78.40',
changeDir: 'up',
status: 'Active'
},
{
sl: '03',
name: 'Demand Drafts Payable',
sub: 'Issued DDs outstanding for payment',
code: '2003-DD',
opening: '284.20',
current: '311.60',
change: '+27.40',
changeDir: 'up',
status: 'Active'
},
{
sl: '04',
name: "Banker's Cheques / Pay Orders",
sub: 'Issued pay orders pending encashment',
code: '2004-PO',
opening: '198.75',
current: '176.30',
change: '-22.45',
changeDir: 'dn',
status: 'Active'
},
{
sl: '05',
name: 'Credit Balance in Cash Credit Accounts',
sub: 'CC accounts in credit position',
code: '2005-CC',
opening: '542.10',
current: '618.90',
change: '+76.80',
changeDir: 'up',
status: 'Active'
},
{
sl: '06',
name: 'Overdue Fixed Deposits',
sub: 'FDs past maturity — treated as demand',
code: '2006-OFD',
opening: '89.40',
current: '112.85',
change: '+23.45',
changeDir: 'dn',
status: 'Overdue'
},
{
sl: '07',
name: 'Interest Payable on Demand Deposits',
sub: 'Accrued interest on CA/SB',
code: '2007-INT',
opening: '34.20',
current: '38.55',
change: '+4.35',
changeDir: 'up',
status: 'Active'
},
{
sl: '08',
name: 'Margins held against Letters of Credit',
sub: 'Withdrawable LC margin deposits',
code: '2008-LCM',
opening: '412.60',
current: '388.20',
change: '-24.40',
changeDir: 'dn',
status: 'Active'
},
{
sl: '09',
name: 'Margins held against Bank Guarantees',
sub: 'Withdrawable BG margin deposits',
code: '2009-BGM',
opening: '318.40',
current: '342.10',
change: '+23.70',
changeDir: 'up',
status: 'Active'
},
{
sl: '10',
name: 'Security Deposits (Withdrawable)',
sub: 'EMD / SD refundable on demand',
code: '2010-SD',
opening: '124.80',
current: '118.60',
change: '-6.20',
changeDir: 'dn',
status: 'Active'
},
{
sl: '11',
name: 'Unclaimed Deposits (Demand Portion)',
sub: 'Balances unclaimed >10 years',
code: '2011-UCD',
opening: '56.30',
current: '59.40',
change: '+3.10',
changeDir: 'dn',
status: 'Unclaimed'
},
{
sl: '12',
name: 'Inter-Bank Demand Deposits',
sub: 'Demand deposits from other banks',
code: '2012-IBD',
opening: '2,840.00',
current: '3,120.50',
change: '+280.50',
changeDir: 'up',
status: 'Active'
},
{
sl: '13',
name: 'Clearing Adjustments / Balances',
sub: 'Net clearing settlement balances',
code: '2013-CLR',
opening: '186.40',
current: '210.80',
change: '+24.40',
changeDir: 'up',
status: 'Active'
},
{
sl: '14',
name: 'Outstanding Payment Instruments',
sub: 'Cheques issued but not yet presented',
code: '2014-OPI',
opening: '142.60',
current: '158.90',
change: '+16.30',
changeDir: 'up',
status: 'Active'
},
{
sl: '15',
name: 'Demand Liabilities towards Other Banks',
sub: 'Nostro / Vostro demand obligations',
code: '2015-DLB',
opening: '984.20',
current: '1,048.60',
change: '+64.40',
changeDir: 'up',
status: 'Active'
},
{
sl: '16',
name: 'Deposits Repayable on Demand',
sub: 'Miscellaneous on-demand deposits',
code: '2016-DRD',
opening: '618.30',
current: '698.05',
change: '+79.75',
changeDir: 'up',
status: 'Active'
/**
* The four tabs, filled from the return's own lines.
*
* These were four fixed lists until now — 435 lines of invented accounts with invented balances,
* which a reader had no way to tell apart from the bank's real position. They are now built from
* the accounts the return actually read, so a tab is empty exactly when the bank has nothing
* mapped under that head, and says so.
*/
demandLiabilities: CrrSlrRow[] = [];
timeLiabilities: CrrSlrRow[] = [];
bankingAssets: CrrSlrRow[] = [];
liquidAssets: CrrSlrRow[] = [];
/**
* Turns Part A's lines into the rows a tab shows.
*
* The form reports each head at both of the closes it carries, so the earlier column is the
* opening and the later one the current — the same pair these tables have always shown, now taken
* from the form rather than invented. Headings and totals are skipped: a heading carries no
* figure, and a total would be added a second time by the footer below.
*/
private rowsFrom(lines: any[], latest: number, match: (label: string) => boolean): CrrSlrRow[] {
const rows: CrrSlrRow[] = [];
for (const line of lines ?? []) {
const label = String(line?.label ?? '');
if (line?.heading || line?.total || !line?.values || !match(label)) {
continue;
}
const opening = latest > 0 ? (line.values[latest - 1] ?? null) : null;
const current = line.values[latest] ?? null;
const change = opening === null || current === null ? null : current - opening;
rows.push({
sl: String(rows.length + 1).padStart(2, '0'),
// The form's own wording, with its numbering trimmed off the front — the table has a column
// of its own for that.
name: label.replace(/^\s*\(?[a-zA-Z0-9]+\)?[.)]?\s*/, '').trim() || label.trim(),
sub: line.configKey ? String(line.configKey).replace(/_/g, ' ') : 'Derived from the form',
code: line.configKey ?? '—',
opening: this.crore(opening),
current: this.crore(current),
openingValue: opening,
currentValue: current,
change: change === null ? '—' : `${change >= 0 ? '+' : ''}${this.crore(change).replace('₹', '')}`,
changeDir: change === null ? 'flat' : change >= 0 ? 'up' : 'dn',
// A head with no GL mapped reports nothing, and the form says so rather than reporting nil.
status: line.configured === false ? 'Not mapped' : 'Active'
});
}
];
return rows;
}
timeLiabilities = [
{
sl: '01',
name: 'Fixed Deposits (FD)',
sub: '7 days to 10 years · 6.5–7.2%',
code: '3001-FD',
opening: '28,420.50',
current: '29,184.30',
change: '+763.80',
changeDir: 'up',
status: 'Active'
},
{
sl: '02',
name: 'Recurring Deposits (RD)',
sub: 'Monthly instalment · 6.0–6.8%',
code: '3002-RD',
opening: '4,218.60',
current: '4,412.80',
change: '+194.20',
changeDir: 'up',
status: 'Active'
},
{
sl: '03',
name: 'Time Liability Portion of Savings',
sub: '90% of SB balance treated as time',
code: '3003-SBT',
opening: '10,840.20',
current: '11,204.60',
change: '+364.40',
changeDir: 'up',
status: 'Active'
},
{
sl: '04',
name: 'Reinvestment Deposits',
sub: 'Cumulative interest plans · 7.0%',
code: '3004-RID',
opening: '1,842.40',
current: '1,918.70',
change: '+76.30',
changeDir: 'up',
status: 'Active'
},
{
sl: '05',
name: 'Staff Security Deposits (Fixed Period)',
sub: 'Employee security deposits — locked',
code: '3005-SSD',
opening: '84.60',
current: '86.40',
change: '+1.80',
changeDir: 'up',
status: 'Locked'
},
{
sl: '06',
name: 'Margin Money for Guarantees / LC',
sub: 'LC/BG margins — fixed lock-in period',
code: '3006-MGM',
opening: '620.80',
current: '644.20',
change: '+23.40',
changeDir: 'up',
status: 'Locked'
},
{
sl: '07',
name: 'Certificates of Deposit (CD)',
sub: 'Issued to corporates / FIs · 7.35%',
code: '3007-CD',
opening: '3,840.00',
current: '3,920.50',
change: '+80.50',
changeDir: 'up',
status: 'Active'
},
{
sl: '08',
name: 'Interest Accrued on Time Deposits',
sub: 'Accrued but unpaid interest on FD/RD',
code: '3008-IAT',
opening: '842.60',
current: '918.40',
change: '+75.80',
changeDir: 'up',
status: 'Active'
},
{
sl: '09',
name: 'Long-term Deposits',
sub: 'Tenor >5 years · 7.5%',
code: '3009-LTD',
opening: '5,620.40',
current: '5,818.60',
change: '+198.20',
changeDir: 'up',
status: 'Active'
},
{
sl: '10',
name: 'Tax Saving Deposits',
sub: 'Sec 80C FDs — 5-year lock-in · 6.8%',
code: '3010-TSD',
opening: '1,240.80',
current: '1,318.40',
change: '+77.60',
changeDir: 'up',
status: 'Locked'
},
{
sl: '11',
name: 'Foreign Currency Deposits',
sub: 'FCNR(B), RFC — USD/EUR/GBP',
code: '3011-FCD',
opening: '2,184.60',
current: '2,348.20',
change: '+163.60',
changeDir: 'up',
status: 'Active'
},
{
sl: '12',
name: 'Time Liabilities to Other Banks',
sub: 'Inter-bank term deposits — call money',
code: '3012-TLB',
opening: '1,840.20',
current: '1,924.40',
change: '+84.20',
changeDir: 'up',
status: 'Active'
},
{
sl: '13',
name: 'Term Borrowings',
sub: 'Bonds, debentures, refinance · 8.1%',
code: '3013-TBR',
opening: '1,018.40',
current: '1,118.50',
change: '+100.10',
changeDir: 'up',
status: 'Active'
}
];
/**
* A tab's column total, added from the rows on screen.
*
* Added from the rows rather than stated separately, so the foot of a table always agrees with
* what is above it. The footers used to be fixed text and agreed with nothing.
*
* Summed from each row's own figure, not from the text it displays: a total parsed back out of
* formatted strings would quietly lose the paise the display rounds away.
*/
totalOf(rows: CrrSlrRow[], column: 'openingValue' | 'currentValue'): string {
const values = rows.map((r) => r[column]).filter((v): v is number => v !== null);
return values.length ? this.crore(values.reduce((a, b) => a + b, 0)) : '—';
}
bankingAssets = [
{
sl: '01',
name: 'Balances with Other Banks',
sub: 'Nostro / Vostro balances — SBI, PNB, BOB',
code: '4001-BWB',
opening: '3,840.20',
current: '4,112.40',
change: '+272.20',
changeDir: 'up',
status: 'Active'
},
{
sl: '02',
name: 'Money at Call with Banks',
sub: 'Overnight & short-notice call money',
code: '4002-MCB',
opening: '2,180.50',
current: '2,418.60',
change: '+238.10',
changeDir: 'up',
status: 'Active'
},
{
sl: '03',
name: 'Loans to Banks (Inter-Bank Lending)',
sub: 'Term loans & credit lines to SCBs',
code: '4003-LTB',
opening: '1,580.00',
current: '1,709.20',
change: '+129.20',
changeDir: 'up',
status: 'Active'
/** The movement across a tab, between the two closes the form reports. */
totalChange(rows: CrrSlrRow[]): string {
const opening = rows.map((r) => r.openingValue).filter((v): v is number => v !== null);
const current = rows.map((r) => r.currentValue).filter((v): v is number => v !== null);
if (!opening.length || !current.length) {
return '—';
}
];
const change = current.reduce((a, b) => a + b, 0) - opening.reduce((a, b) => a + b, 0);
return `${change >= 0 ? '+' : ''}${this.crore(change).replace('₹', '')}`;
}
liquidAssets = [
{
sl: '01',
name: 'Central Government Securities',
sub: 'G-Secs held in HTM, AFS & HFT portfolios',
code: '5001-CGS',
opening: '6,840.50',
current: '7,218.40',
change: '+377.90',
changeDir: 'up',
status: 'Active',
category: 'G-Sec'
},
{
sl: '02',
name: 'State Development Loans (SDLs)',
sub: 'State govt bonds — various maturities',
code: '5002-SDL',
opening: '2,420.30',
current: '2,584.60',
change: '+164.30',
changeDir: 'up',
status: 'Active',
category: 'SDL'
},
{
sl: '03',
name: 'Treasury Bills (91/182/364 day)',
sub: 'Short-term govt securities — discounted',
code: '5003-TB',
opening: '1,840.20',
current: '1,962.80',
change: '+122.60',
changeDir: 'up',
status: 'Active',
category: 'T-Bill'
},
{
sl: '04',
name: 'Gold (valued at current market)',
sub: 'Gold reserves held per RBI valuation norms',
code: '5004-GLD',
opening: '680.40',
current: '724.60',
change: '+44.20',
changeDir: 'up',
status: 'Active',
category: 'Gold'
},
{
sl: '05',
name: 'Special Deposit Scheme with RBI',
sub: 'Deposits placed under SDS — SLR eligible',
code: '5005-SDS',
opening: '420.00',
current: '420.00',
change: '0.00',
changeDir: 'up',
status: 'Active',
category: 'RBI Dep'
},
{
sl: '06',
name: 'NABARD / SIDBI / NHB Bonds',
sub: 'PSE bonds approved as SLR-eligible',
code: '5006-PSE',
opening: '310.80',
current: '328.40',
change: '+17.60',
changeDir: 'up',
status: 'Active',
category: 'PSE Bond'
}
];
/** Fills the four tabs from one reading of the form. */
private fillTabs(lines: any[], latest: number): void {
const has = (label: string, ...words: string[]): boolean => {
const l = label.toLowerCase();
return words.some((w) => l.includes(w));
};
// Matched on how the form labels each head, not on a fixed list of account names: a bank that
// maps a new account to a head gets it on the right tab without this screen being changed.
this.demandLiabilities = this.rowsFrom(lines, latest, (l) => has(l, 'demand liabilit'));
this.timeLiabilities = this.rowsFrom(lines, latest, (l) => has(l, 'time liabilit') && !has(l, 'demand and time'));
// III — what the banking system owes this bank, which nets off the liability base.
this.bankingAssets = this.rowsFrom(lines, latest, (l) =>
has(l, 'current account balances with sbi', 'balances in all accounts', 'money at call', 'advances and any other')
);
// V, VI and VII — the cash, balances and securities the two ratios are met in.
this.liquidAssets = this.rowsFrom(lines, latest, (l) =>
has(l, 'cash on hand', 'reserve bank of india', 'co-operative bank', 'government securities')
);
}
close(): void {
this.dialogRef.close();
@@ -28,17 +28,34 @@
<fa-icon icon="external-link-alt"></fa-icon> View
</button>
</div>
<!-- Read from the return, not stated here. Both the rates and the amounts were fixed text
until now, so the card reported ₹2,077 Cr at 3% whatever the ledger held and whatever
rate Admin was on. -->
<div class="card-metrics">
<div class="metric-row">
<span class="metric-label">CRR Required (3%)</span>
<span class="metric-value">&#8377;2,077 Cr</span>
<span class="metric-label"
>CRR Required{{ crrSlr.crrRate === null ? '' : ' (' + crrSlr.crrRate + '%)' }}</span
>
<span class="metric-value">{{ crrSlr.crrRequired }}</span>
</div>
<div class="metric-row">
<span class="metric-label">SLR Required (18%)</span>
<span class="metric-value">&#8377;12,460 Cr</span>
<span class="metric-label"
>SLR Required{{ crrSlr.slrRate === null ? '' : ' (' + crrSlr.slrRate + '%)' }}</span
>
<span class="metric-value">{{ crrSlr.slrRequired }}</span>
</div>
</div>
<div class="mini-sub"><span class="tag-success">Within Limits</span></div>
<!-- "Within Limits" is a compliance statement, so it is made only where the return actually
says so. Where it cannot compute, the card says that instead of passing the bank. -->
<div class="mini-sub">
@if (crrSlr.status === 'ok') {
<span class="tag-success">Within Limits</span>
} @else if (crrSlr.status === 'short') {
<span class="tag-danger">Shortfall</span>
} @else {
<span class="tag-muted">Not computed</span>
}
</div>
</div>
<!-- CRAR -->
@@ -444,6 +444,17 @@ $purple: #7c3aed;
font-weight: 600;
}
/* A figure that could not be computed. Deliberately not green: a tile that cannot read the ledger
has not found the bank compliant, and a success tag on an unknown position is a false all-clear. */
.tag-muted {
background: #f3f4f6;
color: #4b5563;
padding: 2px 6px;
border-radius: 10px;
font-size: 10px;
font-weight: 600;
}
/* ─── Compact Detail Cards ─── */
.dash-card.compact {
padding: 12px 16px;
@@ -19,6 +19,7 @@ import { MatTooltipModule } from '@angular/material/tooltip';
import { AsyncPipe, DatePipe } from '@angular/common';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { RbiCircularsService } from '../../rbi-circulars/rbi-circulars.service';
/**
* Dashboard component.
*/
@@ -40,9 +41,29 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
export class DashboardComponent implements OnInit {
private router = inject(Router);
private dialog = inject(MatDialog);
private rbi = inject(RbiCircularsService);
today = new Date();
/**
* The CRR/SLR tile, read from the return.
*
* The amounts and the rates were both fixed text on the template until now, so the tile reported
* ₹2,077 Cr at 3% whatever the ledger held and whatever rate Admin was on. A dashboard figure is
* read as the bank's position, and one that cannot move is worse than none: nothing on the tile
* says it is a placeholder.
*
* Dashes until the return answers. 'status' drives the compliance tag, which is only claimed
* where the return states it.
*/
crrSlr: {
crrRate: number | null;
slrRate: number | null;
crrRequired: string;
slrRequired: string;
status: 'ok' | 'short' | 'unknown';
} = { crrRate: null, slrRate: null, crrRequired: '—', slrRequired: '—', status: 'unknown' };
/** Array of all user activities */
userActivity: string[];
/** Array of most recent user activities */
@@ -67,6 +88,45 @@ export class DashboardComponent implements OnInit {
this.recentActivities = this.getRecentActivities();
this.frequentActivities = this.getFrequentActivities();
this.setFilteredActivities();
this.loadCrrSlr();
}
/** Fills the CRR/SLR tile from the return, leaving it dashed where the return cannot compute. */
private loadCrrSlr(): void {
const crore = (v: number | null | undefined): string =>
v === null || v === undefined ? '—' : `₹${Math.round(v / 1e7).toLocaleString('en-IN')} Cr`;
// The statutory Form 1, which is the return this bank files — twice a month, at the 15th and at
// the month end. The working report's 45-day lag lands mid-fortnight, on a date the bank never
// reported, so a tile built on it shows a position that appears on no filing.
this.rbi.getForm1Statutory().subscribe({
next: (data: any) => {
const latest = (data?.columns?.length ?? 0) - 1;
if (latest < 0) {
return;
}
this.crrSlr.crrRate = data?.crrPercentage ?? null;
this.crrSlr.slrRate = data?.slrPercentage ?? null;
this.crrSlr.crrRequired = crore(data?.partB?.required?.[latest]);
this.crrSlr.slrRequired = crore(data?.partC?.required?.[latest]);
// Compliant only where BOTH ratios are met: the tile carries one tag for the pair, and a
// bank short on SLR is not within limits because its cash reserve happens to be ample.
const crrHeld = data?.partB?.maintained?.[latest];
const crrNeed = data?.partB?.required?.[latest];
const slrHeld = data?.partC?.maintained?.[latest];
const slrNeed = data?.partC?.required?.[latest];
if ([
crrHeld,
crrNeed,
slrHeld,
slrNeed
].every((v) => v !== null && v !== undefined)) {
this.crrSlr.status = crrHeld >= crrNeed && slrHeld >= slrNeed ? 'ok' : 'short';
}
},
error: () => undefined
});
}
/**
@@ -0,0 +1,43 @@
<!--
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/.
-->
@if (product) {
<div class="lptc">
<div class="lptc__head">
<strong>{{ product.name }}</strong>
<span class="lptc__chip">{{ category }}</span>
<span class="lptc__chip lptc__chip--type">{{ type }}</span>
</div>
<div class="lptc__grid">
<div>
<span class="lptc__label">Amount</span>
<span>
@if (product.minPrincipal != null || product.maxPrincipal != null) {
{{ product.minPrincipal | number: '1.0-0' }} – {{ product.maxPrincipal | number: '1.0-0' }}
} @else {
—
}
</span>
</div>
<div>
<span class="lptc__label">Tenure (repayments)</span>
<span>
@if (product.minNumberOfRepayments != null || product.maxNumberOfRepayments != null) {
{{ product.minNumberOfRepayments }} – {{ product.maxNumberOfRepayments }}
} @else {
—
}
</span>
</div>
<div>
<span class="lptc__label">Short name</span>
<span>{{ product.shortName || '—' }}</span>
</div>
</div>
</div>
}
@@ -0,0 +1,52 @@
/**
* 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/.
*/
.lptc {
margin: 4px 0 16px;
padding: 12px 16px;
border: 1px solid #bae6fd;
border-radius: 8px;
background: #f0f9ff;
&__head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
&__chip {
padding: 1px 10px;
border-radius: 999px;
background: #e0f2fe;
color: #075985;
font-size: 12px;
font-weight: 600;
&--type {
background: #ede9fe;
color: #5b21b6;
}
}
&__grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px 16px;
font-size: 13px;
color: #1e293b;
}
&__label {
display: block;
font-size: 11px;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #64748b;
}
}
@@ -0,0 +1,37 @@
/**
* 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, Input } from '@angular/core';
import { DecimalPipe } from '@angular/common';
import { productCategoryLabel, productTypeLabel } from 'app/products/loan-products/loan-product-types';
/**
* The product picked on the loan account form, summarised with its category, type and the main limits, so the
* officer can see what the account will be opened under before going on.
*/
@Component({
selector: 'mifosx-loan-product-template-card',
templateUrl: './loan-product-template-card.component.html',
styleUrls: ['./loan-product-template-card.component.scss'],
imports: [DecimalPipe]
})
export class LoanProductTemplateCardComponent {
/** A product from the catalogue (the list shape), or undefined when none is picked. */
@Input() product: any;
get category(): string {
return productCategoryLabel(this.product?.advanceExtensions?.productTypeGroup);
}
get type(): string {
return productTypeLabel(
this.product?.advanceExtensions?.productTypeGroup,
this.product?.advanceExtensions?.productTypeSubGroup
);
}
}
@@ -32,6 +32,9 @@ import { MatCheckbox } from '@angular/material/checkbox';
import { MatStepperPrevious, MatStepperNext } from '@angular/material/stepper';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { LoanProductCatalogService } from '../../services/loan-product-catalog.service';
import { LoanProductTemplateCardComponent } from '../loan-product-template-card/loan-product-template-card.component';
import { PRODUCT_SUB_TYPES, PRODUCT_TYPE_GROUPS } from 'app/products/loan-products/loan-product-types';
/**
* Loans Account Details Step
@@ -49,7 +52,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
MatStepperPrevious,
FaIconComponent,
MatStepperNext,
AsyncPipe
AsyncPipe,
LoanProductTemplateCardComponent
]
})
export class LoansAccountDetailsStepComponent implements OnInit, OnDestroy {
@@ -59,6 +63,7 @@ export class LoansAccountDetailsStepComponent implements OnInit, OnDestroy {
private translateService = inject(TranslateService);
private settingsService = inject(SettingsService);
private commons = inject(Commons);
private catalog = inject(LoanProductCatalogService);
//** Defining PlaceHolders for the search bar */
placeHolderLabel = '';
@@ -88,6 +93,27 @@ export class LoansAccountDetailsStepComponent implements OnInit, OnDestroy {
loanId: any = null;
/** Category and product type narrow the product picker to the active products of that kind. */
readonly categories = PRODUCT_TYPE_GROUPS;
categoryFilter = '';
typeFilter = '';
/** Ids of the products active today; null until read, or if the read failed (then nothing is hidden). */
private activeProductIds: Set<number> | null = null;
/** Every product from the template, before any filter. */
private allProducts: any[] = [];
/** Types offered for the chosen category; every type when no category is chosen. */
get typeOptions() {
return this.categoryFilter
? (PRODUCT_SUB_TYPES[this.categoryFilter] ?? [])
: Object.values(PRODUCT_SUB_TYPES).flat();
}
/** The product picked in the form, with its category and type, for the template card. */
get selectedProduct(): any {
return this.catalog.get(this.loansAccountDetailsForm?.get('productId')?.value);
}
loanProductSelected = false;
/** Currency data. */
protected productData: ReplaySubject<string[]> = new ReplaySubject<string[]>(1);
@@ -115,7 +141,8 @@ export class LoansAccountDetailsStepComponent implements OnInit, OnDestroy {
this.maxDate = this.settingsService.maxFutureDate;
this.buildDependencies();
if (this.loansAccountTemplate) {
this.productList = this.loansAccountTemplate.productOptions.sort(this.commons.dynamicSort('name'));
this.allProducts = this.loansAccountTemplate.productOptions.sort(this.commons.dynamicSort('name'));
this.productList = this.allProducts;
if (this.loansAccountTemplate.loanProductId) {
this.loansAccountDetailsForm.patchValue({
productId: this.loansAccountTemplate.loanProductId,
@@ -136,6 +163,44 @@ export class LoansAccountDetailsStepComponent implements OnInit, OnDestroy {
this.searchItem();
});
this.productData.next(this.productList.slice());
this.loadActiveProducts();
}
/**
* A new account can only be opened on a product that is active today, and the picker can be narrowed by category
* and type. Editing an existing account keeps every product, since its own product may have closed since.
*/
private loadActiveProducts(): void {
if (this.loanId) {
return;
}
this.catalog.activeProducts().subscribe((products) => {
this.activeProductIds = products.length > 0 ? new Set(products.map((product) => Number(product.id))) : null;
this.applyProductFilters();
});
}
onCategoryChange(): void {
// A type belongs to one category, so a type from another category cannot stay selected.
if (this.typeFilter && !this.typeOptions.some((type) => type.id === this.typeFilter)) {
this.typeFilter = '';
}
this.applyProductFilters();
}
/** Rebuilds the product list from the template: active only, then category and type, then the search text. */
applyProductFilters(): void {
this.productList = this.allProducts.filter((product: any) => {
if (this.activeProductIds && !this.activeProductIds.has(Number(product.id))) {
return false;
}
const ext = this.catalog.classification(product.id);
if (this.categoryFilter && ext['productTypeGroup'] !== this.categoryFilter) {
return false;
}
return !this.typeFilter || ext['productTypeSubGroup'] === this.typeFilter;
});
this.searchItem();
}
ngOnDestroy(): void {
@@ -0,0 +1,48 @@
/**
* 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 { Observable, of } from 'rxjs';
import { catchError, map, tap } from 'rxjs/operators';
import { ProductsService } from 'app/products/products.service';
/**
* Read-only view of the loan product catalogue for the loan account screens: which products are active today and
* what category and type each one is. Reads the normal product list once; the category and type of each product
* come from the classification the list carries (`advanceExtensions`).
*/
@Injectable({
providedIn: 'root'
})
export class LoanProductCatalogService {
private productsService = inject(ProductsService);
private byId = new Map<number, any>();
/**
* The products that are active today. Also remembers every product it saw, so `get` and `classification` can
* answer for them afterwards. An empty list on failure, so the picker never ends up with nothing to choose.
*/
activeProducts(): Observable<any[]> {
return this.productsService.getLoanProducts().pipe(
tap((products: any[]) => (products ?? []).forEach((product) => this.byId.set(Number(product.id), product))),
map((products: any[]) => (products ?? []).filter((product) => product?.status === 'loanProduct.active')),
catchError(() => of([]))
);
}
/** A product seen by `activeProducts`, or undefined before it ran or for an unknown id. */
get(productId: any): any {
return this.byId.get(Number(productId));
}
/** The product's category and type (`productTypeGroup`, `productTypeSubGroup`, ...), empty when it has none. */
classification(productId: any): Record<string, any> {
return this.get(productId)?.advanceExtensions ?? {};
}
}
@@ -52,7 +52,9 @@
}
} @else {
<span class="rrc-rate-now__value rrc-rate-now__value--none">Not set</span>
<span class="rrc-rate-now__since">{{ type === 'CRR' ? 'Form 1' : 'Form 9' }} cannot compute</span>
<span class="rrc-rate-now__since"
>{{ type === 'CRR' ? 'Form 1 Part B' : 'Form 1 Part C' }} cannot compute</span
>
}
</div>
}
@@ -62,8 +64,10 @@
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="rrc-rate-form__type">
<mat-label>Rate</mat-label>
<mat-select [(ngModel)]="newRate.rateType">
<mat-option value="CRR">CRR — Form 1</mat-option>
<mat-option value="SLR">SLR — Form 9</mat-option>
<!-- Both ratios are Form 1's. Part B charges section 18 on the base, Part C section
24 on the same base - one form, two parts, not two returns. -->
<mat-option value="CRR">CRR — Form 1 Part B</mat-option>
<mat-option value="SLR">SLR — Form 1 Part C</mat-option>
<!-- Not a reserve ratio, but set by RBI and effective-dated the same way: the share of
savings balances that counts as a demand liability, the rest being time. -->
<mat-option value="SAVINGS_DEMAND">Savings — demand share</mat-option>
@@ -23,8 +23,11 @@ export interface ReserveMapping {
}
/**
* Configuration behind RBI Form 1 (CRR) and Form 9 (SLR): the percentages RBI sets, and which GL
* accounts the returns read their figures from.
* Configuration behind RBI Form 1: the percentages RBI sets, and which GL accounts the return reads
* its figures from.
*
* One form settles both ratios. Part B is section 18 (the cash reserve) and Part C is section 24
* (the liquid assets), charged on the same base, so CRR and SLR are configured together here.
*
* Both halves sit on one screen because they are one decision. A percentage applies to a base, and
* an administrator who sets the rate but never maps the accounts produces a return that looks
@@ -47,8 +50,8 @@ export class RbiReserveConfigComponent implements OnInit {
private organizationService = inject(OrganizationService);
/**
* Which return's roles this screen configures, as the backend groups them: 'RESERVE' for Form 1
* and Form 9, 'OSS1', 'OSS2'. Null shows every role.
* Which return's roles this screen configures, as the backend groups them: 'RESERVE' for Form 1,
* 'FORM9' for Form IX, 'OSS1', 'OSS2'. Null shows every role.
*
* A screen per return rather than one screen for all of them: the four roles that drive CRR and
* the thirty that do not are separate decisions, made by different people at different times,
@@ -65,13 +68,14 @@ export class RbiReserveConfigComponent implements OnInit {
roleGroups: { name: string; label: string; roles: any[] }[] = [];
/**
* Which return's configuration is on screen: Form 1, Form 9, or both together.
* Which return's configuration is on screen: Form 1, Form IX, or both together.
*
* The two returns share a base but maintain their requirement in different assets, so an
* administrator setting one up is looking at a different set of heads than one setting up the
* other. Showing all twenty-six at once buries the four that matter for the task in hand.
* The two returns have nothing in common - Form 1 charges ratios on a liability base, Form IX
* reports a balance sheet - so an administrator setting one up is looking at a different set of
* heads than one setting up the other. Showing all of them at once buries the handful that matter
* for the task in hand.
*/
formFilter: 'ALL' | 'FORM_1' | 'FORM_9' | 'FORM_IX' = 'ALL';
formFilter: 'ALL' | 'FORM_1' | 'FORM_IX' = 'ALL';
/** Every reserve role the backend returned, before the form filter narrows it. */
private allRoles: any[] = [];
@@ -95,20 +99,28 @@ export class RbiReserveConfigComponent implements OnInit {
return scoped.filter((r: any) => this.belongsToSelectedForm(r));
}
/**
* The returns this bank files, and the only ones that can be configured.
*
* There is no separate SLR return. Form 1 settles both ratios on one form - Part B is section 18,
* the cash reserve, and Part C is section 24, the liquid assets - so the heads that meet SLR are
* Form 1's heads. A "Form 9 - SLR" option used to sit here from before that was understood; it
* selected a return that has no report behind it, and filtered the SLR heads out of the one form
* that actually reports them.
*/
readonly formFilters = [
{ value: 'ALL', label: 'All returns' },
{ value: 'FORM_1', label: 'Form 1 — CRR (fortnightly)' },
{ value: 'FORM_9', label: 'Form 9 — SLR (monthly)' },
{ value: 'FORM_1', label: 'Form 1 — CRR & SLR' },
{ value: 'FORM_IX', label: 'Form IX — Assets & Liabilities (R408)' }
];
/**
* Whether a head belongs to the return currently selected.
*
* Decided from how the return consumes the head rather than from its name: MAINTAINED is met in
* cash and balances and so is Form 1's, SLR_ASSET is met in securities and gold and so is Form
* 9's, and a head that builds or nets the DTL base belongs to both because both charge their rate
* on that same base. A memo head is stated on either return and so shows on both.
* Form 1 takes every head that is not Form IX's. It reports both ratios: MAINTAINED heads are met
* in cash and balances and settle Part B, SLR_ASSET heads are met in securities and gold and
* settle Part C, and the heads that build or net the DTL base feed the IV(b) both rates are
* charged on. Splitting them across two filters only hid half of one form.
*/
private belongsToSelectedForm(role: any): boolean {
if (this.formFilter === 'ALL') {
@@ -125,19 +137,7 @@ export class RbiReserveConfigComponent implements OnInit {
if (this.formFilter === 'FORM_IX') {
return false;
}
// The built-in SLR investments role predates the SLR_ASSET usage and still carries MAINTAINED,
// so it is placed by name; every other head is placed by how the return consumes it.
if (role.configKey === 'SLR_INVESTMENTS') {
return this.formFilter === 'FORM_9';
}
const usage = role.usage || role.roleUsage;
if (usage === 'MAINTAINED') {
return this.formFilter === 'FORM_1';
}
if (usage === 'SLR_ASSET') {
return this.formFilter === 'FORM_9';
}
// LIABILITY, ASSET_NET and MEMO all appear on both returns.
// Everything left belongs to Form 1, whichever of its two parts it settles.
return true;
}
@@ -171,15 +171,15 @@ export class RbiReserveConfigComponent implements OnInit {
/**
* How a head an administrator adds is consumed by the return.
*
* MAINTAINED and SLR_ASSET are separate because the two returns admit different assets: CRR is
* met in cash and balances with banks, SLR in cash, gold and approved securities. One option for
* both would let a head meant for Form 1 quietly satisfy Form 9.
* MAINTAINED and SLR_ASSET are separate because the two parts admit different assets: CRR is met
* in cash and balances with banks, SLR in cash, gold and approved securities. One option for both
* would let a head meant for Part B quietly satisfy Part C.
*/
readonly roleUsages = [
{ value: 'LIABILITY', label: 'Liability — adds to the DTL base (Form 1 & 9)' },
{ value: 'ASSET_NET', label: 'Asset — netted off the base to give NDTL (Form 1 & 9)' },
{ value: 'MAINTAINED', label: 'Counts toward CRR maintained (Form 1)' },
{ value: 'SLR_ASSET', label: 'Counts toward SLR maintained (Form 9)' },
{ value: 'LIABILITY', label: 'Liability — adds to the DTL base (Form 1)' },
{ value: 'ASSET_NET', label: 'Asset — netted off the base to give NDTL (Form 1)' },
{ value: 'MAINTAINED', label: 'Counts toward CRR maintained (Form 1 Part B)' },
{ value: 'SLR_ASSET', label: 'Counts toward SLR maintained (Form 1 Part C)' },
// Form IX has no ratio and no base: its usages say which side of the balance sheet a head sits
// on. LIABILITY above serves its liability side, and this its asset side.
{ value: 'ASSET', label: 'Asset — enters Total Assets (Form IX)' },
@@ -282,9 +282,9 @@ export class RbiReserveConfigComponent implements OnInit {
get screenSubtitle(): string {
return (
'The CRR and SLR percentages, and the GL accounts that RBI Form 1 (CRR, fortnightly) and Form 9 (SLR, monthly) ' +
'read their figures from. A rate applies to a base — so a role left unmapped stops its return computing rather ' +
'than reporting a nil figure that looks real.'
'The CRR and SLR percentages, and the GL accounts RBI Form 1 reads its figures from — Part B settles the cash ' +
'reserve, Part C the liquid assets, both charged on the same base. A rate applies to a base — so a role left ' +
'unmapped stops the return computing rather than reporting a nil figure that looks real.'
);
}
@@ -0,0 +1,96 @@
<!--
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="ca" *ngIf="register">
<div class="ca__readonly-banner" *ngIf="!canEdit">
<i class="fas fa-lock"></i>
<div>
<div class="ca__readonly-title">Read-only — {{ register.status }}</div>
<div class="ca__readonly-sub">{{ readOnlyReason() }}</div>
</div>
</div>
<div class="ca__active-strip" *ngIf="active">
<span class="ca__active-dot">●</span>
<span class="ca__active-label">CURRENT ACTIVE</span>
<span class="ca__active-name">{{ active.name }}</span>
<span class="ca__active-sub">· effective {{ active.effectiveFrom }} → {{ active.effectiveTo || '—' }}</span>
<span class="ca__active-flag" *ngIf="isActiveRegister()">You are viewing the Active register</span>
<a class="ca__active-link" *ngIf="!isActiveRegister()" (click)="viewActive()">View active →</a>
</div>
<div class="ca__header">
<div class="ca__header-icon">CS</div>
<div class="ca__header-text">
<h2>CIBIL / credit score adjustment</h2>
<p>Applied on top of the chart rate at loan sanction · reused across all loan products linked to this register</p>
</div>
<button mat-stroked-button (click)="backToRegister()">← Back to register</button>
</div>
<div class="ca__card">
<div class="ca__card-head">
<div>
<div class="ca__card-title">Credit score slabs</div>
<div class="ca__card-sub">
Only the Adjustment % is editable · changes apply to new sanctions after the register is approved
</div>
</div>
<button *ngIf="canEdit" mat-stroked-button class="ca__dash-btn" (click)="addSlab()">
<i class="fas fa-plus"></i> Add slab
</button>
</div>
<table class="ca__editgrid">
<thead>
<tr>
<th class="ca__col-ix">#</th>
<th class="ca__num">Score from</th>
<th class="ca__num">Score to</th>
<th>Risk tier</th>
<th>Type</th>
<th class="ca__num">Adjustment</th>
<th class="ca__col-del"></th>
</tr>
</thead>
<tbody>
<tr *ngFor="let slab of register.cibilSlabs; let i = index">
<td class="ca__col-ix">{{ i + 1 }}</td>
<td class="ca__num ca__cell-readonly">{{ slab.from }}</td>
<td class="ca__num ca__cell-readonly">{{ slab.to }}</td>
<td>
<span [class]="tierClass(slab.tier)">{{ slab.tier }}</span>
</td>
<td class="ca__cell-muted">{{ slab.type }}</td>
<td class="ca__num">
<div class="ca__adj-wrap">
<input
[class]="adjustInputClass(slab.adjustmentPct)"
type="number"
step="0.05"
[(ngModel)]="slab.adjustmentPct"
[readonly]="!canEdit"
/>
<span class="ca__adj-suffix">%</span>
</div>
</td>
<td class="ca__col-del">
<button *ngIf="canEdit" type="button" class="ca__delete-btn" title="Delete slab" (click)="deleteSlab(i)">
<i class="fas fa-trash-alt"></i>
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div class="ca__footer">
<button mat-stroked-button (click)="backToRegister()">{{ canEdit ? 'Cancel' : 'Back to register' }}</button>
<button *ngIf="canEdit" mat-flat-button color="primary" (click)="save()">Save adjustments</button>
</div>
</div>
@@ -0,0 +1,414 @@
/**
* 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/.
*/
.ca {
background: #f8fafc;
min-height: calc(100vh - 160px);
padding: 24px 32px;
display: flex;
flex-direction: column;
gap: 16px;
&__active-strip {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 18px;
background: #ecfdf5;
border: 1px solid #a7f3d0;
border-radius: 10px;
font-size: 12px;
color: #065f46;
}
&__active-dot {
color: #059669;
font-size: 14px;
line-height: 1;
}
&__active-label {
font-size: 10px;
letter-spacing: 0.5px;
font-weight: 700;
color: #065f46;
}
&__active-name {
font-weight: 600;
color: #064e3b;
}
&__active-sub {
color: #047857;
}
&__active-flag {
margin-left: auto;
font-size: 11px;
font-weight: 600;
color: #047857;
background: #d1fae5;
padding: 3px 10px;
border-radius: 999px;
}
&__active-link {
margin-left: auto;
font-size: 12px;
font-weight: 600;
color: #047857;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
&__readonly-banner {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 18px;
background: #fef3c7;
border: 1px solid #fcd34d;
border-left: 4px solid #d97706;
border-radius: 10px;
color: #78350f;
i {
font-size: 16px;
color: #d97706;
}
}
&__readonly-title {
font-size: 13px;
font-weight: 700;
color: #78350f;
}
&__readonly-sub {
margin-top: 2px;
font-size: 12px;
color: #92400e;
}
&__header {
display: flex;
align-items: center;
gap: 14px;
padding: 20px 24px;
background: white;
border: 1px solid #e2e8f0;
border-radius: 12px;
}
&__header-icon {
width: 42px;
height: 42px;
border-radius: 10px;
background: #dbeafe;
color: #1d4ed8;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 700;
letter-spacing: 0.3px;
}
&__header-text {
flex: 1;
h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
color: #0f172a;
}
p {
margin: 3px 0 0;
font-size: 12px;
color: #64748b;
}
}
&__card {
background: white;
border: 1px solid #e2e8f0;
border-radius: 12px;
overflow: hidden;
}
&__card-head {
padding: 16px 20px;
border-bottom: 1px solid #f1f5f9;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
&__card-title {
font-size: 15px;
font-weight: 600;
color: #0f172a;
}
&__card-sub {
margin-top: 2px;
font-size: 12px;
color: #64748b;
}
&__dash-btn {
height: 32px;
font-size: 12px;
font-weight: 600;
color: #1d4ed8;
border: 1px dashed #1d4ed8;
padding: 0 14px;
i {
margin-right: 6px;
}
}
&__num {
text-align: right;
}
&__col-ix {
width: 44px;
text-align: center;
color: #94a3b8;
font-weight: 600;
}
&__col-del {
width: 48px;
text-align: center;
}
&__cell-muted {
color: #64748b;
}
&__cell-readonly {
color: #0f172a;
font-variant-numeric: tabular-nums;
padding: 0 22px;
}
&__footer {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 4px 2px;
}
}
/* ---- Editable grid ---------------------------------------------------- */
.ca__editgrid {
width: 100%;
border-collapse: collapse;
font-size: 13px;
thead tr {
background: #f8fafc;
th {
text-align: left;
padding: 10px 16px;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.4px;
color: #64748b;
font-weight: 600;
border-bottom: 1px solid #e2e8f0;
&.ca__num {
text-align: right;
}
}
}
tbody tr {
border-bottom: 1px solid #f1f5f9;
transition: background 0.12s ease;
&:hover {
background: #f8fafc;
.ca__delete-btn {
opacity: 1;
}
}
&:last-child {
border-bottom: 0;
}
td {
padding: 8px 12px;
vertical-align: middle;
}
}
}
/* Borderless cell input — gets a visible border on hover/focus. */
.ca__ghost-input {
width: 100%;
height: 32px;
border: 1px solid transparent;
background: transparent;
border-radius: 6px;
padding: 0 10px;
font-size: 13px;
color: #0f172a;
font-family: inherit;
box-sizing: border-box;
&:hover {
border-color: #e2e8f0;
background: white;
}
&:focus {
outline: none;
border-color: #1d4ed8;
background: white;
box-shadow: 0 0 0 3px rgb(29 78 216 / 10%);
}
&--num {
text-align: right;
font-variant-numeric: tabular-nums;
}
/* Hide number input spinners for a cleaner look. */
&[type='number']::-webkit-inner-spin-button,
&[type='number']::-webkit-outer-spin-button {
appearance: none;
margin: 0;
}
&[type='number'] {
appearance: textfield;
}
}
/* Adjustment input — coloured by sign, with trailing % suffix. */
.ca__adj-wrap {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 2px;
width: 100%;
}
.ca__adj-input {
width: 72px;
font-weight: 700;
&--negative {
color: #059669;
}
&--positive {
color: #d97706;
}
&--high {
color: #dc2626;
}
}
.ca__adj-suffix {
color: #94a3b8;
font-size: 12px;
font-weight: 600;
}
/* Chip-shaped tier select — background matches the current tier. */
.ca__tier-select {
appearance: none;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path fill='%2364748b' d='M0 0l5 6 5-6z'/></svg>");
background-repeat: no-repeat;
background-position: right 10px center;
padding: 4px 26px 4px 12px;
height: 28px;
border: 1px solid transparent;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
font-family: inherit;
&:focus {
outline: none;
box-shadow: 0 0 0 3px rgb(29 78 216 / 15%);
}
&--vpoor {
background-color: #fecaca;
color: #7f1d1d;
}
&--poor {
background-color: #fee2e2;
color: #991b1b;
}
&--fair {
background-color: #fef3c7;
color: #92400e;
}
&--good {
background-color: #dbeafe;
color: #1e40af;
}
&--excellent {
background-color: #d1fae5;
color: #065f46;
}
}
/* Trash button — hidden until row hover. */
.ca__delete-btn {
width: 28px;
height: 28px;
border: 1px solid transparent;
background: transparent;
color: #94a3b8;
border-radius: 6px;
font-size: 12px;
line-height: 1;
cursor: pointer;
opacity: 0;
transition:
opacity 0.12s ease,
color 0.12s ease,
border-color 0.12s ease,
background 0.12s ease;
&:hover {
border-color: #fecaca;
background: #fef2f2;
color: #dc2626;
}
&:focus-visible {
opacity: 1;
}
}
@@ -0,0 +1,139 @@
/**
* 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 { ActivatedRoute, Router } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { RateRegisterService } from '../rate-register.service';
import { CibilSlab, RateRegister } from '../models/rate-register.model';
/**
* CIBIL / credit-score slab editor.
* Score range, tier and type are fixed per slab; only the Adjustment % is editable.
* New slabs can be appended (and removed) via Add slab / row trash.
*/
@Component({
selector: 'mifosx-cibil-adjustment',
templateUrl: './cibil-adjustment.component.html',
styleUrls: ['./cibil-adjustment.component.scss'],
imports: [
CommonModule,
FormsModule,
MatButtonModule
]
})
export class CibilAdjustmentComponent implements OnInit {
private readonly service = inject(RateRegisterService);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
register!: RateRegister;
active?: RateRegister;
ngOnInit(): void {
const idParam = this.route.snapshot.queryParamMap.get('id');
const id = idParam ? Number(idParam) : null;
this.service.list().subscribe((all) => {
const found = (id ? all.find((r) => r.id === id) : undefined) ?? this.service.getActiveOrFirstDraft();
if (!found) {
this.router.navigate(['/products/loan-interest-rate-register']);
return;
}
this.register = found;
this.active = this.service.getActive();
});
}
isActiveRegister(): boolean {
return !!this.active && this.active.id === this.register?.id;
}
viewActive(): void {
if (this.active) {
this.router.navigate(['/products/loan-interest-rate-register/cibil'], { queryParams: { id: this.active.id } });
}
}
/** Only a Draft can be edited. Pending Approval / Active / Expired open read-only. */
get canEdit(): boolean {
return this.register?.status === 'Draft';
}
readOnlyReason(): string {
switch (this.register?.status) {
case 'Pending Approval':
return 'This version has been submitted and is awaiting checker approval. No further edits allowed.';
case 'Active':
return 'This is the current Active register. Click "Edit (new version)" on the list page to propose changes.';
case 'Expired':
return 'This is an inactive historical version. Read-only.';
default:
return 'Read-only.';
}
}
tierClass(tier: CibilSlab['tier']): string {
const map: Record<string, string> = {
'V. Poor': 'tier tier--vpoor',
Poor: 'tier tier--poor',
Fair: 'tier tier--fair',
Good: 'tier tier--good',
Excellent: 'tier tier--excellent'
};
return map[tier] ?? 'tier';
}
/** Classes to colour the adjustment number by sign/magnitude. */
adjustInputClass(pct: number): string {
const base = 'ca__ghost-input ca__ghost-input--num ca__adj-input';
if (pct < 0) {
return `${base} ca__adj-input--negative`;
}
if (pct >= 1) {
return `${base} ca__adj-input--high`;
}
if (pct > 0) {
return `${base} ca__adj-input--positive`;
}
return base;
}
/** Append a new slab continuing from the current highest "score to". */
addSlab(): void {
if (!this.canEdit) {
return;
}
const lastTo = this.register.cibilSlabs.length ? Math.max(...this.register.cibilSlabs.map((s) => s.to)) : 0;
const from = Math.min(lastTo + 1, 900);
this.register.cibilSlabs.push({
from,
to: Math.min(from + 50, 900),
tier: 'Fair',
type: 'Flat',
adjustmentPct: 0
});
}
deleteSlab(index: number): void {
if (!this.canEdit) {
return;
}
this.register.cibilSlabs.splice(index, 1);
}
/** Edits are kept in the in-memory service (the register reference is shared). */
save(): void {
this.router.navigate(['/products/loan-interest-rate-register/edit'], { queryParams: { id: this.register.id } });
}
backToRegister(): void {
this.save();
}
}
@@ -0,0 +1,199 @@
<!--
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="er" *ngIf="register">
<div class="er__active-strip" *ngIf="active">
<span class="er__active-dot">●</span>
<span class="er__active-label">CURRENT ACTIVE</span>
<span class="er__active-name">{{ active.name }}</span>
<span class="er__active-sub">· effective {{ active.effectiveFrom }} → {{ active.effectiveTo || '—' }}</span>
<span class="er__active-flag" *ngIf="isActiveRegister()">You are viewing the Active register</span>
<a class="er__active-link" *ngIf="!isActiveRegister()" (click)="viewActive()">View active →</a>
</div>
<div class="er__readonly-banner" *ngIf="!canEdit">
<i class="fas fa-lock"></i>
<div>
<div class="er__readonly-title">Read-only — {{ register.status }}</div>
<div class="er__readonly-sub">{{ readOnlyReason() }}</div>
</div>
</div>
<div class="er__meta-bar">
<div class="er__field">
<label>REGISTER NAME</label>
<input type="text" [(ngModel)]="register.name" [readonly]="!canEdit" placeholder="Entered when you submit" />
</div>
<div class="er__field er__field--date">
<label>EFFECTIVE FROM</label>
<input type="date" [(ngModel)]="register.effectiveFrom" [readonly]="!canEdit" />
</div>
<div class="er__field er__field--date">
<label>EFFECTIVE TO</label>
<input type="date" [(ngModel)]="register.effectiveTo" [readonly]="!canEdit" />
</div>
<div class="er__field er__field--curr">
<label>CURRENCY</label>
<select [(ngModel)]="register.currency" [disabled]="!canEdit">
<option value="INR">INR</option>
<option value="USD">USD</option>
</select>
</div>
<button mat-stroked-button class="er__meta-btn" *ngIf="canEdit"><i class="fas fa-arrow-down"></i> Import</button>
<button mat-stroked-button class="er__meta-btn"><i class="fas fa-arrow-up"></i> Export</button>
</div>
<div class="er__grid-card">
<div class="er__grid-head">
<div class="er__grid-title">Rate chart grid</div>
<div class="er__grid-sub">
{{ rows.length }} products × {{ slabs.length }} amount slabs
<span *ngIf="canEdit">· click any cell to edit</span>
<span *ngIf="!canEdit">· read-only view</span>
<span class="er__gl-warn" *ngIf="unmappedCount() > 0"
>· {{ unmappedCount() }} without GL — map every product's GL before submitting</span
>
</div>
<div class="er__grid-actions" *ngIf="canEdit">
<button mat-stroked-button class="er__dash-btn" (click)="addProduct()">
<i class="fas fa-plus"></i> Add product
</button>
<button mat-stroked-button class="er__dash-btn" (click)="addSlab()">
<i class="fas fa-plus"></i> Add slab
</button>
</div>
</div>
<div class="er__add-panel" *ngIf="addingProduct && canEdit">
<div class="er__add-title">Add product</div>
<div class="er__add-fields">
<div class="er__field">
<label>PRODUCT NAME</label>
<input type="text" [(ngModel)]="newProductName" placeholder="e.g. Education Loan" />
</div>
<div class="er__field er__field--gl">
<label>GL ACCOUNT (required)</label>
<mifosx-gl-picker [options]="glOptions" [(value)]="newProductGl"></mifosx-gl-picker>
</div>
<button mat-flat-button color="primary" (click)="confirmAddProduct()">Add</button>
<button mat-stroked-button (click)="cancelAddProduct()">Cancel</button>
</div>
<div class="er__add-error" *ngIf="addProductError">{{ addProductError }}</div>
</div>
<div class="er__add-panel" *ngIf="addingSlab && canEdit">
<div class="er__add-title">Add amount slab</div>
<div class="er__add-fields">
<div class="er__field">
<label>UPPER LIMIT (₹) — leave blank for "above the last slab"</label>
<input type="number" min="1" [(ngModel)]="newSlabAmount" placeholder="e.g. 5000000" />
</div>
<div class="er__field">
<label>SLAB LABEL</label>
<input
type="text"
[(ngModel)]="newSlabLabel"
[placeholder]="suggestedSlabLabel() || 'e.g. Upto 50,00,000 or > 50,00,000'"
/>
</div>
<button mat-flat-button color="primary" (click)="confirmAddSlab()">Add</button>
<button mat-stroked-button (click)="cancelAddSlab()">Cancel</button>
</div>
<div class="er__add-hint">The new slab is added as the last column; fill in its rates for each product.</div>
<div class="er__add-error" *ngIf="addSlabError">{{ addSlabError }}</div>
</div>
<div class="er__grid-scroll">
<table class="er__grid">
<thead>
<tr>
<th class="er__col-particulars">Particulars</th>
<th *ngFor="let slab of slabs" class="er__col-rate">{{ slab.label }}</th>
</tr>
</thead>
<tbody>
<tr
*ngFor="let row of rows; let i = index"
[class.er__row-alt]="i % 2 === 1"
[class.er__row--formula]="isFormulaRow(row)"
>
<td class="er__col-particulars">
<span class="er__srno">{{ row.srNo }}</span
>{{ row.particulars }}
<div class="er__gl" *ngIf="glEditRow !== i">
<span
class="er__gl-tag"
[class.er__gl-tag--unmapped]="row.glAccountId == null"
[class.er__gl-tag--editable]="canEdit"
(click)="editGl(i)"
>{{ row.glAccountId == null ? 'GL not mapped' : glLabel(row) }}</span
>
</div>
<mifosx-gl-picker
*ngIf="glEditRow === i"
class="er__gl-select"
[options]="glOptions"
[value]="row.glAccountId ?? null"
[allowClear]="true"
[openOnInit]="true"
(valueChange)="row.glAccountId = $event"
(closed)="glEditRow = null"
></mifosx-gl-picker>
</td>
<td
*ngFor="let cell of row.rates; let j = index"
class="er__col-rate er__cell"
[class.er__cell--na]="isNa(cell)"
[class.er__cell--formula]="isFormulaRow(row)"
[class.er__cell--editing]="isEditing(i, j)"
[class.er__cell--readonly]="!canEdit"
(click)="canEdit && !isEditing(i, j) && startEdit(i, j, cell)"
>
<input
*ngIf="isEditing(i, j); else cellView"
#cellInput
type="text"
class="er__cell-input"
[(ngModel)]="editValue"
(blur)="saveEdit(i, j)"
(keydown.enter)="saveEdit(i, j)"
(keydown.escape)="cancelEdit()"
/>
<ng-template #cellView>{{ display(cell) }}</ng-template>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="er__cibil-strip" (click)="editCibil()">
<div class="er__cibil-badge">CS</div>
<div class="er__cibil-text">
<div class="er__cibil-title">CIBIL / credit score adjustment</div>
<div class="er__cibil-sub">
{{ register.cibilSlabs.length }} slabs · +2.00% (very poor) to base (above 700) · applied on top of chart rate
</div>
</div>
<a class="er__cibil-edit">Edit →</a>
</div>
<div class="er__footer">
<button mat-stroked-button (click)="backToList()">{{ canEdit ? 'Cancel' : 'Back to list' }}</button>
<div class="er__footer-note">Rates in % p.a. · N.A. = slab not offered · formulas resolved at sanction</div>
<div class="er__footer-actions" *ngIf="canEdit">
<button mat-stroked-button (click)="saveDraft()">Save draft</button>
<button
mat-flat-button
class="er__submit"
(click)="submitForApproval()"
[disabled]="!canSubmit"
[title]="canSubmit ? '' : 'Map a GL account for every product first'"
>
<i class="fas fa-check"></i> Submit for approval
</button>
</div>
</div>
</div>
@@ -0,0 +1,496 @@
/**
* 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/.
*/
.er {
background: #f8fafc;
min-height: calc(100vh - 160px);
padding: 20px 24px;
display: flex;
flex-direction: column;
gap: 12px;
&__active-strip {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 18px;
background: #ecfdf5;
border: 1px solid #a7f3d0;
border-radius: 9px;
font-size: 12px;
color: #065f46;
}
&__active-dot {
color: #059669;
font-size: 14px;
line-height: 1;
}
&__active-label {
font-size: 10px;
letter-spacing: 0.5px;
font-weight: 700;
color: #065f46;
}
&__active-name {
font-weight: 600;
color: #064e3b;
}
&__active-sub {
color: #047857;
}
&__active-flag {
margin-left: auto;
font-size: 11px;
font-weight: 600;
color: #047857;
background: #d1fae5;
padding: 3px 10px;
border-radius: 999px;
}
&__active-link {
margin-left: auto;
font-size: 12px;
font-weight: 600;
color: #047857;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
&__gl {
margin: 3px 0 0 30px;
}
&__gl-tag {
display: inline-block;
font-size: 10px;
font-weight: 600;
color: #475569;
background: #f1f5f9;
border-radius: 999px;
padding: 1px 8px;
white-space: nowrap;
&--unmapped {
color: #92400e;
background: #fef3c7;
}
&--editable {
cursor: pointer;
&:hover {
outline: 1px solid #94a3b8;
}
}
}
&__gl-select {
display: block;
margin: 4px 0 0 30px;
max-width: 340px;
}
&__gl-warn {
color: #b45309;
font-weight: 600;
}
&__add-panel {
padding: 14px 18px;
border-bottom: 1px solid #f1f5f9;
background: #f8fafc;
}
&__add-title {
font-size: 13px;
font-weight: 600;
color: #0f172a;
margin-bottom: 10px;
}
&__add-fields {
display: flex;
align-items: flex-end;
gap: 12px;
flex-wrap: wrap;
.er__field {
min-width: 240px;
}
.er__field--gl {
min-width: 320px;
}
}
&__add-hint {
margin-top: 8px;
font-size: 12px;
color: #64748b;
}
&__add-error {
margin-top: 8px;
font-size: 12px;
color: #b91c1c;
}
&__readonly-banner {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 18px;
background: #fef3c7;
border: 1px solid #fcd34d;
border-left: 4px solid #d97706;
border-radius: 9px;
color: #78350f;
i {
font-size: 16px;
color: #d97706;
}
}
&__readonly-title {
font-size: 13px;
font-weight: 700;
color: #78350f;
}
&__readonly-sub {
margin-top: 2px;
font-size: 12px;
color: #92400e;
}
&__meta-bar {
background: white;
border: 1px solid #e2e8f0;
border-radius: 9px;
padding: 12px 14px;
display: flex;
gap: 10px;
align-items: flex-end;
}
&__field {
display: flex;
flex-direction: column;
flex: 1.5;
label {
font-size: 10px;
color: #64748b;
font-weight: 600;
letter-spacing: 0.3px;
margin-bottom: 3px;
}
input,
select {
height: 32px;
border: 1px solid #cbd5e1;
background: white;
border-radius: 6px;
padding: 0 10px;
font-size: 13px;
font-family: inherit;
color: #0f172a;
&:focus {
outline: 2px solid #1d4ed8;
outline-offset: -1px;
}
}
&--date {
flex: 0.9;
}
&--curr {
flex: 0.5;
}
}
&__meta-btn {
height: 32px;
font-size: 11px;
i {
margin-right: 4px;
}
}
&__grid-card {
background: white;
border: 1px solid #e2e8f0;
border-radius: 9px;
overflow: hidden;
display: flex;
flex-direction: column;
flex: 1;
min-height: 500px;
}
&__grid-head {
padding: 8px 14px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
display: flex;
align-items: center;
gap: 10px;
}
&__grid-title {
font-size: 12px;
font-weight: 600;
}
&__grid-sub {
font-size: 10px;
color: #64748b;
}
&__grid-actions {
margin-left: auto;
display: flex;
gap: 6px;
}
&__dash-btn {
height: 26px;
font-size: 10px;
font-weight: 600;
color: #1d4ed8;
border: 1px dashed #1d4ed8;
i {
margin-right: 3px;
}
}
&__grid-scroll {
overflow: auto;
flex: 1;
}
&__grid {
width: 100%;
border-collapse: separate;
border-spacing: 0;
font-size: 11px;
thead tr {
background: #f8fafc;
color: #475569;
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.3px;
th {
padding: 8px 7px;
font-weight: 600;
position: sticky;
top: 0;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
z-index: 2;
white-space: nowrap;
}
}
tbody tr {
border-top: 1px solid #f1f5f9;
}
td {
padding: 7px;
text-align: right;
cursor: text;
&:first-child {
text-align: left;
}
}
}
&__col-particulars {
position: sticky;
left: 0;
background: white;
border-right: 1px solid #e2e8f0;
font-weight: 500;
min-width: 240px;
text-align: left !important;
padding-left: 12px !important;
}
&__row-alt &__col-particulars {
background: #fafbfc;
}
&__row-alt {
background: #fafbfc;
}
&__row--formula {
.er__col-particulars {
background: #eff6ff;
color: #1d4ed8;
font-weight: 600;
}
}
&__srno {
color: #94a3b8;
margin-right: 6px;
}
&__cell {
cursor: cell;
transition: background-color 0.1s;
&:hover {
background: #f1f5f9;
outline: 1px solid #cbd5e1;
outline-offset: -1px;
}
&--readonly {
cursor: default;
&:hover {
background: transparent;
outline: none;
}
}
&--na {
color: #cbd5e1;
}
&--formula {
background: #eff6ff;
color: #1d4ed8;
font-weight: 600;
}
&--editing {
padding: 0 !important;
background: #fef9c3 !important;
outline: 2px solid #1d4ed8 !important;
outline-offset: -2px;
}
}
&__cell-input {
width: 100%;
height: 100%;
min-height: 28px;
border: none;
outline: none;
background: transparent;
padding: 7px;
font-size: 11px;
text-align: right;
font-family: inherit;
color: #0f172a;
box-sizing: border-box;
}
&__cibil-strip {
background: white;
border: 1px solid #e2e8f0;
border-radius: 9px;
padding: 9px 14px;
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
transition: background-color 0.1s;
&:hover {
background: #f8fafc;
}
}
&__cibil-badge {
width: 24px;
height: 24px;
border-radius: 12px;
background: #dbeafe;
color: #1d4ed8;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
}
&__cibil-text {
flex: 1;
}
&__cibil-title {
font-size: 12px;
font-weight: 600;
}
&__cibil-sub {
font-size: 10px;
color: #64748b;
margin-top: 1px;
}
&__cibil-edit {
font-size: 11px;
font-weight: 600;
color: #1d4ed8;
cursor: pointer;
}
&__footer {
background: white;
border: 1px solid #e2e8f0;
border-radius: 9px;
padding: 10px 14px;
display: flex;
align-items: center;
justify-content: space-between;
}
&__footer-note {
font-size: 11px;
color: #64748b;
}
&__footer-actions {
display: flex;
gap: 6px;
}
&__submit {
background: #059669 !important;
color: white !important;
font-weight: 600;
i {
margin-right: 4px;
}
}
}
@@ -0,0 +1,363 @@
/**
* 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, OnDestroy, OnInit, ViewChild, inject } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatIconModule } from '@angular/material/icon';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatNativeDateModule } from '@angular/material/core';
import { RateRegisterService } from '../rate-register.service';
import { GlOption, RateCell, RateChartRow, RateRegister, SlabColumn } from '../models/rate-register.model';
import { GlPickerComponent } from '../gl-picker/gl-picker.component';
/**
* Grid editor for one Loan Interest Rate Register — Excel-shaped: 34 products as rows and 10
* amount slabs as columns. Cells can hold a plain rate, N.A., or a formula ("1% + FD"). All
* data today comes from {@link RateRegisterService}; wire-up to the real API comes later.
*/
@Component({
selector: 'mifosx-edit-register',
templateUrl: './edit-register.component.html',
styleUrls: ['./edit-register.component.scss'],
imports: [
CommonModule,
FormsModule,
RouterLink,
MatButtonModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
MatIconModule,
MatDatepickerModule,
MatNativeDateModule,
GlPickerComponent
]
})
export class EditRegisterComponent implements OnInit, OnDestroy {
private readonly snack = inject(MatSnackBar);
private readonly service = inject(RateRegisterService);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
register!: RateRegister;
active?: RateRegister;
slabs: SlabColumn[] = [];
rows: RateChartRow[] = [];
/** The cell currently being edited (row/column indices into the grid), or null when nothing is open. */
editing: { row: number; col: number } | null = null;
editValue = '';
@ViewChild('cellInput') cellInput?: ElementRef<HTMLInputElement>;
/** Leaving the page keeps the draft: edits are saved so a reload or a trip to Compare does not lose them. */
ngOnDestroy(): void {
if (this.canEdit) {
this.service.saveDrafts();
}
}
saveDraft(): void {
this.service.saveDrafts();
this.snack.open('Draft saved', 'OK', { duration: 2000 });
}
/** Posting GL accounts for the particular → GL mapping. */
glOptions: GlOption[] = [];
/** "Add product" panel state. A new product needs a name and a GL before it can be added. */
addingProduct = false;
newProductName = '';
newProductGl: number | null = null;
addProductError = '';
/** Index of the row whose GL is being changed inline, or null. */
glEditRow: number | null = null;
ngOnInit(): void {
this.service.getGlOptions().subscribe((options) => (this.glOptions = options));
const idParam = this.route.snapshot.queryParamMap.get('id');
const id = idParam ? Number(idParam) : null;
this.service.list().subscribe((all) => {
const found = (id ? all.find((r) => r.id === id) : undefined) ?? this.service.getActiveOrFirstDraft();
if (!found) {
// e.g. a reload on an unsaved local draft: nothing to edit, go back to the list.
this.router.navigate(['/products/loan-interest-rate-register']);
return;
}
this.register = found;
this.active = this.service.getActive();
this.slabs = this.register.slabs;
this.rows = this.register.rows;
});
}
isActiveRegister(): boolean {
return !!this.active && this.active.id === this.register?.id;
}
/** Only a Draft can be edited. Pending Approval / Active / Expired open read-only. */
get canEdit(): boolean {
return this.register?.status === 'Draft';
}
/** Status label shown on the read-only banner. */
readOnlyReason(): string {
switch (this.register?.status) {
case 'Pending Approval':
return 'This version has been submitted and is awaiting checker approval. No further edits allowed.';
case 'Active':
return 'This is the current Active register. Click "Edit (new version)" on the list page to propose changes as a new Draft.';
case 'Expired':
return 'This is an inactive historical version. Read-only.';
default:
return 'Read-only.';
}
}
viewActive(): void {
if (this.active) {
this.router.navigate(['/products/loan-interest-rate-register/edit'], { queryParams: { id: this.active.id } });
}
}
/** Formatted display for a cell. */
display(cell: RateCell): string {
if (cell === null) {
return 'N.A.';
}
if (typeof cell === 'string') {
return cell;
}
return cell.toFixed(2);
}
isFormulaRow(row: RateChartRow): boolean {
return !!row.isFormula;
}
isNa(cell: RateCell): boolean {
return cell === null;
}
/** Click on a cell → open it as an editable input, pre-filled with the current value. */
startEdit(rowIdx: number, colIdx: number, current: RateCell): void {
if (!this.canEdit) {
return;
}
this.editing = { row: rowIdx, col: colIdx };
if (current === null) {
this.editValue = '';
} else if (typeof current === 'string') {
this.editValue = current;
} else {
this.editValue = current.toString();
}
// Let the view render the input, then focus and select its contents.
setTimeout(() => {
const el = this.cellInput?.nativeElement;
if (el) {
el.focus();
el.select();
}
}, 0);
}
isEditing(rowIdx: number, colIdx: number): boolean {
return !!this.editing && this.editing.row === rowIdx && this.editing.col === colIdx;
}
/**
* Save the cell:
* - empty / "N.A." / "NA" → the slab is marked unavailable
* - value with letters, "%" or "+" → persisted as a formula string ("1% + FD")
* - plain number → stored as a number so it formats as a %
*/
saveEdit(rowIdx: number, colIdx: number): void {
if (!this.editing) {
return;
}
const raw = this.editValue.trim();
let next: RateCell;
if (!raw || raw.toLowerCase() === 'n.a.' || raw.toLowerCase() === 'na') {
next = null;
} else if (/[a-zA-Z+%]/.test(raw)) {
next = raw;
} else {
const num = Number(raw);
next = Number.isFinite(num) ? num : this.rows[rowIdx].rates[colIdx];
}
this.rows[rowIdx].rates[colIdx] = next;
this.rows[rowIdx].isFormula = this.rows[rowIdx].rates.some((c) => typeof c === 'string');
this.editing = null;
}
cancelEdit(): void {
this.editing = null;
}
glLabel(row: RateChartRow): string | null {
return this.service.glLabel(row.glAccountId);
}
unmappedCount(): number {
return this.rows.filter((r) => r.glAccountId == null).length;
}
/** Submit is blocked until every product has a GL. */
get canSubmit(): boolean {
return this.unmappedCount() === 0;
}
/** Opens the Add product panel. */
addProduct(): void {
if (!this.canEdit) {
return;
}
this.addingSlab = false;
this.addingProduct = true;
this.newProductName = '';
this.newProductGl = null;
this.addProductError = '';
}
cancelAddProduct(): void {
this.addingProduct = false;
this.addProductError = '';
}
/** Append the new product row at the bottom of the grid. Name and GL are both mandatory. */
confirmAddProduct(): void {
const name = this.newProductName.trim();
if (!name) {
this.addProductError = 'Enter the product name.';
return;
}
if (this.rows.some((r) => r.particulars.trim().toLowerCase() === name.toLowerCase())) {
this.addProductError = 'A product with this name already exists.';
return;
}
if (this.newProductGl == null) {
this.addProductError = 'Select the GL account for this product.';
return;
}
const next = this.rows.length > 0 ? Math.max(...this.rows.map((r) => r.srNo)) + 1 : 1;
this.rows.push({
srNo: next,
particulars: name,
glAccountId: this.newProductGl,
rates: Array(this.slabs.length).fill(null),
isFormula: false
});
this.cancelAddProduct();
}
/** Toggle the inline GL selector on an existing row (Draft only). */
editGl(rowIdx: number): void {
if (this.canEdit) {
this.glEditRow = this.glEditRow === rowIdx ? null : rowIdx;
}
}
/** "Add slab" panel state. A slab is an amount band; at least a label or an upper limit is needed. */
addingSlab = false;
newSlabLabel = '';
newSlabAmount: number | null = null;
addSlabError = '';
/** Opens the Add slab panel. */
addSlab(): void {
if (!this.canEdit) {
return;
}
this.addingProduct = false;
this.addingSlab = true;
this.newSlabLabel = '';
this.newSlabAmount = null;
this.addSlabError = '';
}
cancelAddSlab(): void {
this.addingSlab = false;
this.addSlabError = '';
}
/** Label that is filled in for a slab when only the upper limit is given, e.g. "Upto 50,00,000". */
suggestedSlabLabel(): string {
const amount = this.newSlabAmount;
return amount != null && Number.isFinite(amount) && amount > 0 ? `Upto ${amount.toLocaleString('en-IN')}` : '';
}
/** Append the new amount slab as the last column; every existing row grows by one blank cell. */
confirmAddSlab(): void {
const amount = this.newSlabAmount;
const hasAmount = amount != null && String(amount) !== '';
if (hasAmount && (!Number.isFinite(amount) || (amount as number) <= 0)) {
this.addSlabError = 'The upper limit must be a number greater than 0.';
return;
}
const label = this.newSlabLabel.trim() || this.suggestedSlabLabel();
if (!label) {
this.addSlabError = 'Enter a label, or an upper limit to build one from.';
return;
}
if (this.slabs.some((s) => s.label.trim().toLowerCase() === label.toLowerCase())) {
this.addSlabError = 'A slab with this label already exists.';
return;
}
if (hasAmount && this.slabs.some((s) => s.amountUpto === amount)) {
this.addSlabError = 'A slab with this upper limit already exists.';
return;
}
if (!hasAmount && this.slabs.some((s) => s.amountUpto === null)) {
this.addSlabError = 'An open-ended slab (no upper limit) already exists.';
return;
}
this.slabs.push({
key: `s_${Date.now()}`,
label,
amountUpto: hasAmount ? (amount as number) : null
});
this.rows.forEach((r) => r.rates.push(null));
this.cancelAddSlab();
}
editCibil(): void {
this.router.navigate(['/products/loan-interest-rate-register/cibil'], { queryParams: { id: this.register.id } });
}
backToList(): void {
this.router.navigate(['/products/loan-interest-rate-register']);
}
/** Opens the Compare screen; the actual submit happens from there once the maker has reviewed the diff. */
submitForApproval(): void {
this.router.navigate(['/products/loan-interest-rate-register/preview'], { queryParams: { id: this.register.id } });
}
statusChipClass(): string {
switch (this.register?.status) {
case 'Active':
return 'chip chip--active';
case 'Draft':
return 'chip chip--draft';
case 'Pending Approval':
return 'chip chip--pending';
default:
return 'chip chip--expired';
}
}
}
@@ -0,0 +1,135 @@
/**
* 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 {
AfterViewInit,
Component,
EventEmitter,
Input,
OnChanges,
OnDestroy,
OnInit,
Output,
SimpleChanges,
ViewChild
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, UntypedFormControl } from '@angular/forms';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatSelect, MatSelectModule } from '@angular/material/select';
import { NgxMatSelectSearchModule } from 'ngx-mat-select-search';
import { Subject, takeUntil } from 'rxjs';
import { GlOption } from '../models/rate-register.model';
/**
* Compact searchable GL account dropdown. Typing in the search box filters by GL code or name.
* Emits the chosen GL id; with allowClear a "GL not mapped" entry emits null.
*/
@Component({
selector: 'mifosx-gl-picker',
imports: [
CommonModule,
ReactiveFormsModule,
MatFormFieldModule,
MatSelectModule,
NgxMatSelectSearchModule
],
template: `
<mat-form-field class="gl-picker" subscriptSizing="dynamic">
<mat-select
[value]="value"
[placeholder]="placeholder"
(selectionChange)="valueChange.emit($event.value)"
(openedChange)="onOpenedChange($event)"
>
<mat-option>
<ngx-mat-select-search
[formControl]="filterCtrl"
placeholderLabel="Search GL code or name"
noEntriesFoundLabel="No GL account found"
></ngx-mat-select-search>
</mat-option>
<mat-option *ngIf="allowClear" [value]="null">GL not mapped</mat-option>
<mat-option *ngFor="let o of filtered" [value]="o.id">{{ o.label }}</mat-option>
</mat-select>
</mat-form-field>
`,
styles: [
`
.gl-picker {
width: 100%;
min-width: 260px;
font-size: 12px;
}
`
]
})
export class GlPickerComponent implements OnInit, OnChanges, AfterViewInit, OnDestroy {
@Input() options: GlOption[] = [];
@Input() value: number | null = null;
@Input() placeholder = 'Select GL account';
@Input() allowClear = false;
/** Open the dropdown as soon as it is rendered (used for inline row editing). */
@Input() openOnInit = false;
@Output() valueChange = new EventEmitter<number | null>();
/** Fired when the dropdown panel closes, whether or not a value was picked. */
@Output() closed = new EventEmitter<void>();
@ViewChild(MatSelect) private select?: MatSelect;
filterCtrl = new UntypedFormControl('');
filtered: GlOption[] = [];
private readonly destroy$ = new Subject<void>();
ngOnInit(): void {
this.filterCtrl.valueChanges.pipe(takeUntil(this.destroy$)).subscribe(() => this.applyFilter());
}
ngOnChanges(changes: SimpleChanges): void {
if (changes['options'] || changes['value']) {
this.applyFilter();
}
}
ngAfterViewInit(): void {
if (this.openOnInit) {
setTimeout(() => this.select?.open());
}
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
onOpenedChange(opened: boolean): void {
if (opened) {
this.filterCtrl.setValue('');
} else {
this.closed.emit();
}
}
/** Filter by code or name; the currently selected option always stays in the list so its label shows. */
private applyFilter(): void {
const q = ((this.filterCtrl.value as string) ?? '').trim().toLowerCase();
const all = this.options ?? [];
if (!q) {
this.filtered = all.slice();
return;
}
const matches = all.filter((o) => o.label.toLowerCase().includes(q));
const selected = all.find((o) => o.id === this.value);
this.filtered = selected && !matches.includes(selected) ? [
selected,
...matches
] : matches;
}
}
@@ -0,0 +1,183 @@
<!--
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="lirr">
<div class="lirr__header">
<div class="lirr__header-icon"><i class="fas fa-percentage"></i></div>
<div class="lirr__header-text">
<h2>Loan Interest Rate Register</h2>
<p>
Manage the central CIBIL-based rate chart used across all loan products. Submitting a new version routes through
maker-checker — once approved, the current Active version is <strong>deactivated</strong> and the new one
<strong>activated</strong>.
</p>
</div>
<button mat-stroked-button class="lirr__excel-btn">
<i class="fas fa-exchange-alt"></i>
Excel
</button>
</div>
<div class="lirr__body">
<div class="lirr__kpi-grid">
<div class="lirr__kpi">
<div class="lirr__kpi-label">ACTIVE REGISTER</div>
<div class="lirr__kpi-value">{{ active?.name ?? 'None' }}</div>
<div class="lirr__kpi-sub lirr__kpi-sub--ok">Effective {{ activeWindow() }}</div>
</div>
<div class="lirr__kpi">
<div class="lirr__kpi-label">SLABS</div>
<div class="lirr__kpi-value">10 amount slabs</div>
<div class="lirr__kpi-sub">₹10k → &gt; ₹25L</div>
</div>
<div class="lirr__kpi">
<div class="lirr__kpi-label">CIBIL ADJUSTMENTS</div>
<div class="lirr__kpi-value">{{ active?.cibilSlabs?.length ?? 0 }} slabs</div>
<div class="lirr__kpi-sub">+2.00% (poor) → base (above 700)</div>
</div>
</div>
<div class="lirr__history">
<div class="lirr__history-head">
<div>
<div class="lirr__history-title">Loan Interest Rate Register — Version History</div>
<div class="lirr__history-sub">All versions, newest first · click a version to expand its rate chart</div>
</div>
</div>
<div class="lirr__empty" *ngIf="registers.length === 0">
<div class="lirr__empty-icon"><i class="fas fa-folder-open"></i></div>
<div class="lirr__empty-title">No rate register versions yet</div>
<div class="lirr__empty-sub">
Create the first version to publish rates for all loan products. It starts with the standard loan types (rates
blank) and 5 CIBIL bands — fill in the rates, add any bank-specific products, and submit for approval.
</div>
<button mat-flat-button color="primary" class="lirr__empty-btn" (click)="createFirstVersion()">
<i class="fas fa-plus"></i> Create first version
</button>
</div>
<mat-accordion class="lirr__accordion" multi *ngIf="registers.length > 0">
<mat-expansion-panel
*ngFor="let r of registers"
class="lirr__panel"
[class.lirr__panel--active]="r.status === 'Active'"
[expanded]="r.status === 'Active'"
>
<mat-expansion-panel-header class="lirr__panel-header">
<mat-panel-title class="lirr__panel-title">
<span class="lirr__panel-name">{{ r.name || '(Untitled draft)' }}</span>
<span [class]="statusChipClass(r.status)">{{ statusLabel(r.status) }}</span>
</mat-panel-title>
<mat-panel-description class="lirr__panel-desc">
<span class="lirr__panel-range">{{ effectiveRange(r) }}</span>
<span class="lirr__panel-actions">
<ng-container [ngSwitch]="r.status">
<ng-container *ngSwitchCase="'Pending Approval'">
<button
mat-stroked-button
class="lirr__row-btn lirr__row-btn--neutral"
(click)="openCompare(r, $event)"
>
Compare
</button>
<span class="lirr__row-note">Awaiting checker · action from Approvals tab</span>
</ng-container>
<ng-container *ngSwitchCase="'Draft'">
<button
mat-stroked-button
class="lirr__row-btn lirr__row-btn--neutral"
(click)="openEditor(r, $event)"
>
Edit · Submit
</button>
</ng-container>
<ng-container *ngSwitchCase="'Active'">
<button
mat-stroked-button
class="lirr__row-btn lirr__row-btn--neutral"
(click)="editAsNewVersion(r, $event)"
>
Edit (new version)
</button>
</ng-container>
<ng-container *ngSwitchDefault>
<button
mat-stroked-button
class="lirr__row-btn lirr__row-btn--neutral"
(click)="openEditor(r, $event)"
>
View
</button>
</ng-container>
</ng-container>
</span>
</mat-panel-description>
</mat-expansion-panel-header>
<div class="lirr__panel-body">
<div class="lirr__panel-meta" *ngIf="r.note">{{ r.note }}</div>
<div class="lirr__panel-meta">
Created by {{ r.createdBy }} · Last modified {{ r.lastModified | date: 'dd MMM yyyy' }}
</div>
<h4 class="lirr__panel-section">Rate chart ({{ r.rows.length }} products × {{ r.slabs.length }} slabs)</h4>
<div class="lirr__panel-scroll">
<table class="lirr__panel-grid">
<thead>
<tr>
<th class="lirr__panel-grid-first">Particulars</th>
<th *ngFor="let s of r.slabs" class="lirr__panel-grid-num">{{ s.label }}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let row of r.rows">
<td class="lirr__panel-grid-first">
<span class="lirr__panel-grid-srno">{{ row.srNo }}</span
>{{ row.particulars }}
<span class="lirr__gl-tag" [class.lirr__gl-tag--unmapped]="row.glAccountId == null">{{
row.glAccountId == null ? 'GL not mapped' : glLabel(row.glAccountId)
}}</span>
</td>
<td *ngFor="let cell of row.rates" class="lirr__panel-grid-num">{{ display(cell) }}</td>
</tr>
</tbody>
</table>
</div>
<h4 class="lirr__panel-section">CIBIL / credit-score slabs</h4>
<table class="lirr__panel-cibil">
<thead>
<tr>
<th>#</th>
<th>Score from</th>
<th>Score to</th>
<th>Risk tier</th>
<th>Type</th>
<th>Adjustment</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let slab of r.cibilSlabs; let i = index">
<td>{{ i + 1 }}</td>
<td>{{ slab.from }}</td>
<td>{{ slab.to }}</td>
<td>{{ slab.tier }}</td>
<td>{{ slab.type }}</td>
<td>{{ formatPct(slab.adjustmentPct) }}</td>
</tr>
</tbody>
</table>
</div>
</mat-expansion-panel>
</mat-accordion>
<div class="lirr__history-foot">Showing {{ registers.length }} versions</div>
</div>
</div>
</div>
@@ -0,0 +1,524 @@
/**
* 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/.
*/
.lirr {
background: #f8fafc;
min-height: calc(100vh - 160px);
&__header {
display: flex;
align-items: center;
gap: 14px;
padding: 22px 32px 14px;
background: white;
border-bottom: 1px solid #e2e8f0;
}
&__header-icon {
width: 40px;
height: 40px;
border-radius: 9px;
background: #eff6ff;
color: #1d4ed8;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
}
&__header-text {
flex: 1;
h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
p {
margin: 3px 0 0;
font-size: 12px;
color: #64748b;
}
}
&__excel-btn,
&__create-btn {
i {
margin-right: 6px;
}
}
&__body {
padding: 20px 32px;
}
&__kpi-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 12px;
margin-bottom: 18px;
}
&__kpi {
background: white;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 14px 16px;
}
&__kpi-label {
font-size: 11px;
color: #64748b;
font-weight: 600;
letter-spacing: 0.3px;
}
&__kpi-value {
font-size: 18px;
font-weight: 600;
margin-top: 4px;
}
&__kpi-sub {
font-size: 11px;
color: #64748b;
margin-top: 4px;
&--ok {
color: #059669;
}
}
&__table-card {
background: white;
border: 1px solid #e2e8f0;
border-radius: 10px;
overflow: hidden;
}
&__table-head {
padding: 12px 20px;
border-bottom: 1px solid #f1f5f9;
display: flex;
align-items: center;
}
&__table-title {
font-size: 14px;
font-weight: 600;
}
&__table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
thead {
background: #f8fafc;
color: #64748b;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.3px;
th {
text-align: left;
padding: 10px 12px;
font-weight: 600;
&:first-child {
padding-left: 20px;
}
}
}
tbody tr {
border-top: 1px solid #f1f5f9;
cursor: pointer;
transition: background-color 0.1s;
&:hover {
background: #f8fafc;
}
td {
padding: 12px;
&:first-child {
padding-left: 20px;
}
}
}
}
&__row--active {
background: #f0fdf4;
}
&__name {
font-weight: 600;
}
&__name-sub {
font-size: 11px;
color: #64748b;
margin-top: 2px;
}
&__row-actions {
text-align: right;
color: #64748b;
padding-right: 20px !important;
white-space: nowrap;
}
&__row-btn {
height: 28px;
font-size: 11px;
font-weight: 600;
padding: 0 10px;
margin-left: 6px;
border-radius: 6px;
&--neutral {
color: #334155;
border-color: #cbd5e1;
background: white;
&:hover {
background: #f8fafc;
}
}
&--approve {
color: #065f46;
border-color: #a7f3d0;
background: #ecfdf5;
&:hover {
background: #d1fae5;
}
}
&--reject {
color: #991b1b;
border-color: #fecaca;
background: #fef2f2;
&:hover {
background: #fee2e2;
}
}
}
&__row-note {
font-size: 11px;
color: #64748b;
font-style: italic;
margin-left: 10px;
}
&__table-foot {
padding: 10px 20px;
border-top: 1px solid #f1f5f9;
font-size: 12px;
color: #64748b;
}
/* ---- Version History accordion (CBS BIR style) ------------------------ */
&__history {
background: white;
border: 1px solid #e2e8f0;
border-radius: 12px;
overflow: hidden;
}
&__history-head {
padding: 16px 20px;
border-bottom: 1px solid #f1f5f9;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
&__history-title {
font-size: 15px;
font-weight: 600;
color: #0f172a;
}
&__history-sub {
margin-top: 2px;
font-size: 12px;
color: #64748b;
}
&__accordion {
display: block;
padding: 8px 12px 16px;
}
&__panel {
border: 1px solid #e2e8f0 !important;
border-radius: 10px !important;
margin-bottom: 10px;
box-shadow: none !important;
overflow: hidden;
&.mat-expanded {
border-color: #cbd5e1 !important;
}
&--active {
border-left: 3px solid #10b981 !important;
}
}
&__panel-header {
padding: 0 20px !important;
height: 56px;
}
&__panel-title {
display: flex !important;
align-items: center !important;
gap: 12px !important;
flex: 0 0 auto !important;
margin-right: 24px !important;
}
&__panel-name {
font-size: 14px;
font-weight: 600;
color: #0f172a;
}
&__panel-desc {
display: flex !important;
align-items: center !important;
justify-content: space-between !important;
gap: 12px;
flex: 1 1 auto !important;
color: #475569 !important;
}
&__panel-range {
font-size: 12px;
color: #475569;
font-variant-numeric: tabular-nums;
}
&__panel-actions {
display: inline-flex;
gap: 6px;
align-items: center;
}
&__panel-body {
padding: 8px 20px 20px;
font-size: 12px;
color: #334155;
display: flex;
flex-direction: column;
gap: 10px;
}
&__panel-meta {
font-size: 12px;
color: #64748b;
}
&__panel-section {
margin: 10px 0 4px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #64748b;
font-weight: 700;
}
&__panel-scroll {
overflow: auto;
max-height: 360px;
border: 1px solid #f1f5f9;
border-radius: 8px;
}
&__panel-grid {
width: 100%;
border-collapse: collapse;
font-size: 12px;
thead th {
position: sticky;
top: 0;
background: #f8fafc;
color: #64748b;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.3px;
font-weight: 600;
padding: 8px 10px;
border-bottom: 1px solid #e2e8f0;
white-space: nowrap;
}
tbody td {
padding: 7px 10px;
border-bottom: 1px solid #f1f5f9;
}
}
&__panel-grid-first {
text-align: left;
max-width: 240px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
&__panel-grid-num {
text-align: right;
font-variant-numeric: tabular-nums;
}
&__panel-grid-srno {
display: inline-block;
min-width: 22px;
color: #94a3b8;
font-weight: 600;
margin-right: 6px;
}
&__panel-cibil {
width: 100%;
border-collapse: collapse;
font-size: 12px;
border: 1px solid #f1f5f9;
border-radius: 8px;
overflow: hidden;
thead th {
background: #f8fafc;
color: #64748b;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.3px;
font-weight: 600;
padding: 8px 12px;
text-align: left;
border-bottom: 1px solid #e2e8f0;
}
tbody td {
padding: 8px 12px;
border-bottom: 1px solid #f1f5f9;
}
}
&__gl-tag {
display: inline-block;
margin-left: 8px;
font-size: 10px;
font-weight: 600;
color: #475569;
background: #f1f5f9;
border-radius: 999px;
padding: 1px 8px;
&--unmapped {
color: #92400e;
background: #fef3c7;
}
}
&__history-foot {
padding: 10px 20px;
border-top: 1px solid #f1f5f9;
font-size: 12px;
color: #64748b;
}
&__empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 56px 24px 48px;
text-align: center;
}
&__empty-icon {
width: 56px;
height: 56px;
border-radius: 50%;
background: #ede9fe;
color: #6d28d9;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
margin-bottom: 4px;
}
&__empty-title {
font-size: 16px;
font-weight: 600;
color: #0f172a;
}
&__empty-sub {
font-size: 13px;
color: #64748b;
max-width: 560px;
line-height: 1.5;
}
&__empty-btn {
margin-top: 8px;
i {
margin-right: 6px;
}
}
}
.chip {
padding: 3px 10px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
&--active {
background: #dcfce7;
color: #166534;
}
&--draft {
background: #fef3c7;
color: #92400e;
}
&--pending {
background: #ede9fe;
color: #5b21b6;
}
&--expired {
background: #f1f5f9;
color: #475569;
}
&--scheduled {
background: #e0e7ff;
color: #3730a3;
}
}
@@ -0,0 +1,197 @@
/**
* 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 { DatePipe } from '@angular/common';
import { Router } from '@angular/router';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatExpansionModule } from '@angular/material/expansion';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { RateRegisterService } from './rate-register.service';
import { CibilSlab, RateCell, RateChartRow, RateRegister, SlabColumn } from './models/rate-register.model';
/**
* List screen for the central Loan Interest Rate Register — three KPI cards followed by the
* registers table (name, effective window, status, author, last modified).
*/
@Component({
selector: 'mifosx-loan-interest-rate-register',
templateUrl: './loan-interest-rate-register.component.html',
styleUrls: ['./loan-interest-rate-register.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
DatePipe,
MatExpansionModule
]
})
export class LoanInterestRateRegisterComponent implements OnInit {
private readonly service = inject(RateRegisterService);
private readonly router = inject(Router);
private readonly snack = inject(MatSnackBar);
registers: RateRegister[] = [];
active?: RateRegister;
ngOnInit(): void {
// Load the GL list once so mapped particulars show a readable label instead of "GL #id".
this.service.getGlOptions().subscribe();
this.reload();
}
glLabel(id: number | null | undefined): string | null {
return this.service.glLabel(id);
}
private reload(): void {
this.service.list().subscribe((rows) => {
this.registers = rows;
this.active = rows.find((r) => r.status === 'Active');
});
}
edit(register: RateRegister): void {
this.router.navigate(['/products/loan-interest-rate-register/edit'], { queryParams: { id: register.id } });
}
preview(register: RateRegister): void {
this.router.navigate(['/products/loan-interest-rate-register/preview'], { queryParams: { id: register.id } });
}
/** Route by status: Pending → Compare screen (checker view); otherwise the editor. */
openRow(register: RateRegister): void {
if (register.status === 'Pending Approval') {
this.preview(register);
} else {
this.edit(register);
}
}
/** Checker approves the pending register. The previously-Active one is deactivated. */
approve(register: RateRegister, event: Event): void {
event.stopPropagation();
this.service.approve(register.id).subscribe((updated) => {
if (updated) {
this.snack.open(`${updated.name} activated`, 'OK', { duration: 3000 });
this.reload();
}
});
}
/** Checker rejects the pending register — it goes back to Draft. */
reject(register: RateRegister, event: Event): void {
event.stopPropagation();
this.service.reject(register.id).subscribe((updated) => {
if (updated) {
this.snack.open(`${updated.name} sent back to Draft`, 'OK', { duration: 3000 });
this.reload();
}
});
}
statusChipClass(status: RateRegister['status']): string {
switch (status) {
case 'Active':
return 'chip chip--active';
case 'Draft':
return 'chip chip--draft';
case 'Pending Approval':
return 'chip chip--pending';
case 'Expired':
return 'chip chip--expired';
default:
return 'chip chip--scheduled';
}
}
statusLabel(status: RateRegister['status']): string {
switch (status) {
case 'Active':
return '● Active';
case 'Pending Approval':
return '⏳ Pending Approval';
default:
return status;
}
}
activeWindow(): string {
if (!this.active) {
return '—';
}
return `${this.formatShort(this.active.effectiveFrom)} → ${this.formatShort(this.active.effectiveTo)}`;
}
/** Panel header subtitle — effective date range, with "Current" for the active one. */
effectiveRange(r: RateRegister): string {
const from = this.formatShort(r.effectiveFrom);
const to = r.status === 'Active' ? 'Current' : this.formatShort(r.effectiveTo);
return `From ${from} To ${to}`;
}
/** Compact display of a rate cell (for the read-only expanded view). */
display(cell: RateCell): string {
if (cell === null) {
return 'N.A.';
}
if (typeof cell === 'string') {
return cell;
}
return cell.toFixed(2);
}
formatPct(pct: number): string {
if (pct === 0) {
return '0.00%';
}
const sign = pct > 0 ? '+' : '−';
return `${sign}${Math.abs(pct).toFixed(2)}%`;
}
/** Open this register's full editor (grid) — stops the row click from toggling the panel. */
openEditor(r: RateRegister, event: Event): void {
event.stopPropagation();
this.edit(r);
}
/**
* "Edit (new version)" on Active: duplicate the Active register as a new Draft first, then
* open the Draft in the editor. The live Active is never mutated this way.
*/
editAsNewVersion(r: RateRegister, event: Event): void {
event.stopPropagation();
const draft = this.service.duplicateAsDraft(r.id);
if (draft) {
this.snack.open('New draft created from Active — editing the copy', 'OK', { duration: 2500 });
this.reload();
this.edit(draft);
}
}
/** Scaffold the very first version — used only when the register has no versions at all. */
createFirstVersion(): void {
this.service.createFirstVersion().subscribe((draft) => {
this.snack.open('Draft created — fill in rates and submit for approval', 'OK', { duration: 3000 });
this.reload();
this.edit(draft);
});
}
/** Open the Compare screen for a pending register (checker view). */
openCompare(r: RateRegister, event: Event): void {
event.stopPropagation();
this.preview(r);
}
private formatShort(iso: string | null): string {
if (!iso) {
return '—';
}
const d = new Date(iso);
return d.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: '2-digit' });
}
}
@@ -0,0 +1,63 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
export type RegisterStatus = 'Active' | 'Draft' | 'Pending Approval' | 'Expired' | 'Scheduled';
export interface SlabColumn {
key: string;
label: string;
amountUpto: number | null;
}
/** A single rate cell: number for a %, string for a formula ("1% + FD"), null for N.A. */
export type RateCell = number | string | null;
export interface RateChartRow {
srNo: number;
particulars: string;
isFormula?: boolean;
/** GL account this particular is mapped to. null/undefined = "GL not mapped". */
glAccountId?: number | null;
rates: RateCell[];
}
/** One selectable GL account for the particular-to-GL mapping. */
export interface GlOption {
id: number;
label: string;
}
export type RiskTier = 'V. Poor' | 'Poor' | 'Fair' | 'Good' | 'Excellent';
export interface CibilSlab {
from: number;
to: number;
tier: RiskTier;
type: 'Flat';
adjustmentPct: number;
}
export interface RateRegister {
id: number;
name: string;
effectiveFrom: string;
effectiveTo: string | null;
currency: string;
status: RegisterStatus;
createdBy: string;
lastModified: string;
note?: string;
slabs: SlabColumn[];
rows: RateChartRow[];
cibilSlabs: CibilSlab[];
/** Immutable snapshot of the last-approved state. Compare screen diffs against this. */
baselineRows: RateChartRow[];
baselineCibilSlabs: CibilSlab[];
fallbackNoCibilSlab: number;
fallbackBelowMin: 'use-min-slab' | 'block';
}
@@ -0,0 +1,83 @@
<!--
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="pr" *ngIf="draft">
<div class="pr__header">
<div class="pr__header-icon">⇄</div>
<div class="pr__header-text">
<h2>Compare changes</h2>
<p>Review the diff before submitting · each changed cell shows OLD → NEW with yellow highlight</p>
</div>
<button mat-stroked-button (click)="backToEdit()">← Back to edit</button>
</div>
<div class="pr__summary">
<div class="pr__summary-item">
<div class="pr__summary-label">Draft</div>
<div class="pr__summary-value">{{ draft.name }}</div>
</div>
<div class="pr__summary-item">
<div class="pr__summary-label">Rate cells changed</div>
<div class="pr__summary-value pr__summary-value--count">{{ changedCellCount() }}</div>
</div>
<div class="pr__summary-item">
<div class="pr__summary-label">CIBIL slabs changed</div>
<div class="pr__summary-value pr__summary-value--count">{{ changedCibilCount() }}</div>
</div>
</div>
<!-- Side-by-side baseline/draft context strip -->
<div class="pr__version-strip">
<div class="pr__version pr__version--old">
<div class="pr__version-pill pr__version-pill--old">OLD · WILL BE DEACTIVATED</div>
<div class="pr__version-name">{{ active?.name ?? 'No active register' }}</div>
<div class="pr__version-sub" *ngIf="active">
Effective {{ active.effectiveFrom }} → {{ active.effectiveTo || '—' }}
</div>
</div>
<div class="pr__version-arrow">→</div>
<div class="pr__version pr__version--new">
<div class="pr__version-pill pr__version-pill--new">NEW · WILL BE ACTIVATED</div>
<div class="pr__version-name">{{ draft.name }}</div>
<div class="pr__version-sub">Effective {{ draft.effectiveFrom }} → {{ draft.effectiveTo || '—' }}</div>
</div>
</div>
<mifosx-rate-register-diff
[slabs]="slabs"
[draftRows]="draftRows"
[baselineRows]="baselineRows"
[draftCibil]="draftCibil"
[baselineCibil]="baselineCibil"
></mifosx-rate-register-diff>
<div class="pr__footer">
<button mat-stroked-button (click)="backToEdit()">{{ canSubmit ? 'Cancel' : 'Back' }}</button>
<div class="pr__footer-note" *ngIf="canSubmit">
Submission will create a maker-checker request · the register becomes effective only after a checker approves it.
</div>
<div class="pr__footer-note" *ngIf="canSubmit && unmappedCount() > 0">
{{ unmappedCount() }} product(s) have no GL account. Go back to edit and map a GL for every product before
submitting.
</div>
<div class="pr__footer-note" *ngIf="!canSubmit">
Review-only — this version has already been submitted ({{ draft.status }}).
</div>
<button
*ngIf="canSubmit"
mat-flat-button
color="primary"
class="pr__submit"
(click)="submit()"
[disabled]="submitting || unmappedCount() > 0 || changedCellCount() + changedCibilCount() === 0"
>
<i class="fas fa-paper-plane"></i>
{{ submitting ? 'Submitting…' : 'Submit for approval' }}
</button>
</div>
</div>
@@ -0,0 +1,185 @@
/**
* 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/.
*/
.pr {
background: #f8fafc;
min-height: calc(100vh - 160px);
padding: 24px 32px;
display: flex;
flex-direction: column;
gap: 16px;
&__header {
display: flex;
align-items: center;
gap: 14px;
padding: 20px 24px;
background: white;
border: 1px solid #e2e8f0;
border-radius: 12px;
}
&__header-icon {
width: 42px;
height: 42px;
border-radius: 10px;
background: #ede9fe;
color: #6d28d9;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 700;
}
&__header-text {
flex: 1;
h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
color: #0f172a;
}
p {
margin: 3px 0 0;
font-size: 12px;
color: #64748b;
}
}
&__summary {
display: grid;
grid-template-columns: 2fr 1fr 1fr;
gap: 12px;
padding: 14px 20px;
background: white;
border: 1px solid #e2e8f0;
border-radius: 12px;
}
&__summary-item {
display: flex;
flex-direction: column;
gap: 4px;
}
&__summary-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.4px;
color: #64748b;
font-weight: 600;
}
&__summary-value {
font-size: 14px;
font-weight: 600;
color: #0f172a;
&--count {
font-size: 20px;
font-weight: 700;
color: #6d28d9;
}
}
// ---- OLD → NEW version strip ------------------------------------------
&__version-strip {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 16px;
align-items: stretch;
padding: 14px 20px;
background: white;
border: 1px solid #e2e8f0;
border-radius: 12px;
}
&__version {
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px 14px;
border-radius: 8px;
&--old {
background: #fef2f2;
border-left: 3px solid #dc2626;
}
&--new {
background: #f0fdf4;
border-left: 3px solid #059669;
}
}
&__version-pill {
display: inline-block;
width: fit-content;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.5px;
padding: 3px 10px;
border-radius: 999px;
&--old {
background: #fee2e2;
color: #991b1b;
}
&--new {
background: #d1fae5;
color: #065f46;
}
}
&__version-name {
font-size: 13px;
font-weight: 600;
color: #0f172a;
}
&__version-sub {
font-size: 11px;
color: #64748b;
}
&__version-arrow {
align-self: center;
font-size: 24px;
color: #94a3b8;
font-weight: 700;
}
// ---- Footer -----------------------------------------------------------
&__footer {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 20px;
background: white;
border: 1px solid #e2e8f0;
border-radius: 12px;
}
&__footer-note {
flex: 1;
font-size: 12px;
color: #64748b;
}
&__submit {
i {
margin-right: 6px;
}
}
}
@@ -0,0 +1,161 @@
/**
* 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 { ActivatedRoute, Router } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatDialog } from '@angular/material/dialog';
import {
SubmitDetailsData,
SubmitDetailsDialogComponent,
SubmitDetailsResult
} from '../submit-details-dialog/submit-details-dialog.component';
import { RateRegisterService } from '../rate-register.service';
import { CibilSlab, RateChartRow, RateRegister, SlabColumn } from '../models/rate-register.model';
import { RateRegisterDiffComponent } from '../rate-register-diff/rate-register-diff.component';
import { countChangedCells, countChangedCibil } from '../rate-register-diff.util';
/**
* Inline-diff Compare screen for a Loan Interest Rate Register draft.
*
* Shows a single consolidated matrix where every cell renders either its unchanged value or an
* "OLD → NEW" diff with highlight. A "Show only changed rows" toggle filters out rows with no
* changes so the reviewer can focus on what moved.
*
* Submit calls the mock backend; a `rollbackTransaction:true` response means the row has landed
* in the checker inbox and effective activation waits for approval.
*/
@Component({
selector: 'mifosx-preview-register',
templateUrl: './preview-register.component.html',
styleUrls: ['./preview-register.component.scss'],
imports: [
CommonModule,
MatButtonModule,
RateRegisterDiffComponent
]
})
export class PreviewRegisterComponent implements OnInit {
private readonly service = inject(RateRegisterService);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly snack = inject(MatSnackBar);
private readonly dialog = inject(MatDialog);
draft!: RateRegister;
active?: RateRegister;
slabs: SlabColumn[] = [];
draftRows: RateChartRow[] = [];
baselineRows: RateChartRow[] = [];
draftCibil: CibilSlab[] = [];
baselineCibil: CibilSlab[] = [];
submitting = false;
/** Submit button is live only for a Draft. A Pending Approval or Active register is review-only here. */
get canSubmit(): boolean {
return this.draft?.status === 'Draft';
}
/** Products still without a GL; the backend rejects the save while any remain. */
unmappedCount(): number {
return this.draftRows.filter((r) => r.glAccountId == null).length;
}
ngOnInit(): void {
const idParam = this.route.snapshot.queryParamMap.get('id');
const id = idParam ? Number(idParam) : null;
this.service.list().subscribe((all) => {
const found = (id ? all.find((r) => r.id === id) : undefined) ?? this.service.getActiveOrFirstDraft();
if (!found) {
this.router.navigate(['/products/loan-interest-rate-register']);
return;
}
this.draft = found;
this.active = this.service.getActive();
this.slabs = this.draft.slabs;
this.draftRows = this.draft.rows;
this.draftCibil = this.draft.cibilSlabs;
// Compare always diffs against a frozen baseline — the currently-Active register's
// last-approved snapshot, OR the draft's own baseline when editing Active in place.
const base = this.draft.id === this.active?.id ? this.draft : (this.active ?? this.draft);
this.baselineRows = base.baselineRows;
this.baselineCibil = base.baselineCibilSlabs;
});
}
changedCellCount(): number {
return countChangedCells(this.draftRows, this.baselineRows, this.slabs.length);
}
changedCibilCount(): number {
return countChangedCibil(this.draftCibil, this.baselineCibil);
}
/** Opens the mock backend call. `rollbackTransaction:true` → row sits in checker inbox. */
submit(): void {
if (this.submitting) {
return;
}
const ref = this.dialog.open<SubmitDetailsDialogComponent, SubmitDetailsData, SubmitDetailsResult>(
SubmitDetailsDialogComponent,
{
width: '520px',
maxWidth: '95vw',
data: {
name: this.draft.name,
effectiveFrom: this.draft.effectiveFrom,
effectiveTo: this.draft.effectiveTo ?? '',
activeName: this.active && this.active.id !== this.draft.id ? this.active.name : undefined,
activeFrom: this.active && this.active.id !== this.draft.id ? this.active.effectiveFrom : undefined
}
}
);
ref.afterClosed().subscribe((details) => {
if (!details) {
return;
}
this.draft.name = details.name;
this.draft.effectiveFrom = details.effectiveFrom;
this.draft.effectiveTo = details.effectiveTo || null;
this.service.saveDrafts();
this.send();
});
}
private send(): void {
this.submitting = true;
this.service.submitForApproval(this.draft.id).subscribe({
next: (res) => {
this.submitting = false;
const msg = res.rollbackTransaction ? 'Submitted — awaiting checker approval' : 'Changes activated';
this.snack.open(msg, 'OK', { duration: 3500 });
this.router.navigate(['/products/loan-interest-rate-register']);
},
error: (err) => {
// Belt-and-braces guard — the service already catches, but if anything slips through
// we still unlock the UI so the user can retry instead of staring at a spinner forever.
this.submitting = false;
console.error('[PreviewRegister] submit failed', err);
const reason =
err?.error?.errors?.[0]?.defaultUserMessage ||
err?.error?.defaultUserMessage ||
err?.error?.message ||
'check backend status and try again';
this.snack.open(`Submit failed — ${reason}`, 'Dismiss', { duration: 8000 });
}
});
}
backToEdit(): void {
this.router.navigate(['/products/loan-interest-rate-register/edit'], { queryParams: { id: this.draft.id } });
}
}
@@ -0,0 +1,48 @@
/**
* 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 { CibilSlab, RateCell, RateChartRow } from './models/rate-register.model';
/** Two chart cells are the same when both are N.A., both the same formula, or numerically equal. */
export function sameCell(a: RateCell, b: RateCell): boolean {
if (a === null && b === null) {
return true;
}
if (typeof a === 'number' && typeof b === 'number') {
return Math.abs(a - b) < 1e-9;
}
return a === b;
}
/** Number of {product, amount-slab} cells whose value differs between the draft and the baseline. */
export function countChangedCells(draft: RateChartRow[], baseline: RateChartRow[], slabCount: number): number {
let n = 0;
for (const row of draft) {
const base = baseline.find((r) => r.srNo === row.srNo);
for (let j = 0; j < slabCount; j++) {
if (!sameCell(base?.rates[j] ?? null, row.rates[j] ?? null)) {
n++;
}
}
}
return n;
}
/** Number of CIBIL bands that differ (by position) between the draft and the baseline. */
export function countChangedCibil(draft: CibilSlab[], baseline: CibilSlab[]): number {
let n = 0;
const max = Math.max(draft.length, baseline.length);
for (let i = 0; i < max; i++) {
const a = baseline[i];
const d = draft[i];
if (!a || !d || a.from !== d.from || a.to !== d.to || a.tier !== d.tier || a.adjustmentPct !== d.adjustmentPct) {
n++;
}
}
return n;
}
@@ -0,0 +1,118 @@
<!--
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/.
-->
<!-- Consolidated inline-diff rate chart -->
<div class="rd__card">
<div class="rd__card-head">
<div>
<div class="rd__card-title">Rate chart — inline diff</div>
<div class="rd__card-sub">Changed cells highlighted · unchanged cells show single value</div>
</div>
<mat-slide-toggle *ngIf="changedCellCount() > 0" [(ngModel)]="showOnlyChanged" class="rd__toggle">
Show only changed rows
</mat-slide-toggle>
</div>
<div class="rd__empty" *ngIf="changedCellCount() === 0 && changedCibilCount() === 0">
<i class="fas fa-equals"></i>
<div class="rd__empty-title">No changes detected</div>
<div class="rd__empty-sub">The proposed version is identical to the current Active register.</div>
</div>
<div class="rd__scroll" *ngIf="changedCellCount() > 0">
<table class="rd__grid">
<thead>
<tr>
<th class="rd__col-particulars">Particulars</th>
<th *ngFor="let s of slabs" class="rd__col-rate">{{ s.label }}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let row of visibleRows" [class.rd__row--changed]="rowHasChanges(row.srNo)">
<td class="rd__col-particulars">
<span class="rd__srno">{{ row.srNo }}</span
>{{ row.particulars }}
<div>
<span class="rd__gl-tag" [class.rd__gl-tag--unmapped]="row.glAccountId == null">{{ glText(row) }}</span>
</div>
</td>
<td
*ngFor="let _ of slabs; let j = index"
class="rd__col-rate"
[class.rd__cell-changed]="cellChanged(row.srNo, j)"
>
<ng-container *ngIf="cellChanged(row.srNo, j); else sameCell">
<span class="rd__old">{{ display(baselineCell(row.srNo, j)) }}</span>
<span class="rd__arrow">→</span>
<span class="rd__new">{{ display(draftCell(row.srNo, j)) }}</span>
</ng-container>
<ng-template #sameCell>{{ display(draftCell(row.srNo, j)) }}</ng-template>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- CIBIL slabs inline-diff -->
<div class="rd__card" *ngIf="changedCibilCount() > 0">
<div class="rd__card-head">
<div>
<div class="rd__card-title">CIBIL slabs — inline diff</div>
<div class="rd__card-sub">Changed fields shown as OLD → NEW · unchanged fields render once</div>
</div>
</div>
<table class="rd__cibil">
<thead>
<tr>
<th>#</th>
<th>Score from</th>
<th>Score to</th>
<th>Tier</th>
<th>Adjustment</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let slab of draftCibil; let i = index" [class.rd__row--changed]="cibilChanged(i)">
<td>{{ i + 1 }}</td>
<td [class.rd__cell-changed]="cibilFieldChanged(i, 'from')">
<ng-container *ngIf="cibilFieldChanged(i, 'from'); else sameFrom">
<span class="rd__old">{{ baselineCibil[i]?.from ?? '—' }}</span>
<span class="rd__arrow">→</span>
<span class="rd__new">{{ slab.from }}</span>
</ng-container>
<ng-template #sameFrom>{{ slab.from }}</ng-template>
</td>
<td [class.rd__cell-changed]="cibilFieldChanged(i, 'to')">
<ng-container *ngIf="cibilFieldChanged(i, 'to'); else sameTo">
<span class="rd__old">{{ baselineCibil[i]?.to ?? '—' }}</span>
<span class="rd__arrow">→</span>
<span class="rd__new">{{ slab.to }}</span>
</ng-container>
<ng-template #sameTo>{{ slab.to }}</ng-template>
</td>
<td [class.rd__cell-changed]="cibilFieldChanged(i, 'tier')">
<ng-container *ngIf="cibilFieldChanged(i, 'tier'); else sameTier">
<span class="rd__old">{{ baselineCibil[i]?.tier ?? '—' }}</span>
<span class="rd__arrow">→</span>
<span class="rd__new">{{ slab.tier }}</span>
</ng-container>
<ng-template #sameTier>{{ slab.tier }}</ng-template>
</td>
<td [class.rd__cell-changed]="cibilFieldChanged(i, 'adjustmentPct')">
<ng-container *ngIf="cibilFieldChanged(i, 'adjustmentPct'); else sameAdj">
<span class="rd__old">{{ formatPct(baselineCibil[i]?.adjustmentPct ?? 0) }}</span>
<span class="rd__arrow">→</span>
<span class="rd__new">{{ formatPct(slab.adjustmentPct) }}</span>
</ng-container>
<ng-template #sameAdj>{{ formatPct(slab.adjustmentPct) }}</ng-template>
</td>
</tr>
</tbody>
</table>
</div>
@@ -0,0 +1,206 @@
/**
* 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: flex;
flex-direction: column;
gap: 16px;
}
.rd {
&__card {
background: white;
border: 1px solid #e2e8f0;
border-radius: 12px;
overflow: hidden;
}
&__card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 16px 20px;
border-bottom: 1px solid #f1f5f9;
}
&__card-title {
font-size: 15px;
font-weight: 600;
color: #0f172a;
}
&__card-sub {
margin-top: 2px;
font-size: 12px;
color: #64748b;
}
&__toggle {
font-size: 12px;
font-weight: 500;
}
// ---- Empty state (no changes) -----------------------------------------
&__empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 48px 20px;
color: #64748b;
i {
font-size: 28px;
color: #94a3b8;
}
}
&__empty-title {
font-size: 15px;
font-weight: 600;
color: #0f172a;
}
&__empty-sub {
font-size: 12px;
text-align: center;
max-width: 420px;
}
// ---- Scrollable rate chart --------------------------------------------
&__scroll {
overflow: auto;
max-height: 520px;
}
&__grid {
width: 100%;
border-collapse: collapse;
font-size: 12px;
thead th {
position: sticky;
top: 0;
background: #f8fafc;
color: #64748b;
font-size: 10px;
text-transform: uppercase;
font-weight: 600;
letter-spacing: 0.3px;
padding: 10px;
text-align: right;
border-bottom: 1px solid #e2e8f0;
white-space: nowrap;
}
thead th.rd__col-particulars {
text-align: left;
}
tbody td {
padding: 8px 10px;
border-bottom: 1px solid #f1f5f9;
text-align: right;
font-variant-numeric: tabular-nums;
color: #0f172a;
white-space: nowrap;
}
tbody td.rd__col-particulars {
text-align: left;
font-weight: 500;
max-width: 240px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
&__srno {
display: inline-block;
min-width: 24px;
color: #94a3b8;
font-weight: 600;
margin-right: 6px;
}
&__gl-tag {
display: inline-block;
margin-left: 30px;
font-size: 10px;
font-weight: 600;
color: #475569;
background: #f1f5f9;
border-radius: 999px;
padding: 1px 8px;
&--unmapped {
color: #92400e;
background: #fef3c7;
}
}
// ---- Row & cell highlights --------------------------------------------
&__row--changed {
background: #fffbeb;
}
&__cell-changed {
background: #fef3c7 !important;
font-weight: 700;
}
&__old {
color: #991b1b;
font-weight: 500;
text-decoration: line-through;
text-decoration-color: #dc2626;
text-decoration-thickness: 1px;
}
&__arrow {
color: #94a3b8;
margin: 0 4px;
font-weight: 700;
}
&__new {
color: #065f46;
font-weight: 700;
}
// ---- CIBIL inline-diff table ------------------------------------------
&__cibil {
width: 100%;
border-collapse: collapse;
font-size: 12px;
thead th {
background: #f8fafc;
color: #64748b;
font-size: 10px;
text-transform: uppercase;
font-weight: 600;
letter-spacing: 0.3px;
padding: 10px 14px;
text-align: left;
border-bottom: 1px solid #e2e8f0;
}
tbody td {
padding: 10px 14px;
border-bottom: 1px solid #f1f5f9;
color: #0f172a;
}
}
}
@@ -0,0 +1,121 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, Input, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { RateRegisterService } from '../rate-register.service';
import { CibilSlab, RateCell, RateChartRow, SlabColumn } from '../models/rate-register.model';
import { countChangedCells, countChangedCibil, sameCell } from '../rate-register-diff.util';
/**
* Inline-diff view of a proposed Loan Interest Rate Register version against a baseline: one rate
* chart where every changed cell reads OLD -> NEW, plus the CIBIL bands. Shared by the Compare
* screen (before submitting) and the Approvals dialog (before approving), so both show the same thing.
*/
@Component({
selector: 'mifosx-rate-register-diff',
templateUrl: './rate-register-diff.component.html',
styleUrls: ['./rate-register-diff.component.scss'],
imports: [
CommonModule,
FormsModule,
MatSlideToggleModule
]
})
export class RateRegisterDiffComponent implements OnInit {
private readonly service = inject(RateRegisterService);
@Input() slabs: SlabColumn[] = [];
@Input() draftRows: RateChartRow[] = [];
@Input() baselineRows: RateChartRow[] = [];
@Input() draftCibil: CibilSlab[] = [];
@Input() baselineCibil: CibilSlab[] = [];
/** User toggle to collapse the chart to only the rows that changed. */
showOnlyChanged = true;
ngOnInit(): void {
// Loads the GL list so mapped particulars show a readable label; the cache is shared.
this.service.getGlOptions().subscribe();
}
glText(row: RateChartRow): string {
return row.glAccountId == null ? 'GL not mapped' : (this.service.glLabel(row.glAccountId) ?? 'GL not mapped');
}
display(cell: RateCell): string {
if (cell === null) {
return 'N.A.';
}
if (typeof cell === 'string') {
return cell;
}
return cell.toFixed(2);
}
/** The baseline (OLD) value of a cell; null when the row did not exist in the baseline. */
baselineCell(srNo: number, colIdx: number): RateCell {
return this.baselineRows.find((r) => r.srNo === srNo)?.rates[colIdx] ?? null;
}
draftCell(srNo: number, colIdx: number): RateCell {
return this.draftRows.find((r) => r.srNo === srNo)?.rates[colIdx] ?? null;
}
cellChanged(srNo: number, colIdx: number): boolean {
return !sameCell(this.baselineCell(srNo, colIdx), this.draftCell(srNo, colIdx));
}
rowHasChanges(srNo: number): boolean {
for (let j = 0; j < this.slabs.length; j++) {
if (this.cellChanged(srNo, j)) {
return true;
}
}
return false;
}
/** Rows to render: all of them, or only those with a change when the toggle is on. */
get visibleRows(): RateChartRow[] {
if (!this.showOnlyChanged || this.changedCellCount() === 0) {
return this.draftRows;
}
return this.draftRows.filter((r) => this.rowHasChanges(r.srNo));
}
changedCellCount(): number {
return countChangedCells(this.draftRows, this.baselineRows, this.slabs.length);
}
changedCibilCount(): number {
return countChangedCibil(this.draftCibil, this.baselineCibil);
}
cibilChanged(index: number): boolean {
const a = this.baselineCibil[index];
const d = this.draftCibil[index];
return !a || !d || a.from !== d.from || a.to !== d.to || a.tier !== d.tier || a.adjustmentPct !== d.adjustmentPct;
}
/** Per-field highlight inside a CIBIL row. */
cibilFieldChanged(index: number, field: 'from' | 'to' | 'tier' | 'adjustmentPct'): boolean {
const a = this.baselineCibil[index];
const d = this.draftCibil[index];
return !a || !d || a[field] !== d[field];
}
formatPct(pct: number): string {
if (pct === 0) {
return '0.00%';
}
const sign = pct > 0 ? '+' : '−';
return `${sign}${Math.abs(pct).toFixed(2)}%`;
}
}
@@ -0,0 +1,647 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { inject, Injectable } from '@angular/core';
import { Observable, catchError, forkJoin, map, of, switchMap, tap } from 'rxjs';
import { ProductsService } from '../products.service';
import {
CibilSlab,
GlOption,
RateCell,
RateChartRow,
RateRegister,
RegisterStatus,
SlabColumn
} from './models/rate-register.model';
/**
* Central Loan Interest Rate Register service.
*
* Data comes from HTTP (/loanproducts/baseinterestrates); there is no built-in sample data. If the
* backend cannot be reached the list is empty apart from local Drafts (not-yet-submitted new
* versions), which live only in memory and are merged in front of server data on every list().
*/
@Injectable({ providedIn: 'root' })
export class RateRegisterService {
private readonly productsService = inject(ProductsService);
/** True while the HTTP backend is reachable. Flipped to false on first HTTP failure (dev mode fallback). */
private useHttp = true;
/** Snapshot of the last list() result — getActive() / getActiveOrFirstDraft() read from this. */
private cache: RateRegister[] = [];
/** In-memory drafts the maker has created but not yet submitted. Preserved across list() refreshes. */
private localDrafts: RateRegister[] = this.loadDrafts();
private static readonly DRAFTS_KEY = 'cbsLoanRateRegisterDrafts';
/** Drafts are kept in this browser's localStorage so a reload does not lose the maker's work. */
private loadDrafts(): RateRegister[] {
try {
const raw = localStorage.getItem(RateRegisterService.DRAFTS_KEY);
const parsed = raw ? (JSON.parse(raw) as RateRegister[]) : [];
return Array.isArray(parsed) ? parsed.filter((d) => d?.status === 'Draft') : [];
} catch {
return [];
}
}
/** Persist the current drafts (call after any edit the user should not lose). */
saveDrafts(): void {
try {
localStorage.setItem(
RateRegisterService.DRAFTS_KEY,
JSON.stringify(this.localDrafts.filter((d) => d.status === 'Draft'))
);
} catch {
/* storage unavailable (private mode / quota): the draft just stays in memory */
}
}
/** Base for the synthetic ids of pending server entries: -(base + commandId). Local drafts count down from -1. */
private static readonly PENDING_ID_BASE = 1_000_000;
/** One maker-checker entry (with its commandAsJson) as a read-only register, e.g. for the Approvals dialog. */
registerFromPendingEntry(entry: any): RateRegister | null {
return this.pendingToRegisters([entry])[0] ?? null;
}
/** A version as the backend returns it, as a UI register; used as the "old" side of a comparison. */
registerFromServerDto(dto: any): RateRegister {
return this.fromBackend(dto, new Map());
}
/** Turn the maker-checker entries awaiting a checker into read-only "Pending Approval" registers. */
private pendingToRegisters(entries: any): RateRegister[] {
const items: any[] = Array.isArray(entries) ? entries : (entries?.pageItems ?? []);
const out: RateRegister[] = [];
for (const e of items) {
if ((e?.entityName ?? '').toUpperCase() !== 'LOANBASEINTERESTRATE' || !e?.commandAsJson) {
continue;
}
let payload: any;
try {
payload = JSON.parse(e.commandAsJson);
} catch {
continue;
}
const glBySrNo = new Map<number, number | null>();
for (const p of payload.products ?? []) {
glBySrNo.set(p.srNo, p.glAccountId ?? null);
}
const madeOn =
typeof e.madeOnDate === 'number'
? new Date(e.madeOnDate < 1e12 ? e.madeOnDate * 1000 : e.madeOnDate).toISOString().slice(0, 10)
: String(e.madeOnDate ?? '').slice(0, 10);
const reg = this.fromBackend(
{
id: -(RateRegisterService.PENDING_ID_BASE + e.id),
name: payload.name,
effectiveFromDate: payload.effectiveFromDate ?? madeOn,
effectiveToDate: null,
currencyCode: payload.currencyCode,
isActive: false,
fallbackNoCibilSlab: payload.fallbackNoCibilSlab,
fallbackBelowMin: payload.fallbackBelowMin,
note: payload.note,
createdDate: madeOn,
slabs: payload.slabs,
cibilSlabs: payload.cibilSlabs
},
glBySrNo
);
reg.status = 'Pending Approval';
reg.createdBy = e.maker ?? 'Maker';
reg.lastModified = madeOn;
// With no Active register to diff against, the Compare screen falls back to this blank baseline.
reg.baselineRows = reg.rows.map((r) => ({
srNo: r.srNo,
particulars: r.particulars,
glAccountId: r.glAccountId ?? null,
rates: new Array(r.rates.length).fill(null)
}));
reg.baselineCibilSlabs = [];
out.push(reg);
}
return out;
}
private readonly slabs: SlabColumn[] = [
{ key: 's1', label: 'Upto 10,000', amountUpto: 10000 },
{ key: 's2', label: 'Upto 25,000', amountUpto: 25000 },
{ key: 's3', label: 'Upto 1,00,000', amountUpto: 100000 },
{ key: 's4', label: 'Upto 2,00,000', amountUpto: 200000 },
{ key: 's5', label: 'Upto 5,00,000', amountUpto: 500000 },
{ key: 's6', label: 'Upto 10,00,000', amountUpto: 1000000 },
{ key: 's7', label: 'Upto 15,00,000', amountUpto: 1500000 },
{ key: 's8', label: 'Upto 20,00,000', amountUpto: 2000000 },
{ key: 's9', label: 'Upto 25,00,000', amountUpto: 2500000 },
{ key: 's10', label: '> 25,00,000', amountUpto: null }
];
// Credit-score bands per the bank's approved register:
// above 700 = base rate (no change), below that stepped up by 0.5% per band.
// No discount band — the register never gives a negative adjustment.
private readonly cibilSlabs: CibilSlab[] = [
{ from: 0, to: 500, tier: 'V. Poor', type: 'Flat', adjustmentPct: 2.0 },
{ from: 501, to: 600, tier: 'Poor', type: 'Flat', adjustmentPct: 1.5 },
{ from: 601, to: 650, tier: 'Fair', type: 'Flat', adjustmentPct: 1.0 },
{ from: 651, to: 700, tier: 'Good', type: 'Flat', adjustmentPct: 0.5 },
{ from: 701, to: 900, tier: 'Excellent', type: 'Flat', adjustmentPct: 0 }
];
/**
* The standard loan types every bank offers. Same list the backend seeds into
* m_loan_rate_register_product; used only if that endpoint cannot be reached when the first
* version is created. Any other particular is added by the bank itself.
*/
private readonly defaultParticulars: { srNo: number; name: string }[] = [
{ srNo: 1, name: 'Housing Loan (Purchase/Construction)' },
{ srNo: 2, name: 'Home Repair/Renovation' },
{ srNo: 3, name: 'Personal Loan (Unsecured)' },
{ srNo: 4, name: 'Personal Loan (Secured/Property)' },
{ srNo: 5, name: 'Two Wheeler Loan' },
{ srNo: 6, name: 'Car Loan (New)' },
{ srNo: 7, name: 'Used Car/Vehicle Loan' },
{ srNo: 8, name: 'Commercial Vehicle Loan' },
{ srNo: 9, name: 'Gold Loan' },
{ srNo: 10, name: 'Business Term Loan' },
{ srNo: 11, name: 'Machinery & Equipment Loan' },
{ srNo: 12, name: 'Cash Credit' },
{ srNo: 13, name: 'Overdraft' },
{ srNo: 14, name: 'Agriculture Loan' },
{ srNo: 15, name: 'Loan against FDR' },
{ srNo: 16, name: 'Loan against RD' }
];
/**
* List all registers (local drafts + server versions). If the backend cannot be reached only the
* local drafts are returned: there is no built-in sample data. A successful but empty response is a
* genuinely empty register and shows the "Create first version" state.
*/
list(): Observable<RateRegister[]> {
if (!this.useHttp) {
this.cache = [...this.localDrafts];
return of(this.cache);
}
// Approved versions, the particulars master (each row's GL) and the versions still awaiting a
// checker. A failure of the master or the pending lookup must not hide the approved versions.
return forkJoin({
versions: this.productsService.getLoanBaseInterestRates(),
products: this.productsService.getLoanRateRegisterProducts().pipe(catchError(() => of([]))),
pending: this.productsService.getPendingLoanRateRegisters().pipe(
// The list omits each entry's payload, so fetch the detail of every pending entry.
switchMap((entries: any) => {
const items: any[] = (Array.isArray(entries) ? entries : (entries?.pageItems ?? [])).filter(
(e: any) => (e?.entityName ?? '').toUpperCase() === 'LOANBASEINTERESTRATE'
);
if (items.length === 0) {
return of([] as any[]);
}
return forkJoin(
items.map((e) =>
this.productsService.getLoanRateRegisterAuditDetail(e.id).pipe(
map((detail: any) => ({ ...detail, ...e })),
catchError(() => of(null))
)
)
).pipe(map((all) => all.filter((x) => x !== null)));
}),
catchError(() => of([] as any[]))
)
}).pipe(
map(({ versions, products, pending }) => {
const glBySrNo = new Map<number, number | null>();
for (const p of (products as any[]) ?? []) {
glBySrNo.set(p.srNo, p.glAccountId ?? null);
}
const approved = ((versions as any[]) ?? []).map((d) => this.fromBackend(d, glBySrNo));
return [
...this.pendingToRegisters(pending),
...approved
];
}),
catchError((err) => {
console.warn('[RateRegisterService] Backend unavailable —', err?.message ?? err);
this.useHttp = false;
return of([] as RateRegister[]);
}),
tap((effectiveVersions) => {
this.cache = [
...this.localDrafts,
...effectiveVersions
];
}),
map(() => this.cache)
);
}
get(id: number): Observable<RateRegister | undefined> {
const cached = this.cache.find((r) => r.id === id);
if (cached) {
return of(cached);
}
// Local draft lookup (negative ids never hit the server).
if (id < 0) {
return of(this.localDrafts.find((r) => r.id === id));
}
if (!this.useHttp) {
return of(undefined);
}
return this.productsService.getLoanBaseInterestRateById(id).pipe(
map((d: any) => (d ? this.fromBackend(d, new Map()) : undefined)),
catchError(() => of(undefined))
);
}
/** The one currently in force. Used by the Compare screen as the "left-hand" baseline. */
getActive(): RateRegister | undefined {
return this.cache.find((r) => r.status === 'Active');
}
/** The one shown by default on list → edit navigation. */
getActiveOrFirstDraft(): RateRegister | undefined {
return (
this.cache.find((r) => r.status === 'Draft') ?? this.cache.find((r) => r.status === 'Active') ?? this.cache[0]
);
}
getSlabColumns(): SlabColumn[] {
return this.slabs;
}
getCibilSlabs(): CibilSlab[] {
return this.cibilSlabs;
}
// ---- maker-checker (real HTTP when backend is up, mock when not) -----------------
/**
* Submit a Draft for approval. HTTP path POSTs to /loanproducts/baseinterestrates — on a
* maker-checker-gated response ({@code rollbackTransaction=true}) the Draft stays visible as
* "Pending Approval" until the checker acts. On an un-gated response (direct commit) the Draft
* is removed and the register list is re-fetched so the new Active appears.
*/
submitForApproval(id: number): Observable<{ rollbackTransaction: boolean; commandId?: number }> {
const draft = this.localDrafts.find((x) => x.id === id) ?? this.cache.find((x) => x.id === id);
if (!draft) {
return of({ rollbackTransaction: false });
}
const payload = this.toBackend(draft);
return this.productsService.saveLoanBaseInterestRate(payload).pipe(
map((res: any) => {
const rollback = res?.rollbackTransaction === true;
const commandId = res?.commandId;
// Either way the server now owns this version (a pending maker-checker entry, or the new
// Active one), so the local draft goes. list() brings the server copy back on its next call.
this.localDrafts = this.localDrafts.filter((d) => d.id !== id);
this.cache = this.cache.filter((c) => c.id !== id);
this.saveDrafts();
return { rollbackTransaction: rollback, commandId };
})
// No catchError: a rejected submit (e.g. a product without GL, backend down) must reach the
// caller so it can show the reason instead of pretending the request was sent.
);
}
/**
* Checker approves a Pending register — it becomes Active and the previously-Active one is
* deactivated. In real use the Approvals tab calls Fineract's /makercheckers/{id}?command=approve;
* this method remains as a local-only stub for the dev-fallback path.
*/
approve(id: number): Observable<RateRegister | undefined> {
const pending = this.cache.find((x) => x.id === id);
if (!pending || pending.status !== 'Pending Approval') {
return of(undefined);
}
const today = new Date().toISOString().slice(0, 10);
const previouslyActive = this.cache.find((x) => x.status === 'Active');
if (previouslyActive) {
previouslyActive.status = 'Expired';
previouslyActive.effectiveTo = today;
previouslyActive.lastModified = today;
}
pending.status = 'Active';
pending.effectiveFrom = today;
pending.lastModified = today;
pending.baselineRows = this.cloneRows(pending.rows);
pending.baselineCibilSlabs = this.cloneCibilSlabs(pending.cibilSlabs);
// Promote the local draft to the server-shaped view by moving it out of localDrafts.
this.localDrafts = this.localDrafts.filter((d) => d.id !== id);
return of(pending);
}
/**
* Scaffold the first-ever Draft. Rows come from the backend's master list of particulars
* (/loanproducts/baseinterestrates/products) with every rate blank, so the bank fills in its own
* rates. If the backend is unreachable, the same standard loan types come from a built-in list.
* Carries a negative id like other local drafts.
*/
createFirstVersion(): Observable<RateRegister> {
const fromTemplate = (): RateRegister =>
this.scaffoldFirstDraft(
this.defaultParticulars.map(
(p): RateChartRow => ({
srNo: p.srNo,
particulars: p.name,
glAccountId: null,
rates: new Array(this.slabs.length).fill(null)
})
),
'First version — standard loan types, rates to be filled'
);
if (!this.useHttp) {
return of(fromTemplate());
}
return this.productsService.getLoanRateRegisterProducts().pipe(
map((products: any[]) => {
const rows: RateChartRow[] = (products ?? []).map(
(p): RateChartRow => ({
srNo: p.srNo,
particulars: p.name,
glAccountId: p.glAccountId ?? null,
rates: new Array(this.slabs.length).fill(null)
})
);
return rows.length > 0
? this.scaffoldFirstDraft(rows, 'First version — standard loan types, rates to be filled')
: fromTemplate();
}),
catchError(() => of(fromTemplate()))
);
}
private scaffoldFirstDraft(rows: RateChartRow[], note: string): RateRegister {
const cibilTemplate = this.cibilSlabs;
const today = new Date().toISOString().slice(0, 10);
const draft: RateRegister = {
id: this.nextLocalDraftId(),
name: 'Interest Rate of Loans — new version',
effectiveFrom: today,
effectiveTo: null,
currency: 'INR',
status: 'Draft',
createdBy: 'Current User',
lastModified: today,
note,
slabs: this.slabs,
rows,
cibilSlabs: this.cloneCibilSlabs(cibilTemplate),
// Baseline is blank for a first version, so Compare shows every filled cell as new.
baselineRows: rows.map((r) => ({
srNo: r.srNo,
particulars: r.particulars,
rates: new Array(r.rates.length).fill(null)
})),
baselineCibilSlabs: [],
fallbackNoCibilSlab: 2,
fallbackBelowMin: 'block'
};
this.localDrafts.unshift(draft);
this.cache.unshift(draft);
this.saveDrafts();
return draft;
}
/**
* Duplicate the current Active (or any register) as a new local Draft so the user can propose
* changes without touching the live Active. Local Drafts carry a negative id to never collide
* with server-assigned ids.
*/
duplicateAsDraft(sourceId: number): RateRegister | undefined {
const source = this.cache.find((r) => r.id === sourceId);
if (!source) {
return undefined;
}
const newId = this.nextLocalDraftId();
const today = new Date().toISOString().slice(0, 10);
const copy: RateRegister = {
id: newId,
// Left blank on purpose: the maker enters name and dates in the submit popup.
name: '',
effectiveFrom: '',
effectiveTo: null,
currency: source.currency,
status: 'Draft',
createdBy: 'Current User',
lastModified: today,
note: `Draft derived from ${source.name}`,
slabs: source.slabs,
rows: this.cloneRows(source.rows),
cibilSlabs: this.cloneCibilSlabs(source.cibilSlabs),
baselineRows: this.cloneRows(source.rows),
baselineCibilSlabs: this.cloneCibilSlabs(source.cibilSlabs),
fallbackNoCibilSlab: source.fallbackNoCibilSlab,
fallbackBelowMin: source.fallbackBelowMin
};
this.localDrafts.unshift(copy);
this.cache.unshift(copy);
this.saveDrafts();
return copy;
}
/** Checker rejects the pending register — it goes back to Draft (local-only fallback). */
reject(id: number): Observable<RateRegister | undefined> {
const pending = this.cache.find((x) => x.id === id);
if (!pending || pending.status !== 'Pending Approval') {
return of(undefined);
}
pending.status = 'Draft';
pending.lastModified = new Date().toISOString().slice(0, 10);
return of(pending);
}
private nextLocalDraftId(): number {
const minId = this.localDrafts.length ? Math.min(...this.localDrafts.map((d) => d.id)) : 0;
return (minId <= 0 ? minId : 0) - 1;
}
private cloneRows(src: RateChartRow[]): RateChartRow[] {
return src.map((r) => ({
srNo: r.srNo,
particulars: r.particulars,
isFormula: r.isFormula,
glAccountId: r.glAccountId ?? null,
rates: [...r.rates]
}));
}
private cloneCibilSlabs(src: CibilSlab[]): CibilSlab[] {
return src.map((s) => ({ ...s }));
}
// ---- backend <-> UI mapping -----------------------------------------------
// Backend sends/receives slabs as a flat list of {productSrNo, slabOrder, cell} rows; the UI
// works with a matrix: SlabColumn[] + RateChartRow[] (34 × 10). The two mappers below bridge
// that gap.
/** Map a backend {@code LoanRateRegisterData} payload into a UI RateRegister. */
private fromBackend(d: any, glBySrNo: Map<number, number | null>): RateRegister {
// Rebuild the slab columns from unique slabOrders.
const slabByOrder = new Map<number, SlabColumn>();
for (const cell of d.slabs ?? []) {
if (!slabByOrder.has(cell.slabOrder)) {
slabByOrder.set(cell.slabOrder, {
key: `s${cell.slabOrder + 1}`,
label: cell.slabLabel,
amountUpto: cell.amountUpto ?? null
});
}
}
const slabs = [...slabByOrder.entries()]
.sort((a, b) => a[0] - b[0])
.map(
([
,
s
]) => s
);
// Group cells by product to build rows.
const rowByProduct = new Map<number, RateChartRow>();
for (const cell of d.slabs ?? []) {
let row = rowByProduct.get(cell.productSrNo);
if (!row) {
row = {
srNo: cell.productSrNo,
particulars: cell.productName,
glAccountId: glBySrNo.get(cell.productSrNo) ?? null,
rates: new Array(slabs.length).fill(null)
};
rowByProduct.set(cell.productSrNo, row);
}
let value: RateCell;
if (cell.isNa) {
value = null;
} else if (cell.isFormula && cell.rateFormula) {
value = cell.rateFormula;
row.isFormula = true;
} else {
value = cell.rateValue ?? null;
}
row.rates[cell.slabOrder] = value;
}
const rows = [...rowByProduct.values()].sort((a, b) => a.srNo - b.srNo);
const cibilSlabs: CibilSlab[] = (d.cibilSlabs ?? [])
.slice()
.sort((a: any, b: any) => a.displayOrder - b.displayOrder)
.map((c: any) => ({
from: c.scoreFrom,
to: c.scoreTo,
tier: c.tier,
type: c.slabType ?? 'Flat',
adjustmentPct: Number(c.adjustmentPct)
}));
const status: RegisterStatus = d.isActive ? 'Active' : 'Expired';
return {
id: d.id,
name: d.name,
effectiveFrom: d.effectiveFromDate,
effectiveTo: d.effectiveToDate ?? null,
currency: d.currencyCode ?? 'INR',
status,
createdBy: d.createdById != null ? `User #${d.createdById}` : 'System',
lastModified: (d.createdDate ?? d.effectiveFromDate ?? '').toString().slice(0, 10),
note: d.note,
slabs,
rows,
cibilSlabs,
baselineRows: this.cloneRows(rows),
baselineCibilSlabs: this.cloneCibilSlabs(cibilSlabs),
fallbackNoCibilSlab: d.fallbackNoCibilSlab ?? 2,
fallbackBelowMin: (d.fallbackBelowMin as 'use-min-slab' | 'block') ?? 'block'
};
}
/** Serialise a UI RateRegister into the JSON shape POST /loanproducts/baseinterestrates expects. */
private toBackend(r: RateRegister): any {
const slabs: any[] = [];
for (const row of r.rows) {
for (let j = 0; j < r.slabs.length; j++) {
const cell = row.rates[j];
const slabCol = r.slabs[j];
const isNa = cell === null;
const isFormula = typeof cell === 'string';
slabs.push({
productSrNo: row.srNo,
productName: row.particulars,
slabOrder: j,
slabLabel: slabCol.label,
amountUpto: slabCol.amountUpto,
rateValue: isNa || isFormula ? null : cell,
rateFormula: isFormula ? cell : null,
isFormula,
isNa
});
}
}
const cibilSlabs = r.cibilSlabs.map((c, i) => ({
scoreFrom: c.from,
scoreTo: c.to,
tier: c.tier,
slabType: c.type,
adjustmentPct: c.adjustmentPct,
displayOrder: i
}));
return {
name: r.name,
effectiveFromDate: r.effectiveFrom,
currencyCode: r.currency,
fallbackNoCibilSlab: r.fallbackNoCibilSlab,
fallbackBelowMin: r.fallbackBelowMin,
note: r.note,
products: r.rows.map((row) => ({
srNo: row.srNo,
name: row.particulars,
glAccountId: row.glAccountId ?? null
})),
slabs,
cibilSlabs
};
}
// ---- GL accounts for the particular → GL mapping ------------------------------
private glOptionsCache: GlOption[] | null = null;
/** Posting GL accounts the user can map a particular to. Cached after the first successful load. */
getGlOptions(): Observable<GlOption[]> {
if (this.glOptionsCache) {
return of(this.glOptionsCache);
}
return this.productsService.getLoanRegisterGlAccounts().pipe(
map((accounts: any[]) =>
(accounts ?? [])
// The server filters on type=1 (ASSET); this keeps asset-only even if that param is ignored.
.filter((a) => a.type == null || a.type.value === 'ASSET' || a.type.id === 1)
.sort((a, b) => String(a.glCode ?? '').localeCompare(String(b.glCode ?? '')))
.map((a) => ({
id: a.id,
label: a.glCode ? `${a.glCode} - ${a.name}` : a.name
}))
),
tap((options) => {
this.glOptionsCache = options;
}),
catchError(() => of([] as GlOption[]))
);
}
/** Display label for a mapped GL id, or null when unmapped or the account list is not loaded. */
glLabel(id: number | null | undefined): string | null {
if (id == null) {
return null;
}
return this.glOptionsCache?.find((o) => o.id === id)?.label ?? `GL #${id}`;
}
}
@@ -0,0 +1,139 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
export interface SubmitDetailsData {
name: string;
effectiveFrom: string;
effectiveTo: string;
/** Active version this one replaces, shown as context. */
activeName?: string;
activeFrom?: string;
}
export interface SubmitDetailsResult {
name: string;
effectiveFrom: string;
effectiveTo: string;
}
/** Asks the maker for the version details (name, effective from / to) just before sending for approval. */
@Component({
selector: 'mifosx-submit-details-dialog',
imports: [
CommonModule,
FormsModule,
MatDialogModule,
MatButtonModule
],
template: `
<h2 mat-dialog-title>Version details</h2>
<mat-dialog-content class="sd">
<p class="sd__hint">
Confirm these details before sending the rate register for approval.
<span *ngIf="data.activeName"
>It will replace <b>{{ data.activeName }}</b
><span *ngIf="data.activeFrom"> (effective {{ data.activeFrom }})</span> once approved.</span
>
</p>
<div class="sd__field">
<label>REGISTER NAME *</label>
<input type="text" [(ngModel)]="name" />
</div>
<div class="sd__row">
<div class="sd__field">
<label>EFFECTIVE FROM *</label>
<input type="date" [(ngModel)]="effectiveFrom" />
</div>
<div class="sd__field">
<label>EFFECTIVE TO</label>
<input type="date" [(ngModel)]="effectiveTo" [min]="effectiveFrom" />
</div>
</div>
<div class="sd__error" *ngIf="error">{{ error }}</div>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-stroked-button (click)="ref.close()">Cancel</button>
<button mat-flat-button color="primary" (click)="confirm()">Submit for approval</button>
</mat-dialog-actions>
`,
styles: [
`
.sd {
min-width: 460px;
display: flex;
flex-direction: column;
gap: 12px;
}
.sd__hint {
margin: 0;
font-size: 12px;
color: #64748b;
}
.sd__row {
display: flex;
gap: 12px;
}
.sd__field {
display: flex;
flex-direction: column;
flex: 1;
}
.sd__field label {
font-size: 10px;
font-weight: 600;
color: #64748b;
letter-spacing: 0.3px;
margin-bottom: 3px;
}
.sd__field input {
height: 34px;
border: 1px solid #cbd5e1;
border-radius: 6px;
padding: 0 10px;
font-size: 13px;
font-family: inherit;
}
.sd__error {
font-size: 12px;
color: #b91c1c;
}
`
]
})
export class SubmitDetailsDialogComponent {
readonly ref = inject<MatDialogRef<SubmitDetailsDialogComponent, SubmitDetailsResult>>(MatDialogRef);
readonly data = inject<SubmitDetailsData>(MAT_DIALOG_DATA);
name = this.data.name;
effectiveFrom = this.data.effectiveFrom;
effectiveTo = this.data.effectiveTo;
error = '';
confirm(): void {
const name = this.name.trim();
if (!name) {
this.error = 'Enter the register name.';
return;
}
if (!this.effectiveFrom) {
this.error = 'Select the effective from date.';
return;
}
if (this.effectiveTo && this.effectiveTo < this.effectiveFrom) {
this.error = 'Effective to cannot be before effective from.';
return;
}
this.ref.close({ name, effectiveFrom: this.effectiveFrom, effectiveTo: this.effectiveTo });
}
}
@@ -42,19 +42,168 @@
</div>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-cog lp-tab-icon"></i>
{{ 'labels.inputs.SETTINGS' | translate }}
</ng-template>
<div class="lp-tab-content">
<mifosx-loan-product-settings-step
[loanProductsTemplate]="loanProductsTemplate"
[isLinkedToFloatingInterestRates]="loanProductTermsForm.get('isLinkedToFloatingInterestRates')"
(advancePaymentStrategy)="advancePaymentStrategy($event)"
></mifosx-loan-product-settings-step>
</div>
</mat-tab>
@if (isAdvanceProduct) {
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-shield-alt lp-tab-icon"></i>
SECURITY &amp; LIEN
</ng-template>
<div class="lp-tab-content">
<mifosx-loan-product-security-lien-step
[loanProductsTemplate]="loanProductsTemplate"
[productTypeGroup]="loanProductDetailsForm.get('productTypeGroup')"
[productTypeSubGroup]="loanProductDetailsForm.get('productTypeSubGroup')"
></mifosx-loan-product-security-lien-step>
</div>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-sliders-h lp-tab-icon"></i>
LIMIT &amp; TENURE
</ng-template>
<div class="lp-tab-content">
<mifosx-loan-product-advance-limit-tenure-step
[productSubType]="advanceSubType"
></mifosx-loan-product-advance-limit-tenure-step>
</div>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-percent lp-tab-icon"></i>
INTEREST
</ng-template>
<div class="lp-tab-content">
<mifosx-loan-product-advance-interest-step
[productSubType]="advanceSubType"
[glAccountId]="productGlAccount"
></mifosx-loan-product-advance-interest-step>
</div>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-triangle-exclamation lp-tab-icon"></i>
PENAL &amp; NPA
</ng-template>
<div class="lp-tab-content">
<mifosx-loan-product-penal-npa-step
[loanProductsTemplate]="loanProductsTemplate"
></mifosx-loan-product-penal-npa-step>
</div>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-arrows-rotate lp-tab-icon"></i>
RENEWAL &amp; CLOSURE
</ng-template>
<div class="lp-tab-content">
<mifosx-loan-product-renewal-closure-step></mifosx-loan-product-renewal-closure-step>
</div>
</mat-tab>
@if (isOverdraftFacility) {
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-money-check lp-tab-icon"></i>
CHEQUE BOOK
</ng-template>
<div class="lp-tab-content">
<mifosx-loan-product-cheque-book-step></mifosx-loan-product-cheque-book-step>
</div>
</mat-tab>
}
}
@if (isLoanAccount) {
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-shield-alt lp-tab-icon"></i>
SECURITY &amp; COLLATERAL
</ng-template>
<div class="lp-tab-content">
<mifosx-loan-product-loan-security-step></mifosx-loan-product-loan-security-step>
</div>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-sliders-h lp-tab-icon"></i>
AMOUNT &amp; TENURE
</ng-template>
<div class="lp-tab-content">
<mifosx-loan-product-loan-amount-tenure-step></mifosx-loan-product-loan-amount-tenure-step>
</div>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-calendar-alt lp-tab-icon"></i>
REPAYMENT &amp; EMI
</ng-template>
<div class="lp-tab-content">
<mifosx-loan-product-loan-repayment-step></mifosx-loan-product-loan-repayment-step>
</div>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-percent lp-tab-icon"></i>
INTEREST
</ng-template>
<div class="lp-tab-content">
<mifosx-loan-product-loan-interest-step
[productSubType]="advanceSubType"
[glAccountId]="productGlAccount"
></mifosx-loan-product-loan-interest-step>
</div>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-triangle-exclamation lp-tab-icon"></i>
PENAL &amp; NPA
</ng-template>
<div class="lp-tab-content">
<mifosx-loan-product-penal-npa-step
[loanProductsTemplate]="loanProductsTemplate"
></mifosx-loan-product-penal-npa-step>
</div>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-flag-checkered lp-tab-icon"></i>
PREPAYMENT &amp; CLOSURE
</ng-template>
<div class="lp-tab-content">
<mifosx-loan-product-loan-prepayment-step></mifosx-loan-product-loan-prepayment-step>
</div>
</mat-tab>
}
<!--
OLD LOANS A/C PATH — commented out for now (replaced by the Loans A/C steps above).
Settings, Interest Refund, Payment Allocation and Terms tabs are kept here so they can be restored.
@if (!isAdvanceProduct) {
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-cog lp-tab-icon"></i>
{{ 'labels.inputs.SETTINGS' | translate }}
</ng-template>
<div class="lp-tab-content">
<mifosx-loan-product-settings-step
[loanProductsTemplate]="loanProductsTemplate"
[isLinkedToFloatingInterestRates]="loanProductTermsForm?.get('isLinkedToFloatingInterestRates')"
[productTypeGroup]="loanProductDetailsForm.get('productTypeGroup')"
(advancePaymentStrategy)="advancePaymentStrategy($event)"
></mifosx-loan-product-settings-step>
</div>
</mat-tab>
}
@if (isAdvancedPaymentStrategy) {
<mat-tab>
@@ -90,15 +239,20 @@
</mat-tab>
}
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-sliders-h lp-tab-icon"></i>
{{ 'labels.inputs.TERMS' | translate }}
</ng-template>
<div class="lp-tab-content">
<mifosx-loan-product-terms-step [loanProductsTemplate]="loanProductsTemplate"></mifosx-loan-product-terms-step>
</div>
</mat-tab>
@if (!isAdvanceProduct) {
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-sliders-h lp-tab-icon"></i>
{{ 'labels.inputs.TERMS' | translate }}
</ng-template>
<div class="lp-tab-content">
<mifosx-loan-product-terms-step
[loanProductsTemplate]="loanProductsTemplate"
></mifosx-loan-product-terms-step>
</div>
</mat-tab>
}
-->
<mat-tab>
<ng-template mat-tab-label>
@@ -109,11 +263,12 @@
<mifosx-loan-product-charges-step
[loanProductsTemplate]="loanProductsTemplate"
[currencyCode]="loanProductCurrencyForm.get('currencyCode')"
[multiDisburseLoan]="loanProductSettingsForm.get('multiDisburseLoan')"
[multiDisburseLoan]="loanProductSettingsForm?.get('multiDisburseLoan') ?? multiDisburseFallback"
></mifosx-loan-product-charges-step>
</div>
</mat-tab>
<!-- OLD LOANS A/C PATH (Deferred Income Recognition) — commented out for now.
@if (isAdvancedPaymentStrategy) {
<mat-tab>
<ng-template mat-tab-label>
@@ -134,6 +289,7 @@
</div>
</mat-tab>
}
-->
<mat-tab>
<ng-template mat-tab-label>
@@ -13,11 +13,22 @@ import { ActivatedRoute, Router } from '@angular/router';
/** Custom Components */
import { LoanProductDetailsStepComponent } from '../loan-product-stepper/loan-product-details-step/loan-product-details-step.component';
import { LoanProductCurrencyStepComponent } from '../loan-product-stepper/loan-product-currency-step/loan-product-currency-step.component';
import { LoanProductSecurityLienStepComponent } from '../loan-product-stepper/loan-product-security-lien-step/loan-product-security-lien-step.component';
import { LoanProductAdvanceLimitTenureStepComponent } from '../loan-product-stepper/loan-product-advance-limit-tenure-step/loan-product-advance-limit-tenure-step.component';
import { LoanProductAdvanceInterestStepComponent } from '../loan-product-stepper/loan-product-advance-interest-step/loan-product-advance-interest-step.component';
import { LoanProductPenalNpaStepComponent } from '../loan-product-stepper/loan-product-penal-npa-step/loan-product-penal-npa-step.component';
import { LoanProductRenewalClosureStepComponent } from '../loan-product-stepper/loan-product-renewal-closure-step/loan-product-renewal-closure-step.component';
import { LoanProductChequeBookStepComponent } from '../loan-product-stepper/loan-product-cheque-book-step/loan-product-cheque-book-step.component';
import { LoanProductTermsStepComponent } from '../loan-product-stepper/loan-product-terms-step/loan-product-terms-step.component';
import { LoanProductSettingsStepComponent } from '../loan-product-stepper/loan-product-settings-step/loan-product-settings-step.component';
import { LoanProductChargesStepComponent } from '../loan-product-stepper/loan-product-charges-step/loan-product-charges-step.component';
import { LoanProductAccountingStepComponent } from '../loan-product-stepper/loan-product-accounting-step/loan-product-accounting-step.component';
import { LoanProductInterestRefundStepComponent } from '../loan-product-stepper/loan-product-interest-refund-step/loan-product-interest-refund-step.component';
import { LoanProductLoanSecurityStepComponent } from '../loan-product-stepper/loan-product-loan-security-step/loan-product-loan-security-step.component';
import { LoanProductLoanAmountTenureStepComponent } from '../loan-product-stepper/loan-product-loan-amount-tenure-step/loan-product-loan-amount-tenure-step.component';
import { LoanProductLoanRepaymentStepComponent } from '../loan-product-stepper/loan-product-loan-repayment-step/loan-product-loan-repayment-step.component';
import { LoanProductLoanInterestStepComponent } from '../loan-product-stepper/loan-product-loan-interest-step/loan-product-loan-interest-step.component';
import { LoanProductLoanPrepaymentStepComponent } from '../loan-product-stepper/loan-product-loan-prepayment-step/loan-product-loan-prepayment-step.component';
/** Custom Services */
import { ProductsService } from 'app/products/products.service';
@@ -34,7 +45,7 @@ import {
import { Accounting } from 'app/core/utils/accounting';
import { StringEnumOptionData } from '../../../shared/models/option-data.model';
import { LoanProductDeferredIncomeRecognitionStepComponent } from '../loan-product-stepper/loan-product-capitalized-income-step/loan-product-deferred-income-recognition-step.component';
import { UntypedFormGroup } from '@angular/forms';
import { UntypedFormControl, UntypedFormGroup } from '@angular/forms';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { LoanProductPaymentStrategyStepComponent } from '../loan-product-stepper/loan-product-payment-strategy-step/loan-product-payment-strategy-step.component';
import { LoanProductPreviewStepComponent } from '../loan-product-stepper/loan-product-preview-step/loan-product-preview-step.component';
@@ -51,12 +62,24 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
FaIconComponent,
LoanProductDetailsStepComponent,
LoanProductCurrencyStepComponent,
LoanProductSettingsStepComponent,
LoanProductInterestRefundStepComponent,
LoanProductPaymentStrategyStepComponent,
LoanProductTermsStepComponent,
LoanProductSecurityLienStepComponent,
LoanProductAdvanceLimitTenureStepComponent,
LoanProductAdvanceInterestStepComponent,
LoanProductPenalNpaStepComponent,
LoanProductRenewalClosureStepComponent,
LoanProductChequeBookStepComponent,
LoanProductLoanSecurityStepComponent,
LoanProductLoanAmountTenureStepComponent,
LoanProductLoanRepaymentStepComponent,
LoanProductLoanInterestStepComponent,
LoanProductLoanPrepaymentStepComponent,
// Old Loans A/C path — commented out for now (its tabs are commented out in the template):
// LoanProductSettingsStepComponent,
// LoanProductInterestRefundStepComponent,
// LoanProductPaymentStrategyStepComponent,
// LoanProductTermsStepComponent,
// LoanProductDeferredIncomeRecognitionStepComponent,
LoanProductChargesStepComponent,
LoanProductDeferredIncomeRecognitionStepComponent,
LoanProductAccountingStepComponent,
LoanProductPreviewStepComponent
]
@@ -73,12 +96,39 @@ export class CreateLoanProductComponent implements OnInit {
@ViewChild(LoanProductDetailsStepComponent, { static: true }) loanProductDetailsStep: LoanProductDetailsStepComponent;
@ViewChild(LoanProductCurrencyStepComponent, { static: true })
loanProductCurrencyStep: LoanProductCurrencyStepComponent;
// Conditional tabs (Advances A/C only): static false — resolved after the @if renders them.
@ViewChild(LoanProductSecurityLienStepComponent)
loanProductSecurityLienStep: LoanProductSecurityLienStepComponent;
@ViewChild(LoanProductAdvanceLimitTenureStepComponent)
loanProductAdvanceLimitTenureStep: LoanProductAdvanceLimitTenureStepComponent;
@ViewChild(LoanProductAdvanceInterestStepComponent)
loanProductAdvanceInterestStep: LoanProductAdvanceInterestStepComponent;
@ViewChild(LoanProductPenalNpaStepComponent) loanProductPenalNpaStep: LoanProductPenalNpaStepComponent;
@ViewChild(LoanProductRenewalClosureStepComponent)
loanProductRenewalClosureStep: LoanProductRenewalClosureStepComponent;
@ViewChild(LoanProductChequeBookStepComponent) loanProductChequeBookStep: LoanProductChequeBookStepComponent;
// Conditional tabs (Loans A/C only): resolved after the @if renders them.
@ViewChild(LoanProductLoanSecurityStepComponent)
loanProductLoanSecurityStep: LoanProductLoanSecurityStepComponent;
@ViewChild(LoanProductLoanAmountTenureStepComponent)
loanProductLoanAmountTenureStep: LoanProductLoanAmountTenureStepComponent;
@ViewChild(LoanProductLoanRepaymentStepComponent)
loanProductLoanRepaymentStep: LoanProductLoanRepaymentStepComponent;
@ViewChild(LoanProductLoanInterestStepComponent)
loanProductLoanInterestStep: LoanProductLoanInterestStepComponent;
@ViewChild(LoanProductLoanPrepaymentStepComponent)
loanProductLoanPrepaymentStep: LoanProductLoanPrepaymentStepComponent;
/** Charges step needs a real control; Settings is absent on the Advances path, so fall back to this. */
readonly multiDisburseFallback = new UntypedFormControl(false);
@ViewChild(LoanProductInterestRefundStepComponent, { static: true })
loanProductInterestRefundStep: LoanProductInterestRefundStepComponent;
@ViewChild(LoanProductDeferredIncomeRecognitionStepComponent, { static: true })
loanProductDeferredIncomeRecognitionStep: LoanProductDeferredIncomeRecognitionStepComponent;
@ViewChild(LoanProductTermsStepComponent, { static: true }) loanProductTermsStep: LoanProductTermsStepComponent;
@ViewChild(LoanProductSettingsStepComponent, { static: true })
// Terms & Settings tabs are now conditional (Loans path only), so resolve them non-statically.
@ViewChild(LoanProductTermsStepComponent) loanProductTermsStep: LoanProductTermsStepComponent;
@ViewChild(LoanProductSettingsStepComponent)
loanProductSettingsStep: LoanProductSettingsStepComponent;
@ViewChild(LoanProductChargesStepComponent, { static: true }) loanProductChargesStep: LoanProductChargesStepComponent;
@ViewChild(LoanProductAccountingStepComponent, { static: true })
@@ -143,7 +193,23 @@ export class CreateLoanProductComponent implements OnInit {
}
get loanProductTermsForm() {
return this.loanProductTermsStep.loanProductTermsForm;
// Absent on the Advances path (tab hidden), so guard for callers.
return this.loanProductTermsStep?.loanProductTermsForm;
}
/** True once the Advance Limit & Tenure step reports an Overdraft (running) facility. */
get isOverdraftFacility(): boolean {
return this.isAdvanceProduct && !!this.loanProductAdvanceLimitTenureStep?.isOverdraft;
}
/** The GL account chosen on the Details step; the rate register row can be matched by it. */
get productGlAccount(): number | string | null {
return this.loanProductDetailsStep?.loanProductDetailsForm?.get('glAccount')?.value ?? null;
}
/** Sub type chosen on the Details step; drives the FD 12–15 month tenure policy downstream. */
get advanceSubType(): string {
return this.loanProductDetailsStep?.loanProductDetailsForm?.get('productTypeSubGroup')?.value ?? '';
}
advancePaymentStrategy(value: string) {
@@ -231,47 +297,103 @@ export class CreateLoanProductComponent implements OnInit {
}
get loanProductSettingsForm() {
return this.loanProductSettingsStep.loanProductSettingsForm;
// Absent on the Advances path (tab hidden), so guard for callers.
return this.loanProductSettingsStep?.loanProductSettingsForm;
}
get loanProductAccountingForm() {
return this.loanProductAccountingStep.loanProductAccountingForm;
}
/** The Details step's chosen product type; drives the Advances-only tabs. */
get isAdvanceProduct(): boolean {
return this.loanProductDetailsForm?.get('productTypeGroup')?.value === 'advances';
}
/** The Details step's chosen product type; drives the Loans A/C (EMI) tabs. */
get isLoanAccount(): boolean {
return this.loanProductDetailsForm?.get('productTypeGroup')?.value === 'loans';
}
get totalTabs(): number {
let tabs = 6; // details, currency, settings, terms, charges, accounting
if (this.isAdvancedPaymentStrategy) tabs += 3; // interest refund, payment allocation, deferred income
let tabs = 4; // details, currency, charges, accounting (always)
if (this.isAdvanceProduct) {
// security & lien, limit & tenure, interest, penal & npa, renewal & closure
tabs += 5;
if (this.isOverdraftFacility) tabs += 1; // cheque book
} else if (this.isLoanAccount) {
// security & collateral, amount & tenure, repayment & emi, interest, penal & npa, prepayment & closure
tabs += 6;
}
// Old Loans A/C path (Settings, Terms, Interest Refund, Payment Allocation, Deferred Income) — commented out:
// else {
// tabs += 2; // settings, terms
// if (this.isAdvancedPaymentStrategy) tabs += 3; // interest refund, payment allocation, deferred income
// }
if (this.loanProductFormValid) tabs += 1; // preview
return tabs;
}
get loanProductFormValid() {
if (this.isAdvancedPaymentStrategy) {
const base =
this.loanProductDetailsForm.valid && this.loanProductCurrencyForm.valid && this.loanProductAccountingForm.valid;
// Advances A/C: the curated Figma steps gate the form; Settings/Terms tabs are hidden.
if (this.isAdvanceProduct) {
return (
this.loanProductDetailsForm.valid &&
this.loanProductCurrencyForm.valid &&
this.loanProductTermsForm.valid &&
this.loanProductSettingsForm.valid &&
this.loanIncomeCapitalizationForm.valid &&
this.loanProductAccountingForm.valid
);
} else {
return (
this.loanProductDetailsForm.valid &&
this.loanProductCurrencyForm.valid &&
this.loanProductTermsForm.valid &&
this.loanProductSettingsForm.valid &&
this.loanProductAccountingForm.valid
base &&
!!this.loanProductSecurityLienStep?.loanProductSecurityLienForm?.valid &&
!!this.loanProductAdvanceLimitTenureStep?.loanProductAdvanceLimitTenureForm?.valid &&
!!this.loanProductAdvanceInterestStep?.loanProductAdvanceInterestForm?.valid &&
!!this.loanProductPenalNpaStep?.loanProductPenalNpaForm?.valid &&
!!this.loanProductRenewalClosureStep?.loanProductRenewalClosureForm?.valid &&
(!this.isOverdraftFacility || !!this.loanProductChequeBookStep?.loanProductChequeBookForm?.valid)
);
}
// Loans A/C (EMI): the curated Loans steps gate the form; Settings/Terms tabs are commented out.
if (this.isLoanAccount) {
return (
base &&
!!this.loanProductLoanSecurityStep?.loanProductLoanSecurityForm?.valid &&
!!this.loanProductLoanAmountTenureStep?.loanProductLoanAmountTenureForm?.valid &&
!!this.loanProductLoanRepaymentStep?.loanProductLoanRepaymentForm?.valid &&
!!this.loanProductLoanInterestStep?.loanProductLoanInterestForm?.valid &&
!!this.loanProductPenalNpaStep?.loanProductPenalNpaForm?.valid &&
!!this.loanProductLoanPrepaymentStep?.loanProductLoanPrepaymentForm?.valid
);
}
// No product type chosen yet.
return false;
// Old Loans A/C path (Settings + Terms) — commented out for now:
// const loansValid = !!this.loanProductTermsForm?.valid && !!this.loanProductSettingsForm?.valid;
// if (this.isAdvancedPaymentStrategy) {
// return base && loansValid && !!this.loanIncomeCapitalizationForm?.valid;
// }
// return base && loansValid;
}
get loanProduct() {
// Advances A/C builds its own payload: the curated steps map onto Fineract's mandatory core fields
// (principal, tenure, rate, amortization defaults) plus the whitelisted satellite params.
if (this.isAdvanceProduct) {
return this.assembleAdvanceProduct();
}
// Loans A/C (EMI): builds its own payload from the Loans steps, mapped onto Fineract's core fields.
if (this.isLoanAccount) {
return this.assembleLoanProduct();
}
return {};
/* OLD LOANS A/C PATH (Settings/Terms composition) — commented out for now.
const loanProduct = {
...this.loanProductDetailsStep.loanProductDetails,
...this.loanProductCurrencyStep.loanProductCurrency,
...this.loanProductTermsStep.loanProductTerms,
...this.loanProductSettingsStep.loanProductSettings,
...(this.loanProductTermsStep?.loanProductTerms ?? {}),
...(this.loanProductSettingsStep?.loanProductSettings ?? {}),
...this.loanProductChargesStep.loanProductCharges,
...this.loanProductAccountingStep.loanProductAccounting
};
@@ -302,6 +424,340 @@ export class CreateLoanProductComponent implements OnInit {
}
}
return loanProduct;
*/
}
/**
* Builds the create payload for a Loans A/C (EMI) product: the Loans steps mapped onto Fineract's core
* product fields (so the standard product saves). The Loans-only options that have no core field yet
* (eligibility, collateral rules, prepayment, repayment mode, floating reset, register link) stay UI-only
* and are not sent — an unknown key would be rejected by checkForUnsupportedParameters.
*/
private assembleLoanProduct(): any {
const num = (v: any): number | undefined => (v === '' || v === null || v === undefined ? undefined : Number(v));
const d = this.loanProductDetailsStep.loanProductDetails;
const cur = this.loanProductCurrencyStep.loanProductCurrency;
const at = this.loanProductLoanAmountTenureStep.loanProductLoanAmountTenure;
const rp = this.loanProductLoanRepaymentStep.loanProductLoanRepayment;
const intr = this.loanProductLoanInterestStep.loanProductLoanInterest;
const sec = this.loanProductLoanSecurityStep.loanProductLoanSecurity;
const pp = this.loanProductLoanPrepaymentStep.loanProductLoanPrepayment;
const pn = this.loanProductPenalNpaStep.loanProductPenalNpa;
const stepper = this.loanProductLoanRepaymentStep;
const interestStep = this.loanProductLoanInterestStep;
const frequency = stepper.frequencyOptions.find((f) => f.id === rp.frequency)?.fineractId ?? 2;
const daysInYear = interestStep.daysInYearOptions.find((o) => o.id === intr.daysInYear)?.fineractId ?? 1;
const daysInMonth = interestStep.daysInMonthOptions.find((o) => o.id === intr.daysInMonth)?.fineractId ?? 1;
const defaultStrategy = this.loanProductsTemplate?.transactionProcessingStrategyOptions?.[0]?.code;
const minTenure = num(at.minTenure);
const minRate = num(intr.minRate);
const multiTranche = at.disbursementType === 'multiple';
const moratorium = num(rp.moratoriumMonths);
const payload: any = {
// identity + currency
name: d.name,
shortName: d.shortName,
description: d.description,
startDate: d.startDate,
closeDate: d.closeDate,
currencyCode: cur.currencyCode,
digitsAfterDecimal: cur.digitsAfterDecimal,
inMultiplesOf: cur.inMultiplesOf ?? 1,
// amount -> principal
minPrincipal: num(at.minAmount),
principal: num(at.defaultAmount) ?? num(at.minAmount),
maxPrincipal: num(at.maxAmount),
// tenure (months) -> number of repayments
minNumberOfRepayments: minTenure,
numberOfRepayments: num(at.defaultTenure) ?? minTenure,
maxNumberOfRepayments: num(at.maxTenure),
repaymentEvery: num(rp.repaidEvery) ?? 1,
repaymentFrequencyType: frequency,
graceOnPrincipalPayment: moratorium && moratorium > 0 ? moratorium : undefined,
// rate
minInterestRatePerPeriod: minRate,
interestRatePerPeriod: num(intr.defaultRate) ?? minRate,
maxInterestRatePerPeriod: num(intr.maxRate),
interestRateFrequencyType: 3, // per year
// amortization / calculation
amortizationType: rp.repaymentType === 'equal_principal' ? 0 : 1, // 0 equal principal, 1 equal installments
interestType: intr.calcMethod === 'flat' ? 1 : 0, // 0 declining balance, 1 flat
interestCalculationPeriodType: intr.calcPeriod === 'daily' ? 0 : 1, // 0 daily, 1 same as repayment period
transactionProcessingStrategyCode: defaultStrategy,
daysInYearType: daysInYear,
daysInMonthType: daysInMonth,
isInterestRecalculationEnabled: false,
// disbursement
multiDisburseLoan: multiTranche,
maxTrancheCount: multiTranche ? num(at.maxTranches) : undefined,
// NPA / delinquency map onto core product fields
overdueDaysForNPA: num(pn.npaDays),
graceOnArrearsAgeing: num(pn.graceOnArrearsAgeing),
inArrearsTolerance: num(pn.inArrearsTolerance),
delinquencyBucketId: pn.delinquencyBucketId || undefined,
// accounting rule + GL mappings + charges (all standard, whitelisted)
...this.loanProductAccountingStep.loanProductAccounting,
...this.loanProductChargesStep.loanProductCharges,
// Details-step satellite params (same whitelist the Advances path uses)
productTypeGroup: d.productTypeGroup,
productTypeSubGroup: d.productTypeSubGroup,
glAccount: d.glAccount || undefined,
loanProductCategory: d.loanProductCategory,
prioritySector: d.prioritySector,
cibilAccountType: d.cibilAccountType,
kfsEnabled: d.kfsEnabled,
aprEnabled: d.aprEnabled,
// Loans A/C (EMI) fields kept on the product satellite (same whitelist as the Advances fields)
margin: num(sec.margin),
ltv: sec.ltv,
securityType: sec.securityType,
repaymentType: rp.repaymentType,
appropriationOrder: rp.appropriationOrder,
interestCalcMethod: intr.calcMethod,
spread: num(intr.spread),
lockInMonths: num(pp.lockInMonths),
interestOnPreclosure: pp.interestOnPreclosure,
penalApplicable: pn.penalApplicable,
penalRate: num(pn.penalRate),
penalAppliedOn: pn.penalAppliedOn,
penalGraceDays: num(pn.penalGraceDays),
npaTrigger: pn.npaTrigger,
interestAfterNpa: pn.interestAfterNpa,
npaUpgradeOnlyAllArrears: pn.upgradeOnlyAllArrears,
npaKeepStandardIfDepositCovers: pn.keepStandardIfDepositCovers,
securityMandatory: sec.securityMandatory,
guarantorsRequired: num(sec.guarantorsRequired),
coApplicant: sec.coApplicant,
thirdPartySecurity: sec.thirdPartySecurity,
legalOpinionRequired: sec.legalOpinionRequired,
propertyInsuranceRequired: sec.propertyInsuranceRequired,
registerCharge: sec.registerCharge,
releaseSecurityOnClosure: sec.autoReleaseOnClosure,
allowMultipleSecurities: sec.allowMultipleSecurities,
allowThirdPartyDeposit: sec.allowThirdPartyDeposit,
minAge: num(at.minAge),
maxAgeAtMaturity: num(at.maxAgeAtMaturity),
minCibil: num(at.minCibil),
minMonthlyIncome: num(at.minMonthlyIncome),
maxFoir: num(at.maxFoir),
maxLoansPerCustomer: num(at.maxLoansPerCustomer),
disbursementType: at.disbursementType,
disbursementMode: at.disbursementMode,
undisbursedInterest: at.undisbursedInterest,
preEmiInterest: at.preEmiInterest,
emiDueDay: rp.emiDueDay,
holidayRule: rp.holidayRule,
moratoriumMonths: num(rp.moratoriumMonths),
moratoriumInterest: rp.moratoriumInterest,
variableInstallments: rp.variableInstallments,
repaymentMode: rp.repaymentMode,
linkedAccountType: rp.linkedAccountType,
pdcCollection: rp.pdcCollection,
mandateRequired: rp.mandateRequired,
advanceEmi: rp.advanceEmi,
rateType: intr.interestType,
registerParticular: intr.registerParticular,
applyCibilAdjustment: intr.applyCibilAdjustment,
rateResolvedAt: intr.rateResolvedAt,
calcPeriod: intr.calcPeriod,
interestStartsFrom: intr.interestStartsFrom,
compounding: intr.compounding,
benchmark: intr.benchmark,
resetFrequency: intr.resetFrequency,
resetChange: intr.resetChange,
partPrepaymentAllowed: pp.partPrepaymentAllowed,
minPartPayment: num(pp.minPartPayment),
allowedOn: pp.allowedOn,
partPaymentsPerYear: pp.partPaymentsPerYear,
partPrepaymentMode: pp.partPrepaymentMode,
foreclosureFloatingIndividual: num(pp.foreclosureFloatingIndividual),
foreclosureFixedIndividual: num(pp.foreclosureFixedIndividual),
foreclosureNonIndividual: num(pp.foreclosureNonIndividual),
foreclosureTakeover: num(pp.foreclosureTakeover),
foreclosureQuoteValidDays: num(pp.foreclosureQuoteValidDays),
autoCloseOnZeroBalance: pp.autoCloseOnZeroBalance,
nocLetter: pp.nocLetter,
restructuring: pp.restructuring,
releaseCollateralOnClosure: pp.releaseCollateralOnClosure,
topUpAllowed: pp.topUpAllowed,
balanceTransferOutAllowed: pp.balanceTransferOutAllowed
};
// Drop undefined keys so we never send an unresolved value.
Object.keys(payload).forEach((k) => payload[k] === undefined && delete payload[k]);
return payload;
}
/**
* Builds the create payload for an Advances A/C product: the curated wizard steps mapped onto Fineract's
* mandatory core fields (so the standard product saves) plus the whitelisted satellite params (persisted by
* LoanProductAdvanceExtService). Only keys the backend accepts are emitted — an unknown key would be
* rejected by checkForUnsupportedParameters.
*/
private assembleAdvanceProduct(): any {
const num = (v: any): number | undefined => (v === '' || v === null || v === undefined ? undefined : Number(v));
const d = this.loanProductDetailsStep.loanProductDetails;
const cur = this.loanProductCurrencyStep.loanProductCurrency;
const sec = this.loanProductSecurityLienStep.loanProductSecurityLien;
const lt = this.loanProductAdvanceLimitTenureStep.loanProductAdvanceLimitTenure;
const intr = this.loanProductAdvanceInterestStep.loanProductAdvanceInterest;
const pn = this.loanProductPenalNpaStep.loanProductPenalNpa;
const rc = this.loanProductRenewalClosureStep.loanProductRenewalClosure;
const cb = this.isOverdraftFacility ? this.loanProductChequeBookStep?.loanProductChequeBook : null;
const defaultStrategy = this.loanProductsTemplate?.transactionProcessingStrategyOptions?.[0]?.code;
const minTenure = num(lt.minTenure);
const minRate = num(intr.minRate);
const payload: any = {
// identity + currency
name: d.name,
shortName: d.shortName,
description: d.description,
startDate: d.startDate,
closeDate: d.closeDate,
currencyCode: cur.currencyCode,
digitsAfterDecimal: cur.digitsAfterDecimal,
inMultiplesOf: 1,
// limit -> principal
minPrincipal: num(lt.minLimit),
principal: num(lt.defaultLimit) ?? num(lt.minLimit),
maxPrincipal: num(lt.maxLimit),
// tenure -> repayments (monthly)
minNumberOfRepayments: minTenure,
numberOfRepayments: num(lt.defaultTenure) ?? minTenure,
maxNumberOfRepayments: num(lt.maxTenure),
repaymentEvery: 1,
repaymentFrequencyType: 2, // months
// rate
minInterestRatePerPeriod: minRate,
// A deposit-linked row ("1% + FD") has no plain minimum or maximum, so the register step supplies the default.
interestRatePerPeriod: num(intr.defaultRate) ?? minRate,
maxInterestRatePerPeriod: num(intr.maxRate),
interestRateFrequencyType: 3, // per year
// Fineract mandatory engine defaults for an Advance (no EMI schedule shown to the officer)
amortizationType: 1, // equal installments
interestType: 0, // declining balance
interestCalculationPeriodType: 1, // same as repayment period
transactionProcessingStrategyCode: defaultStrategy,
daysInYearType: 1, // actual
daysInMonthType: 1, // actual
isInterestRecalculationEnabled: false,
// NPA / delinquency map onto core product fields
overdueDaysForNPA: num(pn.npaDays),
graceOnArrearsAgeing: num(pn.graceOnArrearsAgeing),
inArrearsTolerance: num(pn.inArrearsTolerance),
delinquencyBucketId: pn.delinquencyBucketId || undefined,
// accounting rule + GL mappings + charges (all standard, whitelisted)
...this.loanProductAccountingStep.loanProductAccounting,
...this.loanProductChargesStep.loanProductCharges,
// ---- satellite params (must match LoanProductAdvanceExtService.ADVANCE_PARAMETERS) ----
productTypeGroup: d.productTypeGroup,
productTypeSubGroup: d.productTypeSubGroup,
glAccount: d.glAccount || undefined,
loanProductCategory: d.loanProductCategory,
prioritySector: d.prioritySector,
cibilAccountType: d.cibilAccountType,
kfsEnabled: d.kfsEnabled,
aprEnabled: d.aprEnabled,
facilityType: lt.facilityType,
securityType: sec.securityType,
eligibleAmountBasis: sec.eligibleAmountBasis,
margin: num(sec.margin),
ltv: sec.ltv,
dpMethod: 'security_value_x_ltv',
lienRequired: this.loanProductSecurityLienStep.isLienSubType,
lienMarkingStage: sec.lienMarkingStage,
lienCertificateRequired: sec.lienCertificateRequired,
lienCertificatePrefix: sec.lienCertificatePrefix,
printCertificateOnDisbursement: sec.printOnDisbursement,
interestBasis: intr.interestBasis,
registerParticular: intr.registerParticular || undefined,
spread: num(intr.spread),
interestChargedOn: intr.interestChargedOn,
interestPostingFrequency: intr.postingFrequency,
penalApplicable: pn.penalApplicable,
penalRate: num(pn.penalRate),
penalAppliedOn: pn.penalAppliedOn,
penalGraceDays: num(pn.penalGraceDays),
npaTrigger: pn.npaTrigger,
interestAfterNpa: pn.interestAfterNpa,
renewalAllowed: rc.renewalAllowed,
renewalPeriodMonths: num(rc.renewalPeriodMonths),
renewalGraceDays: num(rc.renewalGraceDays),
preclosureAllowed: rc.preclosureAllowed,
lockInMonths: num(rc.lockInMonths),
interestOnPreclosure: rc.interestOnPreclosure,
chequeBookEnabled: this.isOverdraftFacility,
chequeType: cb?.chequeType,
defaultChequeBookSize: num(cb?.defaultBookSize),
ppsThreshold: num(cb?.ppsThreshold),
ppsMandatoryAbove: num(cb?.ppsMandatoryAbove),
// ---- remaining wizard fields ----
// security & lien extras (deposit list joined; rule checkboxes)
eligibleDepositProducts: (sec.eligibleDepositProducts ?? []).join('|'),
lienTenureNotExceedMaturity: sec.lienRules?.tenureNotExceedMaturity,
lienAutoReleaseOnClosure: sec.lienRules?.autoReleaseOnClosure,
lienAllowMultipleDeposits: sec.lienRules?.allowMultipleDeposits,
lienAdjustFromMaturity: sec.lienRules?.adjustFromMaturity,
lienAllowThirdParty: sec.lienRules?.allowThirdPartyDeposit,
lienAllowRenewalUnderLien: sec.lienRules?.allowRenewalUnderLien,
// limit & tenure extras
repaymentType: lt.repaymentType,
appropriationOrder: lt.appropriationOrder,
debitBalanceAllow: lt.debitBalanceAllow,
allowDebitAfterDueDate: lt.allowDebitTransAfterDueDate,
branchLevelAccountType: lt.branchLevelAccountType,
// interest extras
interestCalcMethod: intr.calcMethod,
interestOnCreditBalance: intr.interestOnCreditBalance,
interestPostingDay: intr.postingDay,
// NPA upgrade rules
npaUpgradeOnlyAllArrears: pn.upgradeOnlyAllArrears,
npaKeepStandardIfDepositCovers: pn.keepStandardIfDepositCovers,
// cheque book full config
chequeBookSizes: (cb?.bookSizes ?? []).join('|'),
chequeMaxBooksOutstanding: num(cb?.maxBooksOutstanding),
chequeReissueWhenLeaves: num(cb?.reissueWhenLeaves),
chequeFirstBookFree: cb?.firstBookFree,
chequeFreeBooksPerYear: num(cb?.freeBooksPerYear),
chequeNamePrinted: cb?.namePrinted,
chequePrintDescription: cb?.chequePrintDescription,
chequeStopPaymentAllowed: cb?.stopPaymentAllowed,
chequeMaintainStockRegister: cb?.maintainStockRegister,
chequeAllowWithdrawalSlip: cb?.allowWithdrawalSlip,
chequeBlockNpa: cb?.blockNpa,
chequeBlockLimitExpired: cb?.blockLimitExpired,
chequeBlockOverDp: cb?.blockOverDp,
chequeBlockFrozen: cb?.blockFrozen,
chequeBlockLienReleased: cb?.blockLienReleased
};
// Drop undefined keys so we never send an unresolved value.
Object.keys(payload).forEach((k) => payload[k] === undefined && delete payload[k]);
return payload;
}
submit() {
@@ -0,0 +1,216 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/*
* Shared styling for the Advances A/C wizard steps (Security & Lien, Limit & Tenure,
* Interest, Penal & NPA, Renewal & Closure, Cheque Book).
*/
$accent: #0ea5e9;
$border: #e2e8f0;
$gray-50: #f8f9fa;
$gray-200: #e8eaed;
$gray-900: #202124;
.adv-section {
border: 1px solid $border;
border-radius: 10px;
overflow: hidden;
background: #fff;
& + & {
margin-top: 16px;
}
&__header {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 24px;
background: $gray-50;
border-bottom: 1px solid $gray-200;
i {
font-size: 14px;
color: $accent;
}
h3 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: $gray-900;
}
}
&__hint {
margin-left: auto;
font-size: 12px;
color: #5f6368;
}
}
.adv-subsection {
padding: 16px 24px;
& + & {
border-top: 1px solid $border;
}
}
.adv-group {
margin: 0 0 12px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #5f6368;
}
.adv-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 4px 16px;
padding: 16px 24px;
@media (width <= 600px) {
grid-template-columns: 1fr;
}
}
.adv-grid--3 {
grid-template-columns: repeat(3, 1fr);
@media (width <= 900px) {
grid-template-columns: repeat(2, 1fr);
}
@media (width <= 600px) {
grid-template-columns: 1fr;
}
}
.adv-subsection .adv-grid {
padding: 0;
}
.adv-field {
width: 100%;
&--full {
grid-column: 1 / -1;
}
}
.adv-checks {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px 32px;
@media (width <= 600px) {
grid-template-columns: 1fr;
}
}
.adv-type-options {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
padding: 16px 24px 0;
@media (width <= 600px) {
grid-template-columns: 1fr;
}
}
.adv-type-option {
border: 1px solid #cbd5e1;
border-radius: 8px;
padding: 10px 14px;
&__label {
display: block;
font-weight: 600;
color: $gray-900;
}
&__hint {
display: block;
font-size: 12px;
color: #5f6368;
}
}
.adv-note {
display: flex;
align-items: flex-start;
gap: 8px;
margin-top: 16px;
padding: 12px 24px;
border: 1px dashed $border;
border-radius: 10px;
font-size: 13px;
color: #5f6368;
i {
color: $accent;
margin-top: 2px;
}
}
.adv-info {
padding: 12px 16px;
border-radius: 8px;
background: #f0f9ff;
border: 1px solid #bae6fd;
font-size: 13px;
color: #075985;
line-height: 1.5;
}
/* Read-only reference tables (Loans A/C steps): collateral rules, foreclosure charges. */
.adv-table {
width: 100%;
border-collapse: collapse;
th {
text-align: left;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
color: #475569;
padding: 12px 24px;
background: $gray-50;
border-bottom: 1px solid $border;
}
td {
font-size: 14px;
padding: 10px 24px;
border-bottom: 1px solid #eef2f6;
color: #1e293b;
}
.adv-table__no {
color: #94a3b8;
}
.adv-table__yes {
color: #047857;
font-weight: 600;
}
}
.adv-type-options--3 {
grid-template-columns: repeat(3, 1fr);
@media (width <= 900px) {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,98 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<form [formGroup]="loanProductAdvanceInterestForm">
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-percent"></i>
<h3>Rate</h3>
<span class="adv-section__hint">Taken from the Loan Interest Rate Register</span>
</div>
<div style="padding: 16px 0 8px">
<mifosx-register-rate-view
[productSubType]="productSubType"
[glAccountId]="glAccountId"
(selectionChange)="onRegisterSelection($event)"
></mifosx-register-rate-view>
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-sliders-h"></i>
<h3>Calculation</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Calculation Method</mat-label>
<mat-select formControlName="calcMethod">
@for (opt of calcMethodOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Days in Year</mat-label>
<mat-select formControlName="daysInYear">
@for (opt of daysInYearOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Days in Month</mat-label>
<mat-select formControlName="daysInMonth">
@for (opt of daysInMonthOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Interest Charged On</mat-label>
<mat-select formControlName="interestChargedOn">
@for (opt of chargedOnOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Interest on Credit Balance</mat-label>
<mat-select formControlName="interestOnCreditBalance">
@for (opt of yesNoOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-clock"></i>
<h3>Posting</h3>
</div>
<div class="adv-grid">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Posting Frequency</mat-label>
<mat-select formControlName="postingFrequency">
@for (opt of postingFrequencyOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Posting Day</mat-label>
<mat-select formControlName="postingDay">
@for (opt of postingDayOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
</form>
@@ -0,0 +1,144 @@
/**
* 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, Input, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { RegisterRateSelection, RegisterRateViewComponent } from '../register-rate-view/register-rate-view.component';
/**
* Interest step for an Advances A/C product (Figma step 5). Interest basis + spread, rate bounds,
* daily calculation and monthly posting. UI-only for now — not sent in the create payload.
*/
@Component({
selector: 'mifosx-loan-product-advance-interest-step',
templateUrl: './loan-product-advance-interest-step.component.html',
styleUrls: ['../advance-steps.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
RegisterRateViewComponent
]
})
export class LoanProductAdvanceInterestStepComponent {
private formBuilder = inject(UntypedFormBuilder);
loanProductAdvanceInterestForm: UntypedFormGroup;
/** Sub type and GL account picked on the Details step; they choose the Loan Interest Rate Register row. */
@Input() productSubType = '';
@Input() glAccountId: number | string | null = null;
/** The register row the product takes its rate from; null while the register has no row for it. */
registerSelection: RegisterRateSelection | null = null;
readonly interestBasisOptions = [
{ id: 'deposit_spread', label: 'Deposit Rate + Spread' },
{ id: 'fixed', label: 'Fixed rate' },
{ id: 'floating', label: 'Floating (EBLR-linked)' }
];
readonly calcMethodOptions = [
{ id: 'daily', label: 'Daily product (daily outstanding)' },
{ id: 'monthly', label: 'Monthly average balance' }
];
readonly daysInYearOptions = [
'Actual (365 / 366)',
'365',
'360'
];
readonly daysInMonthOptions = [
'Actual',
'30'
];
readonly chargedOnOptions = [
{ id: 'utilised', label: 'Utilised (debit) balance' },
{ id: 'limit', label: 'Sanctioned limit' }
];
readonly postingFrequencyOptions = [
'Monthly',
'Quarterly',
'Half-yearly',
'At Maturity'
];
readonly postingDayOptions = [
'Last day of month',
'1st of month',
'Anniversary day'
];
readonly yesNoOptions = [
'YES',
'NO'
];
constructor() {
this.loanProductAdvanceInterestForm = this.formBuilder.group({
interestBasis: ['deposit_spread'],
spread: [
2.0,
[
Validators.min(0),
Validators.max(20)
]
],
// Filled from the register row; never typed in.
minRate: [''],
maxRate: [''],
defaultRate: [''],
// The rate comes from the Loan Interest Rate Register, so a product with no register row cannot be created.
registerParticular: [
'',
Validators.required
],
calcMethod: ['daily'],
daysInYear: ['Actual (365 / 366)'],
daysInMonth: ['Actual'],
interestChargedOn: ['utilised'],
interestOnCreditBalance: ['NO'],
postingFrequency: ['Monthly'],
postingDay: ['Last day of month']
});
}
/**
* The register row decides the rate. A row of plain rates fixes the minimum and maximum; a deposit row
* ("1% + FD") is the deposit rate plus a spread, which has no plain minimum or maximum. With no row for the
* product the register particular stays empty, which keeps this step, and so the whole product, invalid until
* the product has been added to the register.
*/
onRegisterSelection(selection: RegisterRateSelection | null): void {
this.registerSelection = selection;
const form = this.loanProductAdvanceInterestForm;
if (selection) {
form.patchValue(
{
interestBasis: selection.isFormula ? 'deposit_spread' : 'fixed',
spread: selection.isFormula ? selection.minSpread : 0,
minRate: selection.minRate ?? '',
maxRate: selection.maxRate ?? '',
defaultRate: selection.defaultRate ?? '',
registerParticular: selection.particular
},
{ emitEvent: false }
);
} else {
form.patchValue({ minRate: '', maxRate: '', defaultRate: '', registerParticular: '' }, { emitEvent: false });
}
form.updateValueAndValidity({ emitEvent: false });
}
get showSpread(): boolean {
return this.loanProductAdvanceInterestForm.get('interestBasis').value === 'deposit_spread';
}
get loanProductAdvanceInterest() {
return this.loanProductAdvanceInterestForm.getRawValue();
}
}
@@ -0,0 +1,155 @@
<!--
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/.
-->
<form [formGroup]="loanProductAdvanceLimitTenureForm">
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-layer-group"></i>
<h3>Facility Type</h3>
</div>
<mat-radio-group formControlName="facilityType" class="adv-type-options">
<mat-radio-button value="demand" class="adv-type-option">
<span class="adv-type-option__label">Demand Loan</span>
<span class="adv-type-option__hint">Paid out once; repaid at maturity. No cheque book.</span>
</mat-radio-button>
<mat-radio-button value="overdraft" class="adv-type-option">
<span class="adv-type-option__label">Overdraft (Running)</span>
<span class="adv-type-option__hint">Draw and repay any time within the limit. Cheque book allowed.</span>
</mat-radio-button>
</mat-radio-group>
<div style="padding: 16px 24px 20px">
<div class="adv-info">
This choice decides whether the <strong>Cheque Book</strong> step appears. CC / OD sub types are always running.
</div>
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-coins"></i>
<h3>Limit</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Minimum Limit (₹)</mat-label>
<input matInput type="number" formControlName="minLimit" min="0" required />
<mat-error>Minimum Limit is <strong>required</strong></mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Default Limit (₹)</mat-label>
<input matInput type="number" formControlName="defaultLimit" min="0" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Maximum Limit (₹)</mat-label>
<input matInput type="number" formControlName="maxLimit" min="0" required />
<mat-error>Maximum Limit is <strong>required</strong></mat-error>
</mat-form-field>
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-clock"></i>
<h3>Tenure &amp; Repayment</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Minimum Tenure (months)</mat-label>
<input matInput type="number" formControlName="minTenure" [min]="tenureMin" [max]="tenureMax" required />
@if (isFdTenureLocked) {
<mat-hint>FD policy: 12–15 months only</mat-hint>
}
<mat-error>
@if (isFdTenureLocked) {
Must be between <strong>12 and 15</strong> months
} @else {
Minimum Tenure is <strong>required</strong>
}
</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Default Tenure (months)</mat-label>
<input matInput type="number" formControlName="defaultTenure" [min]="tenureMin" [max]="tenureMax" />
@if (isFdTenureLocked) {
<mat-hint>Between 12 and 15</mat-hint>
<mat-error>Must be between <strong>12 and 15</strong> months</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Maximum Tenure (months)</mat-label>
<input matInput type="number" formControlName="maxTenure" [min]="tenureMin" [max]="tenureMax" required />
@if (isFdTenureLocked) {
<mat-hint>FD policy: max 15 months (also capped at deposit maturity)</mat-hint>
} @else {
<mat-hint>Also capped at the deposit's maturity (Lien rule)</mat-hint>
}
<mat-error>
@if (isFdTenureLocked) {
Must be between <strong>12 and 15</strong> months
} @else {
Maximum Tenure is <strong>required</strong>
}
</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Repayment Type</mat-label>
<mat-select formControlName="repaymentType">
@for (opt of repaymentTypeOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field adv-field--full">
<mat-label>Appropriation Order</mat-label>
<mat-select formControlName="appropriationOrder">
@for (opt of appropriationOrderOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-toggle-on"></i>
<h3>Account Behaviour</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Debit Balance Allow</mat-label>
<mat-select formControlName="debitBalanceAllow">
@for (opt of yesNoOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Allow Debit Trans After Due Date</mat-label>
<mat-select formControlName="allowDebitTransAfterDueDate">
@for (opt of yesNoOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Branch Level A/c Type</mat-label>
<mat-select formControlName="branchLevelAccountType">
@for (opt of yesNoOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
<div class="adv-note">
<i class="fas fa-circle-info"></i>
<span>EMI fields (Number of Repayments, Repaid Every, Variable Installments) are hidden for an Advance.</span>
</div>
</form>
@@ -0,0 +1,171 @@
/**
* 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, Input } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { MatRadioButton, MatRadioGroup } from '@angular/material/radio';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Limit & Tenure step for an Advances A/C product (Figma step 4).
*
* Facility Type here decides the rest of the flow: an Overdraft (running) facility gets a cheque book,
* a Demand Loan does not. UI-only for now — not sent in the create payload.
*/
@Component({
selector: 'mifosx-loan-product-advance-limit-tenure-step',
templateUrl: './loan-product-advance-limit-tenure-step.component.html',
styleUrls: ['../advance-steps.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatRadioGroup,
MatRadioButton
]
})
export class LoanProductAdvanceLimitTenureStepComponent {
private formBuilder = inject(UntypedFormBuilder);
loanProductAdvanceLimitTenureForm: UntypedFormGroup;
/**
* Product sub type picked on the Details step. "Loan against FD" is bank-policy capped to a
* 12–15 month tenure, so the officer cannot create an FD product outside that band. Other
* advance sub types (Gold, RD, CC, …) stay open.
*/
private _productSubType = '';
@Input()
set productSubType(value: string) {
const next = value || '';
if (next === this._productSubType) {
return;
}
this._productSubType = next;
this.applyTenurePolicy();
}
get productSubType(): string {
return this._productSubType;
}
readonly repaymentTypeOptions = [
{ id: 'bullet', label: 'Bullet – principal + interest at maturity' },
{ id: 'running', label: 'Running – any time; clear by limit expiry' }
];
readonly appropriationOrderOptions = [
{ id: 'charges_penal_interest_principal', label: 'Charges → Penal → Interest → Principal' },
{ id: 'principal_interest_penal_charges', label: 'Principal → Interest → Penal → Charges' }
];
readonly yesNoOptions = [
'YES',
'NO'
];
constructor() {
this.loanProductAdvanceLimitTenureForm = this.formBuilder.group({
facilityType: ['demand'],
minLimit: [
'',
Validators.required
],
defaultLimit: [''],
maxLimit: [
'',
Validators.required
],
minTenure: [
12,
[
Validators.required,
Validators.min(1)
]
],
defaultTenure: [12],
maxTenure: [
15,
[
Validators.required,
Validators.min(1)
]
],
repaymentType: ['bullet'],
appropriationOrder: ['charges_penal_interest_principal'],
debitBalanceAllow: ['YES'],
allowDebitTransAfterDueDate: ['NO'],
branchLevelAccountType: ['NO']
});
}
/** Read by the wizard to decide whether the Cheque Book step applies. */
get isOverdraft(): boolean {
return this.loanProductAdvanceLimitTenureForm.get('facilityType').value === 'overdraft';
}
get loanProductAdvanceLimitTenure() {
return this.loanProductAdvanceLimitTenureForm.getRawValue();
}
/** True when the FD 12–15 month cap is in force (drives the hints/errors in the template). */
get isFdTenureLocked(): boolean {
return this._productSubType === 'fd';
}
get tenureMin(): number {
return this.isFdTenureLocked ? 12 : 1;
}
get tenureMax(): number | null {
return this.isFdTenureLocked ? 15 : null;
}
/**
* FD advances are bank-policy capped to 12–15 months; every other sub type stays open (min 1).
* Called whenever the sub type changes so the validators — and thus the wizard's validity gate —
* match the selected product.
*/
private applyTenurePolicy(): void {
const form = this.loanProductAdvanceLimitTenureForm;
if (!form) {
return;
}
const min = form.get('minTenure');
const def = form.get('defaultTenure');
const max = form.get('maxTenure');
if (this._productSubType === 'fd') {
const bounds = [
Validators.min(12),
Validators.max(15)
];
min.setValidators([
Validators.required,
...bounds
]);
def.setValidators(bounds);
max.setValidators([
Validators.required,
...bounds
]);
min.setValue(12, { emitEvent: false });
def.setValue(12, { emitEvent: false });
max.setValue(15, { emitEvent: false });
} else {
min.setValidators([
Validators.required,
Validators.min(1)
]);
def.setValidators([Validators.min(1)]);
max.setValidators([
Validators.required,
Validators.min(1)
]);
}
min.updateValueAndValidity({ emitEvent: false });
def.updateValueAndValidity({ emitEvent: false });
max.updateValueAndValidity({ emitEvent: false });
}
}
@@ -0,0 +1,144 @@
<!--
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/.
-->
<form [formGroup]="loanProductChequeBookForm">
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-money-check"></i>
<h3>Cheque Book Facility</h3>
<span class="adv-section__hint">Overdraft (running) only</span>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field adv-field--full" style="grid-column: span 2">
<mat-label>Cheque Type</mat-label>
<mat-select formControlName="chequeType">
@for (opt of chequeTypeOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Book Sizes Allowed (leaves)</mat-label>
<mat-select formControlName="bookSizes" multiple>
@for (opt of bookSizeOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Default Book Size (leaves)</mat-label>
<mat-select formControlName="defaultBookSize">
@for (opt of bookSizeOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Max Books Outstanding</mat-label>
<input matInput type="number" formControlName="maxBooksOutstanding" min="0" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Reissue When Unused Leaves ≤</mat-label>
<input matInput type="number" formControlName="reissueWhenLeaves" min="0" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>First Cheque Book Free</mat-label>
<mat-select formControlName="firstBookFree">
@for (opt of yesNoOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Free Cheque Books per Year</mat-label>
<input matInput type="number" formControlName="freeBooksPerYear" min="0" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Name Printed on Cheque</mat-label>
<mat-select formControlName="namePrinted">
@for (opt of namePrintedOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field adv-field--full" style="grid-column: span 2">
<mat-label>Cheque Print Description</mat-label>
<input matInput formControlName="chequePrintDescription" />
</mat-form-field>
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-shield-halved"></i>
<h3>Validation &amp; Security</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Positive Pay (PPS) Threshold (₹)</mat-label>
<input matInput type="number" formControlName="ppsThreshold" min="0" required />
<mat-hint>Issuer confirms cheques at or above this</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>PPS Mandatory Above (₹)</mat-label>
<input matInput type="number" formControlName="ppsMandatoryAbove" min="0" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Cheque Validity</mat-label>
<input matInput value="3 months (RBI)" disabled />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Stop Payment Allowed</mat-label>
<mat-select formControlName="stopPaymentAllowed">
@for (opt of yesNoOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Maintain Cheque Stock Register</mat-label>
<mat-select formControlName="maintainStockRegister">
@for (opt of yesNoOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Allow Withdrawal Slip</mat-label>
<mat-select formControlName="allowWithdrawalSlip">
@for (opt of yesNoOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-ban"></i>
<h3>Block Cheque Issue &amp; Clearing When</h3>
</div>
<div class="adv-subsection">
<div class="adv-checks">
<mat-checkbox formControlName="blockNpa">Account is NPA or out of order</mat-checkbox>
<mat-checkbox formControlName="blockLimitExpired">Limit has expired (renewal due)</mat-checkbox>
<mat-checkbox formControlName="blockOverDp">Debit would exceed the limit / drawing power</mat-checkbox>
<mat-checkbox formControlName="blockFrozen">Account is frozen or inoperative</mat-checkbox>
<mat-checkbox formControlName="blockLienReleased">
Pledged deposit has matured or its lien was released
</mat-checkbox>
</div>
</div>
</div>
<div class="adv-note">
<i class="fas fa-circle-info"></i>
<span>Cheque book fees (issue, per leaf, return, stop payment) are set on the Charges step.</span>
</div>
</form>
@@ -0,0 +1,92 @@
/**
* 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 { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { MatCheckbox } from '@angular/material/checkbox';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Cheque Book step (Figma step 8), shown only when the Facility Type is Overdraft (running).
* UI-only for now — not sent in the create payload.
*/
@Component({
selector: 'mifosx-loan-product-cheque-book-step',
templateUrl: './loan-product-cheque-book-step.component.html',
styleUrls: ['../advance-steps.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatCheckbox
]
})
export class LoanProductChequeBookStepComponent {
private formBuilder = inject(UntypedFormBuilder);
loanProductChequeBookForm: UntypedFormGroup;
readonly chequeTypeOptions = [
'CTS-2010 · Payable at par (multi-city)',
'CTS-2010 · Local',
'Non-CTS'
];
readonly bookSizeOptions = [
'10',
'25',
'50',
'100'
];
readonly namePrintedOptions = [
'Account holder name',
'Firm / entity name',
'As per instruction'
];
readonly yesNoOptions = [
'YES',
'NO'
];
constructor() {
this.loanProductChequeBookForm = this.formBuilder.group({
chequeType: ['CTS-2010 · Payable at par (multi-city)'],
bookSizes: [
[
'10',
'25',
'50'
]
],
defaultBookSize: ['25'],
maxBooksOutstanding: [1],
reissueWhenLeaves: [5],
firstBookFree: ['YES'],
freeBooksPerYear: [1],
namePrinted: ['Account holder name'],
chequePrintDescription: [''],
ppsThreshold: [
50000,
[
Validators.required,
Validators.min(0)
]
],
ppsMandatoryAbove: [500000],
stopPaymentAllowed: ['YES'],
maintainStockRegister: ['YES'],
allowWithdrawalSlip: ['NO'],
blockNpa: [true],
blockLimitExpired: [true],
blockOverDp: [true],
blockFrozen: [true],
blockLienReleased: [true]
});
}
get loanProductChequeBook() {
return this.loanProductChequeBookForm.getRawValue();
}
}
@@ -7,178 +7,190 @@
-->
<form [formGroup]="loanProductDetailsForm">
<div class="lpd-section">
<div class="lpd-section__header">
<i class="fas fa-info-circle"></i>
<h3>{{ 'labels.inputs.Product Name' | translate }} &amp; {{ 'labels.inputs.Description' | translate }}</h3>
</div>
<div class="lpd-grid">
<mat-form-field appearance="outline" class="lpd-field lpd-field--full">
<mat-label>{{ 'labels.inputs.Product Name' | translate }}</mat-label>
<input
matInput
formControlName="name"
matTooltip="{{ 'tooltips.Product name is a unique identifier' | translate }}"
required
/>
<mat-error>
{{ 'labels.inputs.Product Name' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>{{ 'labels.inputs.Short Name' | translate }}</mat-label>
<input
matInput
formControlName="shortName"
maxlength="4"
matTooltip="{{ 'tooltips.Short name is a unique identifier' | translate }}"
required
/>
<mat-error>
{{ 'labels.inputs.Short Name' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
</mat-form-field>
</div>
</div>
<div class="lpd-section">
<div class="lpd-section__header">
<i class="fas fa-calendar-alt"></i>
<h3>{{ 'labels.inputs.Availability' | translate }}</h3>
</div>
<div class="lpd-grid">
<mat-form-field appearance="outline" class="lpd-field" (click)="startDatePicker.open()">
<mat-label>{{ 'labels.inputs.Start Date' | translate }}</mat-label>
<input
matInput
[min]="minDate"
[max]="maxDate"
[matDatepicker]="startDatePicker"
matTooltip="{{ 'tooltips.loan product will be active and available to clients' | translate }}"
formControlName="startDate"
/>
<mat-datepicker-toggle matSuffix [for]="startDatePicker"></mat-datepicker-toggle>
<mat-datepicker #startDatePicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field" (click)="closeDatePicker.open()">
<mat-label>{{ 'labels.inputs.Close Date' | translate }}</mat-label>
<input
matInput
[min]="minDate"
[max]="maxDate"
[matDatepicker]="closeDatePicker"
matTooltip="{{ 'tooltips.loan product will become inactive and unavailable to clients' | translate }}"
formControlName="closeDate"
/>
<mat-datepicker-toggle matSuffix [for]="closeDatePicker"></mat-datepicker-toggle>
<mat-datepicker #closeDatePicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field lpd-field--full">
<mat-label>{{ 'labels.inputs.Description' | translate }}</mat-label>
<textarea
matInput
matTooltip="{{ 'tooltips.Provide additional information' | translate }}"
formControlName="description"
cdkTextareaAutosize
cdkAutosizeMinRows="2"
></textarea>
</mat-form-field>
</div>
</div>
<!-- Interest Information -->
<div class="lpd-section">
<div class="lpd-section__header">
<i class="fas fa-percentage"></i>
<h3>Interest Information</h3>
</div>
<div class="lpd-grid">
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Debit Int. Calculation</mat-label>
<mat-select formControlName="debitIntCalculation">
@for (opt of yesNoOptions; track opt.value) {
<mat-option [value]="opt.value">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Separate Penal Int.</mat-label>
<mat-select formControlName="separatePenalInterest">
@for (opt of yesNoOptions; track opt.value) {
<mat-option [value]="opt.value">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Credit Int. Calculation</mat-label>
<mat-select formControlName="creditIntCalculation">
@for (opt of yesNoOptions; track opt.value) {
<mat-option [value]="opt.value">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Sub A/c Type Balance</mat-label>
<mat-select formControlName="subAccountTypeBalance">
@for (opt of yesNoOptions; track opt.value) {
<mat-option [value]="opt.value">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Final Int. Rounding</mat-label>
<mat-select formControlName="finalIntRounding">
@for (opt of yesNoOptions; track opt.value) {
<mat-option [value]="opt.value">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
@if (loanProductDetailsForm.get('finalIntRounding')?.value) {
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Rounding UpTo</mat-label>
<input matInput type="number" formControlName="roundingUpTo" min="0" />
</mat-form-field>
}
</div>
</div>
<!-- Product Classification + GL Account — combined section -->
<!-- Product Type — picked first; it decides which sections the rest of the wizard shows -->
<div class="lpd-section">
<div class="lpd-section__header">
<i class="fas fa-sitemap"></i>
<h3>Product Classification</h3>
<h3>Product Type</h3>
</div>
<div class="lpd-grid">
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Product Type Group</mat-label>
<mat-select formControlName="productTypeGroup">
@for (group of productTypeGroups; track group.id) {
<mat-option [value]="group.id">{{ group.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-radio-group formControlName="productTypeGroup" class="lpd-type-options">
@for (group of productTypeGroups; track group.id) {
<mat-radio-button [value]="group.id" class="lpd-type-option">
<span class="lpd-type-option__label">{{ group.label }}</span>
<span class="lpd-type-option__hint">{{ group.hint }}</span>
</mat-radio-button>
}
</mat-radio-group>
@if (loanProductDetailsForm.get('productTypeGroup')?.value) {
@if (loanProductDetailsForm.get('productTypeGroup')?.value) {
<div class="lpd-grid">
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>GL Account ({{ glClassificationLabel }})</mat-label>
<mat-select formControlName="glAccount">
@for (acc of availableGlAccounts; track acc.id) {
<mat-option [value]="acc.id"> ({{ acc.glCode }}) {{ acc.name }} </mat-option>
<mat-label>Product Sub Type</mat-label>
<mat-select formControlName="productTypeSubGroup" [required]="!isExistingProduct">
@for (subGroup of availableSubGroups; track subGroup.id) {
<mat-option [value]="subGroup.id">{{ subGroup.label }}</mat-option>
}
</mat-select>
<mat-error>GL Account is required</mat-error>
<mat-error>Product Sub Type is <strong>required</strong></mat-error>
</mat-form-field>
}
</div>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>GL Account (Asset)</mat-label>
<mat-select
formControlName="glAccount"
[required]="!isExistingProduct"
matTooltip="Ledger GL of this product. Account numbers of its loan accounts are generated from this GL."
>
@for (acc of assetAccountOptions; track acc.id) {
<mat-option [value]="acc.id">({{ acc.glCode }}) {{ acc.name }}</mat-option>
}
</mat-select>
<mat-error>GL Account is <strong>required</strong></mat-error>
</mat-form-field>
</div>
}
</div>
@if (!isProductTypeChosen) {
<div class="lpd-type-pending">
<i class="fas fa-circle-info"></i>
Select a Product Type and Sub Type to continue.
</div>
}
@if (isProductTypeChosen) {
<div class="lpd-section">
<div class="lpd-section__header">
<i class="fas fa-info-circle"></i>
<h3>{{ 'labels.inputs.Product Name' | translate }} &amp; {{ 'labels.inputs.Description' | translate }}</h3>
</div>
<div class="lpd-grid">
<mat-form-field appearance="outline" class="lpd-field lpd-field--full">
<mat-label>{{ 'labels.inputs.Product Name' | translate }}</mat-label>
<input
matInput
formControlName="name"
matTooltip="{{ 'tooltips.Product name is a unique identifier' | translate }}"
required
/>
<mat-error>
{{ 'labels.inputs.Product Name' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>{{ 'labels.inputs.Short Name' | translate }}</mat-label>
<input
matInput
formControlName="shortName"
[maxlength]="isExistingProduct ? 4 : null"
matTooltip="{{ 'tooltips.Short name is a unique identifier' | translate }}"
required
(input)="normalizeShortName($event)"
/>
@if (loanProductDetailsForm.get('shortName').hasError('pattern')) {
<mat-error>Short Name must be exactly <strong>2 capital letters</strong> (A–Z)</mat-error>
} @else {
<mat-error>
{{ 'labels.inputs.Short Name' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field lpd-field--row-start" (click)="startDatePicker.open()">
<mat-label>{{ 'labels.inputs.Start Date' | translate }}</mat-label>
<input
matInput
[min]="startDateMin"
[max]="startDateMax"
[matDatepicker]="startDatePicker"
matTooltip="{{ 'tooltips.loan product will be active and available to clients' | translate }}"
formControlName="startDate"
/>
<mat-datepicker-toggle matSuffix [for]="startDatePicker"></mat-datepicker-toggle>
<mat-datepicker #startDatePicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field" (click)="closeDatePicker.open()">
<mat-label>{{ 'labels.inputs.Close Date' | translate }}</mat-label>
<input
matInput
[min]="closeDateMin"
[max]="maxDate"
[matDatepicker]="closeDatePicker"
matTooltip="{{ 'tooltips.loan product will become inactive and unavailable to clients' | translate }}"
formControlName="closeDate"
/>
<mat-datepicker-toggle matSuffix [for]="closeDatePicker"></mat-datepicker-toggle>
<mat-datepicker #closeDatePicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field lpd-field--full">
<mat-label>{{ 'labels.inputs.Description' | translate }}</mat-label>
<textarea
matInput
matTooltip="{{ 'tooltips.Provide additional information' | translate }}"
formControlName="description"
cdkTextareaAutosize
cdkAutosizeMinRows="2"
></textarea>
</mat-form-field>
</div>
</div>
<div class="lpd-section">
<div class="lpd-section__header">
<i class="fas fa-flag"></i>
<h3>Regulatory Classification</h3>
</div>
<div class="lpd-grid">
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Loan Product Category (RBI)</mat-label>
<mat-select formControlName="loanProductCategory">
@for (opt of loanProductCategoryOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Priority Sector</mat-label>
<mat-select formControlName="prioritySector">
@for (opt of prioritySectorOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>CIBIL Account Type</mat-label>
<mat-select formControlName="cibilAccountType">
@for (opt of cibilAccountTypeOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Key Fact Statement (KFS)</mat-label>
<mat-select formControlName="kfsEnabled">
@for (opt of yesNoOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>APR Calculation</mat-label>
<mat-select formControlName="aprEnabled">
@for (opt of yesNoOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
}
</form>
@@ -61,6 +61,57 @@ $gray-900: #202124;
&--full {
grid-column: 1 / -1;
}
// Starts a new row, so Start / Close Date sit together instead of one of them beside Short Name.
&--row-start {
grid-column-start: 1;
}
}
// Product Type — first section
.lpd-type-options {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
padding: 16px 24px 0;
@media (width <= 600px) {
grid-template-columns: 1fr;
}
}
.lpd-type-option {
border: 1px solid $border;
border-radius: 8px;
padding: 8px 12px;
&__label {
display: block;
font-weight: 600;
color: $gray-900;
}
&__hint {
display: block;
font-size: 12px;
color: #5f6368;
}
}
.lpd-type-pending {
display: flex;
align-items: center;
gap: 8px;
margin-top: 16px;
padding: 12px 24px;
border: 1px dashed $border;
border-radius: 10px;
font-size: 13px;
color: #5f6368;
i {
color: $accent;
}
}
.lpd-checkbox-row {
@@ -15,7 +15,9 @@ import { Dates } from 'app/core/utils/dates';
import { SettingsService } from 'app/settings/settings.service';
import { MatTooltip } from '@angular/material/tooltip';
import { CdkTextareaAutosize } from '@angular/cdk/text-field';
import { MatRadioButton, MatRadioGroup } from '@angular/material/radio';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { PRODUCT_SUB_TYPES, PRODUCT_TYPE_GROUPS } from '../../loan-product-types';
@Component({
selector: 'mifosx-loan-product-details-step',
@@ -24,7 +26,9 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
imports: [
...STANDALONE_SHARED_IMPORTS,
MatTooltip,
CdkTextareaAutosize
CdkTextareaAutosize,
MatRadioGroup,
MatRadioButton
]
})
export class LoanProductDetailsStepComponent implements OnInit {
@@ -39,6 +43,14 @@ export class LoanProductDetailsStepComponent implements OnInit {
minDate = new Date(2000, 0, 1);
maxDate = new Date(new Date().setFullYear(new Date().getFullYear() + 10));
// Start Date policy: a new product must start on the current day — the picker allows only today
// (neither past nor future). Widened for an existing product so its saved start date stays valid.
startDateMin: Date = new Date();
startDateMax: Date = new Date();
// Close (End) Date accepts only future dates — default min is tomorrow; widened for an existing product.
closeDateMin: Date = new Date();
/**
* @param {FormBuilder} formBuilder Form Builder.
* @param {Dates} dateUtils Date Utils.
@@ -50,90 +62,133 @@ export class LoanProductDetailsStepComponent implements OnInit {
}
ngOnInit() {
// Product Type is not persisted by the backend yet, so an existing product comes back without one.
// Only a new product is made to pick it first; editing must not hide or block the rest of the form.
this.isExistingProduct = !!this.loanProductsTemplate?.id;
if (!this.isExistingProduct) {
this.loanProductDetailsForm.get('productTypeGroup').setValidators(Validators.required);
this.loanProductDetailsForm.get('productTypeSubGroup').setValidators(Validators.required);
this.loanProductDetailsForm.get('glAccount').setValidators(Validators.required);
// A new product's short name is exactly two capital letters. Saved products keep the old 4-character
// rule, so an existing short name such as 'PW16' does not stop the product from being edited.
this.loanProductDetailsForm.get('shortName').setValidators([
Validators.required,
Validators.pattern(/^[A-Z]{2}$/)
]);
this.loanProductDetailsForm.get('productTypeGroup').updateValueAndValidity({ emitEvent: false });
this.loanProductDetailsForm.get('productTypeSubGroup').updateValueAndValidity({ emitEvent: false });
this.loanProductDetailsForm.get('glAccount').updateValueAndValidity({ emitEvent: false });
this.loanProductDetailsForm.get('shortName').updateValueAndValidity({ emitEvent: false });
}
// A loan / advance is an asset of the bank, so its ledger GL is always an asset account.
this.assetAccountOptions = this.loanProductsTemplate?.accountingMappingOptions?.assetAccountOptions ?? [];
// Start Date: default to today for a new product and lock the picker to the current day.
// An existing product keeps the wider range so its saved date remains selectable/valid.
if (this.isExistingProduct) {
this.startDateMin = this.minDate;
this.startDateMax = this.maxDate;
this.closeDateMin = this.minDate;
} else {
this.startDateMin = new Date();
this.startDateMax = new Date();
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
this.closeDateMin = tomorrow;
}
this.loanProductDetailsForm.patchValue({
name: this.loanProductsTemplate.name,
shortName: this.loanProductsTemplate.shortName,
description: this.loanProductsTemplate.description,
startDate: this.loanProductsTemplate.startDate && new Date(this.loanProductsTemplate.startDate),
startDate: this.loanProductsTemplate.startDate ? new Date(this.loanProductsTemplate.startDate) : new Date(),
closeDate: this.loanProductsTemplate.closeDate && new Date(this.loanProductsTemplate.closeDate)
});
const mappingOptions = this.loanProductsTemplate?.accountingMappingOptions ?? {};
this.assetAccountOptions = mappingOptions.assetAccountOptions ?? [];
this.liabilityAccountOptions = mappingOptions.liabilityAccountOptions ?? [];
this.loanProductDetailsForm.patchValue({
debitIntCalculation: this.loanProductsTemplate.debitIntCalculation ?? false,
separatePenalInterest: this.loanProductsTemplate.separatePenalInterest ?? false,
creditIntCalculation: this.loanProductsTemplate.creditIntCalculation ?? false,
subAccountTypeBalance: this.loanProductsTemplate.subAccountTypeBalance ?? false,
finalIntRounding: this.loanProductsTemplate.finalIntRounding ?? false,
roundingUpTo: this.loanProductsTemplate.roundingUpTo ?? null
});
}
/** Product Classification data */
readonly productTypeGroups = [
{ id: 'loans', label: 'Loan' },
{ id: 'advanced', label: 'Advanced' }
/** True when editing a saved product; see ngOnInit. */
isExistingProduct = false;
/** Product Type -- picked first, since it decides which sections the rest of the wizard shows. */
/** Category and type lists are shared with the product list and the loan account product picker. */
readonly productTypeGroups = PRODUCT_TYPE_GROUPS;
readonly productTypeSubGroups = PRODUCT_SUB_TYPES;
/** Regulatory Classification options (UI-only; RBI / bureau reporting). */
readonly loanProductCategoryOptions = [
{ id: 'advances_deposits', label: 'Advances against deposits' },
{ id: 'gold_loan', label: 'Gold loan' },
{ id: 'agriculture', label: 'Agriculture' },
{ id: 'msme', label: 'MSME' },
{ id: 'personal', label: 'Personal / retail' },
{ id: 'housing', label: 'Housing' },
{ id: 'other', label: 'Other' }
];
readonly productTypeSubGroups: Record<string, { id: string; label: string }[]> = {
loans: [
{ id: 'emi', label: 'EMI' },
{ id: 'cc', label: 'CC (Cash Credit)' },
{ id: 'overdraft', label: 'Overdraft' },
{ id: 'warehouse', label: 'Warehouse' },
{ id: 'gold', label: 'Gold' }
],
advanced: [
{ id: 'fd', label: 'FD (Fixed Deposit)' },
{ id: 'rd', label: 'RD (Recurring Deposit)' }
]
};
readonly prioritySectorOptions = [
{ id: 'yes', label: 'Yes' },
{ id: 'no', label: 'No' }
];
/** Default GL per product type group */
private readonly glDefaultMap: Record<string, string> = {
loans: 'asset',
advanced: 'asset'
};
readonly cibilAccountTypeOptions = [
{ id: 'loan_bank_deposits', label: 'Loan against Bank Deposits' },
{ id: 'gold_loan', label: 'Gold Loan' },
{ id: 'overdraft', label: 'Overdraft' },
{ id: 'business_loan', label: 'Business Loan' },
{ id: 'other', label: 'Other' }
];
readonly yesNoOptions = [
'YES',
'NO'
];
/** The rest of the Details form stays hidden on a new product until its type and sub type are picked. */
get isProductTypeChosen(): boolean {
if (this.isExistingProduct) {
return true;
}
const value = this.loanProductDetailsForm.value;
return !!value.productTypeGroup && !!value.productTypeSubGroup;
}
get availableSubGroups() {
const group = this.loanProductDetailsForm.get('productTypeGroup')?.value;
return group ? (this.productTypeSubGroups[group] ?? []) : [];
}
get glOptions(): string[] {
return [
'asset',
'liability'
];
}
/**
* Asset GLs offered for the product's ledger GL -- the GL the product's loan account numbers are
* generated from. Separate from the Accounting tab mappings, which drive the journal entries.
*/
assetAccountOptions: any[] = [];
liabilityAccountOptions: any[] = [];
get availableGlAccounts(): any[] {
const type = this.loanProductDetailsForm.get('glClassification')?.value;
if (type === 'asset') return this.assetAccountOptions;
if (type === 'liability') return this.liabilityAccountOptions;
return [];
}
get glClassificationLabel(): string {
const type = this.loanProductDetailsForm.get('glClassification')?.value;
return type === 'liability' ? 'Liability' : 'Asset';
}
get selectedGroupLabel(): string {
const group = this.loanProductDetailsForm.get('productTypeGroup')?.value;
return this.productTypeGroups.find((g) => g.id === group)?.label ?? '';
}
readonly yesNoOptions = [
{ value: true, label: 'YES' },
{ value: false, label: 'NO' }
];
/**
* A new product's short name is two capital letters, so what is typed or pasted is upper-cased, anything
* that is not a letter is dropped, and only the first two letters are kept. Trimming here rather than with
* maxlength means a paste like 'g1old' still yields 'GO' instead of being cut to 'g1' first.
*/
normalizeShortName(event: Event) {
if (this.isExistingProduct) {
return;
}
// Read from the input itself: this handler can run before the form control has taken the new value.
const typed = (event.target as HTMLInputElement).value ?? '';
const cleaned = typed
.toUpperCase()
.replace(/[^A-Z]/g, '')
.slice(0, 2);
if (cleaned !== typed) {
this.loanProductDetailsForm.get('shortName').setValue(cleaned);
}
}
createLoanProductDetailsForm() {
this.loanProductDetailsForm = this.formBuilder.group({
@@ -150,21 +205,26 @@ export class LoanProductDetailsStepComponent implements OnInit {
closeDate: [''],
productTypeGroup: [''],
productTypeSubGroup: [''],
glClassification: ['asset'],
glAccount: [''],
debitIntCalculation: [false],
separatePenalInterest: [false],
creditIntCalculation: [false],
subAccountTypeBalance: [false],
finalIntRounding: [false],
roundingUpTo: [null]
// Regulatory Classification (UI-only; stripped in buildPayload)
loanProductCategory: ['advances_deposits'],
prioritySector: ['no'],
cibilAccountType: ['loan_bank_deposits'],
kfsEnabled: ['YES'],
aprEnabled: ['YES']
});
// Auto-set GL based on product type group
this.loanProductDetailsForm.get('productTypeGroup')?.valueChanges.subscribe((group: string) => {
const defaultVal = this.glDefaultMap[group] ?? 'asset';
this.loanProductDetailsForm.get('glClassification')?.setValue(defaultVal);
this.loanProductDetailsForm.get('glAccount')?.setValue('');
// A sub type belongs to one group, so changing the group clears it.
this.loanProductDetailsForm.get('productTypeGroup')?.valueChanges.subscribe(() => {
this.loanProductDetailsForm.get('productTypeSubGroup')?.setValue('');
});
// Picking a sub type pre-fills the Short Name with its code; the user can still change it.
this.loanProductDetailsForm.get('productTypeSubGroup')?.valueChanges.subscribe((subGroupId: string) => {
const subGroup = this.availableSubGroups.find((s) => s.id === subGroupId);
if (subGroup) {
this.loanProductDetailsForm.get('shortName').setValue(subGroup.shortName);
}
});
}
@@ -0,0 +1,150 @@
<!--
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/.
-->
<form [formGroup]="loanProductLoanAmountTenureForm">
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-coins"></i>
<h3>Loan Amount</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Minimum Amount (₹)</mat-label>
<input matInput type="number" formControlName="minAmount" min="0" required />
<mat-error>Minimum Amount is <strong>required</strong></mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Default Amount (₹)</mat-label>
<input matInput type="number" formControlName="defaultAmount" min="0" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Maximum Amount (₹)</mat-label>
<input matInput type="number" formControlName="maxAmount" min="0" required />
<mat-error>Maximum Amount is <strong>required</strong></mat-error>
</mat-form-field>
</div>
<div style="padding: 0 24px 20px">
<div class="adv-info">
Each loan is sanctioned within these bounds. The amount is fixed at sanction. It is not a limit that can be
drawn and repaid again.
</div>
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-clock"></i>
<h3>Tenure</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Minimum Tenure (months)</mat-label>
<input matInput type="number" formControlName="minTenure" min="1" required />
<mat-error>Minimum Tenure is <strong>required</strong></mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Default Tenure (months)</mat-label>
<input matInput type="number" formControlName="defaultTenure" min="1" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Maximum Tenure (months)</mat-label>
<input matInput type="number" formControlName="maxTenure" min="1" required />
<mat-hint>Age at maturity cap comes from Borrower Eligibility below</mat-hint>
<mat-error>Maximum Tenure is <strong>required</strong></mat-error>
</mat-form-field>
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-user"></i>
<h3>Borrower Eligibility</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Minimum Age (years)</mat-label>
<input matInput type="number" formControlName="minAge" min="18" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Maximum Age at Maturity (years)</mat-label>
<input matInput type="number" formControlName="maxAgeAtMaturity" min="18" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Minimum CIBIL Score</mat-label>
<input matInput type="number" formControlName="minCibil" min="300" max="900" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Minimum Monthly Income (₹)</mat-label>
<input matInput type="number" formControlName="minMonthlyIncome" min="0" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Maximum FOIR / DBR (%)</mat-label>
<input matInput type="number" formControlName="maxFoir" min="0" max="100" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Maximum Loans per Customer</mat-label>
<input matInput type="number" formControlName="maxLoansPerCustomer" min="1" />
</mat-form-field>
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-arrow-right"></i>
<h3>Disbursement</h3>
</div>
<mat-radio-group formControlName="disbursementType" class="adv-type-options">
<mat-radio-button value="single" class="adv-type-option">
<span class="adv-type-option__label">Single disbursement</span>
<span class="adv-type-option__hint">Full amount paid out once.</span>
</mat-radio-button>
<mat-radio-button value="multiple" class="adv-type-option">
<span class="adv-type-option__label">Multiple tranches</span>
<span class="adv-type-option__hint">Paid out in stages, for example as construction progresses.</span>
</mat-radio-button>
</mat-radio-group>
<div class="adv-grid adv-grid--3">
@if (isMultiTranche) {
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Maximum Tranches</mat-label>
<input matInput type="number" formControlName="maxTranches" min="2" />
<mat-error>At least <strong>2</strong> tranches</mat-error>
</mat-form-field>
}
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Disbursement Mode</mat-label>
<mat-select formControlName="disbursementMode">
@for (opt of disbursementModeOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Interest on Undisbursed Amount</mat-label>
<mat-select formControlName="undisbursedInterest">
@for (opt of undisbursedInterestOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="adv-subsection">
<mat-checkbox formControlName="preEmiInterest">
Pre-EMI interest is charged on each disbursed tranche until the first full EMI
</mat-checkbox>
</div>
</div>
<div class="adv-note">
<i class="fas fa-info-circle"></i>
<span>
<strong>Hidden for Loans A/C:</strong> Facility Type (Demand / Overdraft), Drawing power, Limit renewal, Cheque
book.
</span>
</div>
</form>
@@ -0,0 +1,106 @@
/**
* 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 { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms';
import { MatCheckbox } from '@angular/material/checkbox';
import { MatRadioButton, MatRadioGroup } from '@angular/material/radio';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Amount & Tenure step for a Loans A/C (EMI) product (step 4): fixed sanctioned amount, tenure,
* borrower eligibility and disbursement. Amount, tenure and tranches feed Fineract's core product
* fields; eligibility is UI-only for now.
*/
@Component({
selector: 'mifosx-loan-product-loan-amount-tenure-step',
templateUrl: './loan-product-loan-amount-tenure-step.component.html',
styleUrls: ['../advance-steps.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatCheckbox,
MatRadioGroup,
MatRadioButton
]
})
export class LoanProductLoanAmountTenureStepComponent {
private formBuilder = inject(UntypedFormBuilder);
loanProductLoanAmountTenureForm: UntypedFormGroup;
readonly disbursementModeOptions = [
{ id: 'transfer_dd_vendor', label: 'Transfer to A/C, DD, Direct to vendor' },
{ id: 'transfer', label: 'Transfer to A/C only' },
{ id: 'dd', label: 'Demand draft only' }
];
readonly undisbursedInterestOptions = [
{ id: 'none', label: 'None — interest only on disbursed' },
{ id: 'commitment', label: 'Commitment charge on undisbursed' }
];
constructor() {
this.loanProductLoanAmountTenureForm = this.formBuilder.group({
minAmount: [
'',
[
Validators.required,
Validators.min(0)
]
],
defaultAmount: [''],
maxAmount: [
'',
[
Validators.required,
Validators.min(0)
]
],
minTenure: [
12,
[
Validators.required,
Validators.min(1)
]
],
defaultTenure: [
'',
Validators.min(1)
],
maxTenure: [
360,
[
Validators.required,
Validators.min(1)
]
],
minAge: [21],
maxAgeAtMaturity: [70],
minCibil: [650],
minMonthlyIncome: [''],
maxFoir: [50],
maxLoansPerCustomer: [1],
disbursementType: ['single'],
maxTranches: [
2,
Validators.min(2)
],
disbursementMode: ['transfer_dd_vendor'],
undisbursedInterest: ['none'],
preEmiInterest: [true]
});
}
get isMultiTranche(): boolean {
return this.loanProductLoanAmountTenureForm.get('disbursementType').value === 'multiple';
}
get loanProductLoanAmountTenure() {
return this.loanProductLoanAmountTenureForm.getRawValue();
}
}
@@ -0,0 +1,161 @@
<!--
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/.
-->
<form [formGroup]="loanProductLoanInterestForm">
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-percent"></i>
<h3>Interest Type</h3>
</div>
<mat-radio-group formControlName="interestType" class="adv-type-options adv-type-options--3">
<mat-radio-button value="fixed" class="adv-type-option">
<span class="adv-type-option__label">Fixed</span>
<span class="adv-type-option__hint">Same rate for the whole tenure</span>
</mat-radio-button>
<mat-radio-button value="floating" class="adv-type-option">
<span class="adv-type-option__label">Floating</span>
<span class="adv-type-option__hint">Linked to a benchmark; resets periodically</span>
</mat-radio-button>
<mat-radio-button value="hybrid" class="adv-type-option">
<span class="adv-type-option__label">Hybrid</span>
<span class="adv-type-option__hint">Fixed for a period, then floating</span>
</mat-radio-button>
</mat-radio-group>
<div style="height: 20px"></div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-table-cells"></i>
<h3>Rate Source</h3>
<span class="adv-section__hint">Centralised — changes flow to all loans on this product</span>
</div>
<div style="padding: 16px 0 8px">
<mifosx-register-rate-view
[productSubType]="productSubType"
[glAccountId]="glAccountId"
(selectionChange)="onRegisterSelection($event)"
></mifosx-register-rate-view>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Apply CIBIL Adjustment</mat-label>
<mat-select formControlName="applyCibilAdjustment">
@for (opt of yesNoOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Spread over Register Rate (%)</mat-label>
<input matInput type="number" formControlName="spread" min="0" max="20" step="0.01" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Rate Resolved At</mat-label>
<mat-select formControlName="rateResolvedAt">
@for (opt of rateResolvedAtOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-chart-simple"></i>
<h3>Calculation</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Calculation Method</mat-label>
<mat-select formControlName="calcMethod">
@for (opt of calcMethodOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Days in Year</mat-label>
<mat-select formControlName="daysInYear">
@for (opt of daysInYearOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Days in Month</mat-label>
<mat-select formControlName="daysInMonth">
@for (opt of daysInMonthOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Calculation Period</mat-label>
<mat-select formControlName="calcPeriod">
@for (opt of calcPeriodOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Interest Starts From</mat-label>
<mat-select formControlName="interestStartsFrom">
@for (opt of interestStartOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Compounding</mat-label>
<mat-select formControlName="compounding">
@for (opt of compoundingOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
@if (isFloatingOrHybrid) {
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-rotate"></i>
<h3>Floating Rate Reset</h3>
<span class="adv-section__hint">Shown only when the type is Floating or Hybrid</span>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Benchmark</mat-label>
<mat-select formControlName="benchmark">
@for (opt of benchmarkOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Reset Frequency</mat-label>
<mat-select formControlName="resetFrequency">
@for (opt of resetFrequencyOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>On Reset, Change</mat-label>
<mat-select formControlName="resetChange">
@for (opt of resetChangeOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
}
</form>
@@ -0,0 +1,188 @@
/**
* 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, Input, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms';
import { MatRadioButton, MatRadioGroup } from '@angular/material/radio';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { RegisterRateSelection, RegisterRateViewComponent } from '../register-rate-view/register-rate-view.component';
/**
* Interest step for a Loans A/C (EMI) product (step 6). The rate normally comes from the central
* Loan Interest Rate Register (amount slab + CIBIL adjustment); the min / default / max rate and the
* calculation method feed Fineract's core product fields. Register link, spread and floating reset
* are UI-only for now.
*/
@Component({
selector: 'mifosx-loan-product-loan-interest-step',
templateUrl: './loan-product-loan-interest-step.component.html',
styleUrls: ['../advance-steps.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatRadioGroup,
MatRadioButton,
RegisterRateViewComponent
]
})
export class LoanProductLoanInterestStepComponent {
private formBuilder = inject(UntypedFormBuilder);
loanProductLoanInterestForm: UntypedFormGroup;
/** Sub type and GL account picked on the Details step; they choose the Loan Interest Rate Register row. */
@Input() productSubType = '';
@Input() glAccountId: number | string | null = null;
/** The register row the product takes its rate from; null while the register has no row for it. */
registerSelection: RegisterRateSelection | null = null;
/** The 16 default particulars of the Loan Interest Rate Register. */
readonly registerParticulars = [
'Housing Loan (Purchase/Construction)',
'Home Repair/Renovation',
'Personal Loan (Unsecured)',
'Personal Loan (Secured/Property)',
'Two Wheeler Loan',
'Car Loan (New)',
'Used Car/Vehicle Loan',
'Commercial Vehicle Loan',
'Gold Loan',
'Business Term Loan',
'Machinery & Equipment Loan',
'Cash Credit',
'Overdraft',
'Agriculture Loan',
'Loan against FDR',
'Loan against RD'
];
readonly yesNoOptions = [
'YES',
'NO'
];
readonly rateResolvedAtOptions = [
{ id: 'sanction', label: 'Sanction' },
{ id: 'disbursement', label: 'First disbursement' }
];
readonly calcMethodOptions = [
{ id: 'declining', label: 'Declining Balance' },
{ id: 'flat', label: 'Flat' }
];
/** `fineractId` maps to Fineract's daysInYearType (1 = actual). */
readonly daysInYearOptions = [
{ id: 'actual', label: 'Actual (365 / 366)', fineractId: 1 },
{ id: '365', label: '365', fineractId: 365 },
{ id: '360', label: '360', fineractId: 360 }
];
/** `fineractId` maps to Fineract's daysInMonthType (1 = actual). */
readonly daysInMonthOptions = [
{ id: 'actual', label: 'Actual', fineractId: 1 },
{ id: '30', label: '30', fineractId: 30 }
];
readonly calcPeriodOptions = [
{ id: 'same', label: 'Same as repayment period' },
{ id: 'daily', label: 'Daily' }
];
readonly interestStartOptions = [
{ id: 'disbursement', label: 'Disbursement date' },
{ id: 'first_emi', label: 'First EMI date' }
];
readonly compoundingOptions = [
{ id: 'none', label: 'None (simple on outstanding)' },
{ id: 'monthly', label: 'Monthly' }
];
readonly benchmarkOptions = [
{ id: 'repo', label: 'Repo / MCLR / Bank base rate' },
{ id: 'eblr', label: 'External benchmark (EBLR)' }
];
readonly resetFrequencyOptions = [
{ id: 'quarterly', label: 'Quarterly' },
{ id: 'half_yearly', label: 'Half-yearly' },
{ id: 'yearly', label: 'Yearly' }
];
readonly resetChangeOptions = [
{ id: 'tenure', label: 'Tenure (EMI stays constant)' },
{ id: 'emi', label: 'EMI (tenure stays constant)' }
];
constructor() {
this.loanProductLoanInterestForm = this.formBuilder.group({
interestType: ['fixed'],
// The rate comes from the Loan Interest Rate Register, so a product with no register row cannot be created.
registerParticular: [
'',
Validators.required
],
applyCibilAdjustment: ['YES'],
spread: [
0,
[
Validators.min(0),
Validators.max(20)
]
],
rateResolvedAt: ['sanction'],
// Filled from the register row; never typed in.
minRate: [''],
defaultRate: [''],
maxRate: [''],
calcMethod: ['declining'],
daysInYear: ['actual'],
daysInMonth: ['actual'],
calcPeriod: ['same'],
interestStartsFrom: ['disbursement'],
compounding: ['none'],
benchmark: ['repo'],
resetFrequency: ['quarterly'],
resetChange: ['tenure']
});
}
/**
* The register row decides the rate limits. With no row for the product the register particular stays empty,
* which keeps this step, and so the whole product, invalid until the product has been added to the register.
*/
onRegisterSelection(selection: RegisterRateSelection | null): void {
this.registerSelection = selection;
const form = this.loanProductLoanInterestForm;
if (selection) {
form.patchValue(
{
registerParticular: selection.particular,
minRate: selection.minRate ?? '',
maxRate: selection.maxRate ?? '',
defaultRate: selection.defaultRate ?? ''
},
{ emitEvent: false }
);
} else {
form.patchValue({ registerParticular: '', minRate: '', maxRate: '', defaultRate: '' }, { emitEvent: false });
}
form.updateValueAndValidity({ emitEvent: false });
}
/** Reset options only apply to a floating or hybrid rate. */
get isFloatingOrHybrid(): boolean {
const type = this.loanProductLoanInterestForm.get('interestType').value;
return type === 'floating' || type === 'hybrid';
}
get loanProductLoanInterest() {
return this.loanProductLoanInterestForm.getRawValue();
}
}
@@ -0,0 +1,144 @@
<!--
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/.
-->
<form [formGroup]="loanProductLoanPrepaymentForm">
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-arrow-down"></i>
<h3>Part-prepayment</h3>
</div>
<div class="adv-subsection">
<mat-checkbox formControlName="partPrepaymentAllowed">Part-prepayment allowed</mat-checkbox>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Minimum Part-payment (₹)</mat-label>
<input matInput type="number" formControlName="minPartPayment" min="0" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Allowed Only On</mat-label>
<mat-select formControlName="allowedOn">
@for (opt of allowedOnOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Part-payments per Year</mat-label>
<mat-select formControlName="partPaymentsPerYear">
@for (opt of perYearOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<mat-radio-group formControlName="partPrepaymentMode" class="adv-type-options adv-type-options--3">
<mat-radio-button value="reduce_tenure" class="adv-type-option">
<span class="adv-type-option__label">Reduce tenure</span>
<span class="adv-type-option__hint">EMI stays the same</span>
</mat-radio-button>
<mat-radio-button value="reduce_emi" class="adv-type-option">
<span class="adv-type-option__label">Reduce EMI</span>
<span class="adv-type-option__hint">Tenure stays the same</span>
</mat-radio-button>
<mat-radio-button value="borrower_chooses" class="adv-type-option">
<span class="adv-type-option__label">Borrower chooses</span>
<span class="adv-type-option__hint">Picked at payment time</span>
</mat-radio-button>
</mat-radio-group>
<div style="height: 20px"></div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-lock"></i>
<h3>Pre-closure (Foreclosure)</h3>
<span class="adv-section__hint">Charge on outstanding principal</span>
</div>
<div class="adv-grid">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Individual · floating rate (%)</mat-label>
<input matInput type="number" formControlName="foreclosureFloatingIndividual" min="0" max="100" step="0.01" />
<mat-hint>Nil as per RBI</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Individual · fixed rate (%)</mat-label>
<input matInput type="number" formControlName="foreclosureFixedIndividual" min="0" max="100" step="0.01" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Non-individual / business (%)</mat-label>
<input matInput type="number" formControlName="foreclosureNonIndividual" min="0" max="100" step="0.01" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Takeover by another bank (%)</mat-label>
<input matInput type="number" formControlName="foreclosureTakeover" min="0" max="100" step="0.01" />
</mat-form-field>
</div>
<div class="adv-grid adv-grid--3" style="padding-top: 0">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Lock-in Period (months)</mat-label>
<input matInput type="number" formControlName="lockInMonths" min="0" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Interest on Pre-closure</mat-label>
<mat-select formControlName="interestOnPreclosure">
@for (opt of interestOnPreclosureOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Foreclosure Quote Valid (days)</mat-label>
<input matInput type="number" formControlName="foreclosureQuoteValidDays" min="1" />
</mat-form-field>
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-flag-checkered"></i>
<h3>Closure &amp; Restructuring</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Auto-close on Zero Balance</mat-label>
<mat-select formControlName="autoCloseOnZeroBalance">
@for (opt of autoCloseOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>NOC / Closure Letter</mat-label>
<mat-select formControlName="nocLetter">
@for (opt of nocOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Restructuring / Re-schedule</mat-label>
<mat-select formControlName="restructuring">
@for (opt of restructuringOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="adv-subsection">
<div class="adv-checks">
<mat-checkbox formControlName="releaseCollateralOnClosure">Release collateral and lien on closure</mat-checkbox>
<mat-checkbox formControlName="topUpAllowed">Top-up loan on the same account</mat-checkbox>
<mat-checkbox formControlName="balanceTransferOutAllowed">Allow balance transfer (takeover) out</mat-checkbox>
</div>
</div>
<div style="padding: 0 24px 20px">
<div class="adv-info">Unlike an Advance, there is no renewal. A loan ends at maturity or on pre-closure.</div>
</div>
</div>
</form>
@@ -0,0 +1,117 @@
/**
* 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 { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms';
import { MatCheckbox } from '@angular/material/checkbox';
import { MatRadioButton, MatRadioGroup } from '@angular/material/radio';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Prepayment & Closure step for a Loans A/C (EMI) product (step 8). A loan has no renewal; it ends at
* maturity or on pre-closure. UI-only for now: not sent in the create payload.
*/
@Component({
selector: 'mifosx-loan-product-loan-prepayment-step',
templateUrl: './loan-product-loan-prepayment-step.component.html',
styleUrls: ['../advance-steps.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatCheckbox,
MatRadioGroup,
MatRadioButton
]
})
export class LoanProductLoanPrepaymentStepComponent {
private formBuilder = inject(UntypedFormBuilder);
loanProductLoanPrepaymentForm: UntypedFormGroup;
readonly allowedOnOptions = [
{ id: 'emi_date', label: 'EMI due date' },
{ id: 'any_day', label: 'Any day' }
];
readonly perYearOptions = [
{ id: 'unlimited', label: 'Unlimited' },
{ id: 'one', label: 'Once a year' },
{ id: 'two', label: 'Twice a year' }
];
readonly interestOnPreclosureOptions = [
{ id: 'till_date', label: 'Till date of payment' },
{ id: 'full_month', label: 'Full current month' }
];
readonly autoCloseOptions = [
'YES',
'NO'
];
readonly nocOptions = [
{ id: 'auto', label: 'Auto-generate' },
{ id: 'manual', label: 'On request' }
];
readonly restructuringOptions = [
{ id: 'approval', label: 'Allowed with approval' },
{ id: 'no', label: 'Not allowed' }
];
constructor() {
const percent = [
Validators.min(0),
Validators.max(100)
];
this.loanProductLoanPrepaymentForm = this.formBuilder.group({
partPrepaymentAllowed: [true],
minPartPayment: [
'',
Validators.min(0)
],
allowedOn: ['emi_date'],
partPaymentsPerYear: ['unlimited'],
partPrepaymentMode: ['reduce_tenure'],
foreclosureFloatingIndividual: [
0,
percent
],
foreclosureFixedIndividual: [
2,
percent
],
foreclosureNonIndividual: [
3,
percent
],
foreclosureTakeover: [
2,
percent
],
lockInMonths: [
0,
Validators.min(0)
],
interestOnPreclosure: ['till_date'],
foreclosureQuoteValidDays: [
7,
Validators.min(1)
],
autoCloseOnZeroBalance: ['YES'],
nocLetter: ['auto'],
restructuring: ['approval'],
releaseCollateralOnClosure: [true],
topUpAllowed: [false],
balanceTransferOutAllowed: [true]
});
}
get loanProductLoanPrepayment() {
return this.loanProductLoanPrepaymentForm.getRawValue();
}
}
@@ -0,0 +1,155 @@
<!--
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/.
-->
<form [formGroup]="loanProductLoanRepaymentForm">
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-wave-square"></i>
<h3>Repayment Type</h3>
</div>
<mat-radio-group formControlName="repaymentType" class="adv-type-options adv-type-options--3">
@for (opt of repaymentTypeOptions; track opt.id) {
<mat-radio-button [value]="opt.id" class="adv-type-option">
<span class="adv-type-option__label">{{ opt.label }}</span>
<span class="adv-type-option__hint">{{ opt.hint }}</span>
</mat-radio-button>
}
</mat-radio-group>
<div style="padding: 16px 24px 20px">
<div class="adv-info">
This is the main difference from an Advance. Repayment follows a fixed schedule, not a running balance.
</div>
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-calendar-alt"></i>
<h3>Schedule</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Repaid Every</mat-label>
<input matInput type="number" formControlName="repaidEvery" min="1" required />
<mat-error>Repaid Every is <strong>required</strong></mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Frequency</mat-label>
<mat-select formControlName="frequency">
@for (opt of frequencyOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Minimum Days to First Repayment</mat-label>
<input matInput type="number" formControlName="minDaysToFirstRepayment" min="0" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>EMI Due Day</mat-label>
<mat-select formControlName="emiDueDay">
@for (opt of emiDueDayOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>If Due Date is a Holiday</mat-label>
<mat-select formControlName="holidayRule">
@for (opt of holidayRuleOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Moratorium / Grace (months)</mat-label>
<input matInput type="number" formControlName="moratoriumMonths" min="0" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Interest during Moratorium</mat-label>
<mat-select formControlName="moratoriumInterest">
@for (opt of moratoriumInterestOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="adv-subsection">
<mat-checkbox formControlName="variableInstallments">Variable installments allowed</mat-checkbox>
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-money-check"></i>
<h3>Repayment Mode</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Repayment Mode</mat-label>
<mat-select formControlName="repaymentMode">
@for (opt of repaymentModeOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Linked Account Type</mat-label>
<mat-select formControlName="linkedAccountType">
@for (opt of linkedAccountOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>PDC Collection</mat-label>
<mat-select formControlName="pdcCollection">
@for (opt of pdcOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="adv-subsection">
<mat-checkbox formControlName="mandateRequired">
Mandate registration is required before the first disbursement
</mat-checkbox>
</div>
<div style="padding: 0 24px 20px">
<div class="adv-info">
Cheques are used here to repay the EMI as post-dated cheques, not to withdraw from the loan. A cheque book is
therefore not part of a Loan A/C.
</div>
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-list-ol"></i>
<h3>Appropriation Order</h3>
</div>
<div class="adv-grid">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Appropriation Order</mat-label>
<mat-select formControlName="appropriationOrder">
@for (opt of appropriationOrderOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Advance EMI Payment</mat-label>
<mat-select formControlName="advanceEmi">
@for (opt of advanceEmiOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
</form>
@@ -0,0 +1,129 @@
/**
* 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 { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms';
import { MatCheckbox } from '@angular/material/checkbox';
import { MatRadioButton, MatRadioGroup } from '@angular/material/radio';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Repayment & EMI step for a Loans A/C (EMI) product (step 5) — the step an Advance does not have.
* Repayment type and frequency feed Fineract's core product fields; the repayment mode and the
* rest of the schedule options are UI-only for now.
*/
@Component({
selector: 'mifosx-loan-product-loan-repayment-step',
templateUrl: './loan-product-loan-repayment-step.component.html',
styleUrls: ['../advance-steps.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatCheckbox,
MatRadioGroup,
MatRadioButton
]
})
export class LoanProductLoanRepaymentStepComponent {
private formBuilder = inject(UntypedFormBuilder);
loanProductLoanRepaymentForm: UntypedFormGroup;
readonly repaymentTypeOptions = [
{ id: 'emi', label: 'EMI', hint: 'Reducing balance, equal instalments' },
{ id: 'equal_principal', label: 'Equal principal', hint: 'Falling instalments' },
{ id: 'flat', label: 'Flat rate', hint: 'Interest on original amount' },
{ id: 'bullet', label: 'Bullet', hint: 'Pay everything at maturity' },
{ id: 'step', label: 'Step-up / Balloon', hint: 'Custom schedule' }
];
/** `fineractId` is Fineract's repaymentFrequencyType (0 days, 1 weeks, 2 months). */
readonly frequencyOptions = [
{ id: 'months', label: 'Months', fineractId: 2 },
{ id: 'weeks', label: 'Weeks', fineractId: 1 },
{ id: 'days', label: 'Days', fineractId: 0 }
];
readonly emiDueDayOptions = [
{ id: 'disbursement_day', label: 'Same day as disbursement' },
{ id: 'fixed_day', label: 'Fixed day of month (5th)' }
];
readonly holidayRuleOptions = [
{ id: 'next', label: 'Next working day' },
{ id: 'previous', label: 'Previous working day' },
{ id: 'same', label: 'Same day' }
];
readonly moratoriumInterestOptions = [
{ id: 'pre_emi', label: 'Pay monthly (Pre-EMI)' },
{ id: 'capitalise', label: 'Capitalise into loan' }
];
readonly repaymentModeOptions = [
{ id: 'nach', label: 'Auto-debit (NACH / ECS)' },
{ id: 'pdc', label: 'Post-dated cheques (PDC)' },
{ id: 'cash', label: 'Cash / transfer at branch' }
];
readonly linkedAccountOptions = [
{ id: 'savings_current', label: 'Savings / Current' },
{ id: 'savings', label: 'Savings only' }
];
readonly pdcOptions = [
{ id: 'mandatory', label: 'Mandatory — 3 cheques' },
{ id: 'optional', label: 'Optional' },
{ id: 'none', label: 'Not collected' }
];
readonly appropriationOrderOptions = [
{ id: 'charges_penal_interest_principal', label: 'Charges → Penal → Interest → Principal' },
{ id: 'principal_interest_penal_charges', label: 'Principal → Interest → Penal → Charges' }
];
readonly advanceEmiOptions = [
{ id: 'adjust_next', label: 'Allowed — adjust next EMI' },
{ id: 'not_allowed', label: 'Not allowed' }
];
constructor() {
this.loanProductLoanRepaymentForm = this.formBuilder.group({
repaymentType: ['emi'],
repaidEvery: [
1,
[
Validators.required,
Validators.min(1)
]
],
frequency: ['months'],
minDaysToFirstRepayment: [
'',
Validators.min(0)
],
emiDueDay: ['fixed_day'],
holidayRule: ['next'],
moratoriumMonths: [
0,
Validators.min(0)
],
moratoriumInterest: ['pre_emi'],
variableInstallments: [false],
repaymentMode: ['nach'],
linkedAccountType: ['savings_current'],
pdcCollection: ['mandatory'],
mandateRequired: [true],
appropriationOrder: ['charges_penal_interest_principal'],
advanceEmi: ['adjust_next']
});
}
get loanProductLoanRepayment() {
return this.loanProductLoanRepaymentForm.getRawValue();
}
}
@@ -0,0 +1,130 @@
<!--
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/.
-->
<form [formGroup]="loanProductLoanSecurityForm">
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-shield-alt"></i>
<h3>Security</h3>
</div>
<div class="adv-grid">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Security Type</mat-label>
<input matInput formControlName="securityType" readonly />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Security Mandatory</mat-label>
<mat-select formControlName="securityMandatory">
@for (opt of securityMandatoryOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Margin (%)</mat-label>
<input matInput type="number" formControlName="margin" min="0" max="100" step="0.01" required />
<mat-error>Margin must be between <strong>0 and 100</strong></mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Maximum LTV (%)</mat-label>
<input matInput [value]="ltv" readonly />
<mat-hint>= 100 − Margin</mat-hint>
</mat-form-field>
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-table"></i>
<h3>Collateral Rules by Security Type</h3>
<span class="adv-section__hint">Which security this product may accept</span>
</div>
<table class="adv-table">
<thead>
<tr>
<th>Security</th>
<th>Allowed</th>
<th>Max LTV %</th>
<th>Valuation</th>
<th>Insurance</th>
<th>Charge / lien</th>
</tr>
</thead>
<tbody>
@for (rule of collateralRules; track rule.security) {
<tr>
<td>{{ rule.security }}</td>
<td [class.adv-table__yes]="rule.allowed" [class.adv-table__no]="!rule.allowed">
{{ rule.allowed ? 'Yes' : 'No' }}
</td>
<td [class.adv-table__no]="!rule.allowed">{{ rule.ltv }}</td>
<td [class.adv-table__no]="!rule.allowed">{{ rule.valuation }}</td>
<td [class.adv-table__no]="!rule.allowed">{{ rule.insurance }}</td>
<td [class.adv-table__no]="!rule.allowed">{{ rule.charge }}</td>
</tr>
}
</tbody>
</table>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-user-plus"></i>
<h3>Guarantor &amp; Co-applicant</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Guarantors Required (min)</mat-label>
<input matInput type="number" formControlName="guarantorsRequired" min="0" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Co-applicant</mat-label>
<mat-select formControlName="coApplicant">
@for (opt of coApplicantOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Third-party Security</mat-label>
<mat-select formControlName="thirdPartySecurity">
@for (opt of thirdPartyOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-check-circle"></i>
<h3>Conditions</h3>
</div>
<div class="adv-subsection">
<div class="adv-checks">
<mat-checkbox formControlName="legalOpinionRequired">
Legal opinion / title search mandatory before sanction
</mat-checkbox>
<mat-checkbox formControlName="propertyInsuranceRequired">
Property insurance mandatory for the whole tenure
</mat-checkbox>
<mat-checkbox formControlName="registerCharge">Register charge (CERSAI) before disbursement</mat-checkbox>
<mat-checkbox formControlName="autoReleaseOnClosure">Release security automatically on closure</mat-checkbox>
<mat-checkbox formControlName="allowMultipleSecurities">Allow multiple securities for one loan</mat-checkbox>
<mat-checkbox formControlName="allowThirdPartyDeposit">Allow third-party deposit as security</mat-checkbox>
</div>
</div>
<div style="padding: 0 24px 20px">
<div class="adv-info">
Unlike an Advance, the security does not decide a running limit. It only decides eligibility at sanction.
Disbursement happens once, up to the sanctioned amount.
</div>
</div>
</div>
</form>
@@ -0,0 +1,127 @@
/**
* 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 { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms';
import { MatCheckbox } from '@angular/material/checkbox';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Security & Collateral step for a Loans A/C (EMI) product (step 3).
*
* Unlike an Advance, the security only decides eligibility at sanction — it does not set a running
* limit. UI-only for now: not sent in the create payload.
*/
@Component({
selector: 'mifosx-loan-product-loan-security-step',
templateUrl: './loan-product-loan-security-step.component.html',
styleUrls: ['../advance-steps.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatCheckbox
]
})
export class LoanProductLoanSecurityStepComponent {
private formBuilder = inject(UntypedFormBuilder);
loanProductLoanSecurityForm: UntypedFormGroup;
readonly securityMandatoryOptions = [
{ id: 'yes', label: 'Yes — no sanction without security' },
{ id: 'no', label: 'No — unsecured' }
];
readonly coApplicantOptions = [
{ id: 'joint_property', label: 'Mandatory for joint property' },
{ id: 'always', label: 'Always mandatory' },
{ id: 'optional', label: 'Optional' }
];
readonly thirdPartyOptions = [
{ id: 'consent', label: 'Allowed with owner consent' },
{ id: 'no', label: 'Not allowed' }
];
/** Reference table: which security a loan on this product may accept. */
readonly collateralRules = [
{
security: 'Residential property',
allowed: true,
ltv: '80',
valuation: 'Empanelled valuer',
insurance: 'Mandatory',
charge: 'Mortgage + CERSAI'
},
{
security: 'Commercial property',
allowed: true,
ltv: '70',
valuation: 'Empanelled valuer',
insurance: 'Mandatory',
charge: 'Mortgage + CERSAI'
},
{
security: 'Fixed / Recurring Deposit',
allowed: true,
ltv: '90',
valuation: 'Not needed',
insurance: 'Not needed',
charge: 'Lien marked'
},
{
security: 'LIC policy',
allowed: true,
ltv: '90',
valuation: 'Surrender value',
insurance: 'Not needed',
charge: 'Assignment'
},
{ security: 'Vehicle', allowed: false, ltv: '—', valuation: '—', insurance: '—', charge: '—' },
{ security: 'Gold ornaments', allowed: false, ltv: '—', valuation: '—', insurance: '—', charge: '—' }
];
constructor() {
this.loanProductLoanSecurityForm = this.formBuilder.group({
securityType: ['Immovable property (mortgage)'],
securityMandatory: ['yes'],
margin: [
20,
[
Validators.required,
Validators.min(0),
Validators.max(100)
]
],
guarantorsRequired: [
1,
Validators.min(0)
],
coApplicant: ['joint_property'],
thirdPartySecurity: ['consent'],
legalOpinionRequired: [true],
propertyInsuranceRequired: [true],
registerCharge: [true],
autoReleaseOnClosure: [true],
allowMultipleSecurities: [false],
allowThirdPartyDeposit: [false]
});
}
/** Maximum LTV is derived from the margin (100 − margin) and shown read-only. */
get ltv(): number {
const margin = Number(this.loanProductLoanSecurityForm.get('margin').value);
return Number.isFinite(margin) ? Math.max(0, 100 - margin) : 100;
}
get loanProductLoanSecurity() {
return {
...this.loanProductLoanSecurityForm.getRawValue(),
ltv: this.ltv
};
}
}
@@ -0,0 +1,106 @@
<!--
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/.
-->
<form [formGroup]="loanProductPenalNpaForm">
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-percent"></i>
<h3>Penal Charges</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Penal Charges Applicable</mat-label>
<mat-select formControlName="penalApplicable">
@for (opt of yesNoOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Penal Rate (% p.a.)</mat-label>
<input matInput type="number" formControlName="penalRate" min="0" max="100" step="0.01" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Applied On</mat-label>
<mat-select formControlName="penalAppliedOn">
@for (opt of appliedOnOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Penal Grace Days</mat-label>
<input matInput type="number" formControlName="penalGraceDays" min="0" />
</mat-form-field>
<div class="adv-field adv-field--full" style="grid-column: span 2">
<div class="adv-info">
RBI: penal charges are kept separate from interest, never capitalised, and shown in the Key Fact Statement.
</div>
</div>
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-triangle-exclamation"></i>
<h3>Asset Classification (SMA / NPA)</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Delinquency Bucket (SMA)</mat-label>
<mat-select formControlName="delinquencyBucketId">
<mat-option [value]="''">— None —</mat-option>
@for (bucket of delinquencyBucketOptions; track bucket.id) {
<mat-option [value]="bucket.id">{{ bucket.name }}</mat-option>
}
</mat-select>
<mat-hint>SMA 0–30 / 31–60 / 61–90</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>NPA After (days overdue)</mat-label>
<input matInput type="number" formControlName="npaDays" min="0" required />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>NPA Trigger</mat-label>
<mat-select formControlName="npaTrigger">
@for (opt of npaTriggerOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Interest after NPA</mat-label>
<mat-select formControlName="interestAfterNpa">
@for (opt of interestAfterNpaOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Grace on Arrears Ageing (days)</mat-label>
<input matInput type="number" formControlName="graceOnArrearsAgeing" min="0" />
<mat-hint>Days before an amount counts as overdue</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>In-Arrears Tolerance (₹)</mat-label>
<input matInput type="number" formControlName="inArrearsTolerance" min="0" />
<mat-hint>Small balance ignored for arrears</mat-hint>
</mat-form-field>
</div>
<div class="adv-subsection">
<div class="adv-checks">
<mat-checkbox formControlName="upgradeOnlyAllArrears">
Upgrade from NPA only when all arrears are cleared
</mat-checkbox>
<mat-checkbox formControlName="keepStandardIfDepositCovers">
Keep standard while the pledged deposit fully covers the dues
</mat-checkbox>
</div>
</div>
</div>
</form>
@@ -0,0 +1,90 @@
/**
* 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, Input, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { MatCheckbox } from '@angular/material/checkbox';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Penal charges & asset-classification (SMA / NPA) step for an Advances A/C product (Figma step 6).
* UI-only for now — not sent in the create payload.
*/
@Component({
selector: 'mifosx-loan-product-penal-npa-step',
templateUrl: './loan-product-penal-npa-step.component.html',
styleUrls: ['../advance-steps.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatCheckbox
]
})
export class LoanProductPenalNpaStepComponent {
private formBuilder = inject(UntypedFormBuilder);
@Input() loanProductsTemplate: any;
loanProductPenalNpaForm: UntypedFormGroup;
readonly yesNoOptions = [
'YES',
'NO'
];
readonly appliedOnOptions = [
{ id: 'overdue', label: 'Overdue amount' },
{ id: 'outstanding', label: 'Full outstanding' }
];
readonly npaTriggerOptions = [
{ id: 'maturity', label: 'Dues overdue after maturity' },
{ id: 'out_of_order', label: 'Out of order 90 days (CC/OD)' }
];
readonly interestAfterNpaOptions = [
{ id: 'undebited', label: 'Move to Undebited Interest GL' },
{ id: 'suspense', label: 'Move to Interest Suspense GL' }
];
get delinquencyBucketOptions(): any[] {
return this.loanProductsTemplate?.delinquencyBucketOptions ?? [];
}
constructor() {
this.loanProductPenalNpaForm = this.formBuilder.group({
penalApplicable: ['YES'],
penalRate: [
2.0,
[
Validators.min(0),
Validators.max(100)
]
],
penalAppliedOn: ['overdue'],
penalGraceDays: [0],
delinquencyBucketId: [''],
npaDays: [
90,
[
Validators.required,
Validators.min(0)
]
],
npaTrigger: ['maturity'],
interestAfterNpa: ['undebited'],
graceOnArrearsAgeing: [0],
inArrearsTolerance: [0],
upgradeOnlyAllArrears: [true],
keepStandardIfDepositCovers: [true]
});
}
get loanProductPenalNpa() {
return this.loanProductPenalNpaForm.getRawValue();
}
}
@@ -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/.
-->
<form [formGroup]="loanProductRenewalClosureForm">
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-arrows-rotate"></i>
<h3>Limit Renewal</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Renewal Allowed</mat-label>
<mat-select formControlName="renewalAllowed">
@for (opt of yesNoOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Renewal Period (months)</mat-label>
<input matInput type="number" formControlName="renewalPeriodMonths" min="1" required />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Renewal Grace Days</mat-label>
<input matInput type="number" formControlName="renewalGraceDays" min="0" />
<mat-hint>Days allowed after the renewal date</mat-hint>
</mat-form-field>
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-door-open"></i>
<h3>Pre-closure</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Pre-closure Allowed</mat-label>
<mat-select formControlName="preclosureAllowed">
@for (opt of preclosureOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Lock-in Period (months)</mat-label>
<input matInput type="number" formControlName="lockInMonths" min="0" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Interest on Pre-closure</mat-label>
<mat-select formControlName="interestOnPreclosure">
@for (opt of preclosureInterestOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
<div class="adv-note">
<i class="fas fa-circle-info"></i>
<span>Renewal and pre-closure <strong>fees</strong> are set once, on the Charges step.</span>
</div>
</form>
@@ -0,0 +1,64 @@
/**
* 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 { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Limit renewal & pre-closure rules for an Advances A/C product (Figma step 7). Fees live on the
* Charges step. UI-only for now — not sent in the create payload.
*/
@Component({
selector: 'mifosx-loan-product-renewal-closure-step',
templateUrl: './loan-product-renewal-closure-step.component.html',
styleUrls: ['../advance-steps.scss'],
imports: [...STANDALONE_SHARED_IMPORTS]
})
export class LoanProductRenewalClosureStepComponent {
private formBuilder = inject(UntypedFormBuilder);
loanProductRenewalClosureForm: UntypedFormGroup;
readonly yesNoOptions = [
'YES',
'NO'
];
readonly preclosureOptions = [
{ id: 'anytime', label: 'Anytime' },
{ id: 'after_lockin', label: 'After lock-in period' },
{ id: 'not_allowed', label: 'Not allowed' }
];
readonly preclosureInterestOptions = [
{ id: 'till_closing', label: 'Till closing date (broken period)' },
{ id: 'full_period', label: 'Full current period' }
];
constructor() {
this.loanProductRenewalClosureForm = this.formBuilder.group({
renewalAllowed: ['YES'],
renewalPeriodMonths: [
12,
[
Validators.required,
Validators.min(1)
]
],
renewalGraceDays: [30],
preclosureAllowed: ['anytime'],
lockInMonths: [0],
interestOnPreclosure: ['till_closing']
});
}
get loanProductRenewalClosure() {
return this.loanProductRenewalClosureForm.getRawValue();
}
}
@@ -0,0 +1,160 @@
<!--
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/.
-->
<form [formGroup]="loanProductSecurityLienForm">
@if (!isAdvance) {
<div class="lsl-note">
<i class="fas fa-circle-info"></i>
Security &amp; Lien applies to an <strong>Advances A/C</strong> product. Choose it on the Details step.
</div>
}
@if (isAdvance) {
<div class="lsl-section">
<div class="lsl-section__header">
<i class="fas fa-shield-alt"></i>
<h3>Security &amp; Margin</h3>
</div>
<div class="lsl-grid">
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Security Type</mat-label>
<input matInput formControlName="securityType" matTooltip="Set from the Product Sub Type" />
<mat-hint>From Sub Type — not chosen again</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Eligible Amount Basis</mat-label>
<mat-select formControlName="eligibleAmountBasis" required>
@for (opt of eligibleAmountBasisOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
<mat-error>Eligible Amount Basis is <strong>required</strong></mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Margin (%)</mat-label>
<input matInput type="number" formControlName="margin" min="0" max="100" step="0.01" required />
<mat-hint>0 – 100. Drawing Power = security value × LTV</mat-hint>
@if (loanProductSecurityLienForm.get('margin').hasError('required')) {
<mat-error>Margin is <strong>required</strong></mat-error>
} @else {
<mat-error>Margin must be between 0 and 100</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>LTV (%)</mat-label>
<input matInput [value]="ltv" disabled />
<mat-hint>= 100 − Margin</mat-hint>
</mat-form-field>
</div>
</div>
@if (isLienSubType) {
<div class="lsl-section">
<div class="lsl-section__header">
<i class="fas fa-lock"></i>
<h3>Lien Settings</h3>
<span class="lsl-section__hint">FD · RD · DDS · LIC</span>
</div>
<div class="lsl-subsection">
<p class="lsl-group">Lien marking</p>
<div class="lsl-grid">
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Lien Required</mat-label>
<input matInput value="YES (mandatory)" disabled />
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Lien Marking Stage</mat-label>
<mat-select formControlName="lienMarkingStage" required>
@for (opt of lienMarkingStageOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field lsl-field--full">
<mat-label>Eligible Deposit Products</mat-label>
<mat-select formControlName="eligibleDepositProducts" multiple required>
@for (product of depositProductOptions; track product) {
<mat-option [value]="product">{{ product }}</mat-option>
}
</mat-select>
<mat-hint>Only accounts of these deposit products can be pledged for this advance</mat-hint>
<mat-error>Pick at least one deposit product</mat-error>
</mat-form-field>
</div>
</div>
<div class="lsl-subsection">
<p class="lsl-group">Rules</p>
<div class="lsl-checks">
<mat-checkbox formControlName="tenureNotExceedMaturity">
Tenure cannot exceed deposit maturity date
</mat-checkbox>
<mat-checkbox formControlName="autoReleaseOnClosure">
Auto-release lien when the advance is closed
</mat-checkbox>
<mat-checkbox formControlName="allowMultipleDeposits">
Allow multiple deposits for one advance
</mat-checkbox>
<mat-checkbox formControlName="adjustFromMaturity">
Adjust advance from deposit proceeds at maturity
</mat-checkbox>
<mat-checkbox formControlName="allowThirdPartyDeposit">
Allow third-party deposit (consent letter required)
</mat-checkbox>
<mat-checkbox formControlName="allowRenewalUnderLien">
Allow deposit renewal under lien (lien carries forward)
</mat-checkbox>
</div>
</div>
<div class="lsl-subsection">
<p class="lsl-group">Lien certificate</p>
<div class="lsl-grid">
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Lien Certificate Required</mat-label>
<mat-select formControlName="lienCertificateRequired">
@for (opt of yesNoOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
@if (loanProductSecurityLienForm.get('lienCertificateRequired').value === 'YES') {
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Certificate No. Prefix</mat-label>
<input matInput formControlName="lienCertificatePrefix" required />
<mat-error>Prefix is <strong>required</strong></mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Print on Disbursement</mat-label>
<mat-select formControlName="printOnDisbursement">
@for (opt of yesNoOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
}
</div>
</div>
</div>
<div class="lsl-note">
<i class="fas fa-circle-info"></i>
Interest basis and spread are set on the <strong>Interest</strong> step; the lien-certificate fee on the
<strong>Charges</strong> step.
</div>
}
}
</form>
@@ -0,0 +1,117 @@
/**
* 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/.
*/
$accent: #0ea5e9;
$border: #e2e8f0;
$gray-50: #f8f9fa;
$gray-200: #e8eaed;
$gray-900: #202124;
.lsl-section {
border: 1px solid $border;
border-radius: 10px;
overflow: hidden;
background: #fff;
& + & {
margin-top: 16px;
}
&__header {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 24px;
background: $gray-50;
border-bottom: 1px solid $gray-200;
i {
font-size: 14px;
color: $accent;
}
h3 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: $gray-900;
}
}
&__hint {
margin-left: auto;
font-size: 12px;
color: #5f6368;
}
}
.lsl-subsection {
padding: 16px 24px;
& + & {
border-top: 1px solid $border;
}
}
.lsl-group {
margin: 0 0 12px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #5f6368;
}
.lsl-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 4px 16px;
padding: 16px 24px;
@media (width <= 600px) {
grid-template-columns: 1fr;
}
}
.lsl-subsection .lsl-grid {
padding: 0;
}
.lsl-field {
width: 100%;
&--full {
grid-column: 1 / -1;
}
}
.lsl-checks {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px 32px;
@media (width <= 600px) {
grid-template-columns: 1fr;
}
}
.lsl-note {
display: flex;
align-items: center;
gap: 8px;
margin-top: 16px;
padding: 12px 24px;
border: 1px dashed $border;
border-radius: 10px;
font-size: 13px;
color: #5f6368;
i {
color: $accent;
}
}
@@ -0,0 +1,196 @@
/**
* 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, Input, inject } from '@angular/core';
import { AbstractControl, UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { MatTooltip } from '@angular/material/tooltip';
import { MatCheckbox } from '@angular/material/checkbox';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Security & Lien step, shown only for an "Advances A/C" product.
*
* For a deposit-backed advance (Loan against FD / RD / DDS / LIC) the bank marks a lien on the
* customer's own deposit. This step captures the product-level rules for that: the margin (from which
* the LTV and drawing power follow), which deposit products may be pledged, and the lien certificate.
*
* UI-only for now: its values are NOT sent in the create payload, because the backend has no column to
* store them yet. Once the satellite table lands, wire `loanProductSecurityLien` into the payload.
*/
@Component({
selector: 'mifosx-loan-product-security-lien-step',
templateUrl: './loan-product-security-lien-step.component.html',
styleUrls: ['./loan-product-security-lien-step.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatTooltip,
MatCheckbox
]
})
export class LoanProductSecurityLienStepComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
@Input() loanProductsTemplate: any;
/** The Details step's Product Type controls, so this step reacts to the chosen group / sub type. */
@Input() productTypeGroup: AbstractControl;
@Input() productTypeSubGroup: AbstractControl;
loanProductSecurityLienForm: UntypedFormGroup;
/** Sub type -> the security it is backed by. Only deposit / policy sub types get a lien section. */
private readonly securityTypeBySubGroup: Record<string, string> = {
fd: 'Fixed Deposit',
rd: 'Recurring Deposit',
dds: 'Daily Deposit Scheme',
lic: 'LIC Policy',
gold: 'Gold Ornaments',
warehouse: 'Warehouse Receipt',
cc: 'Stock / Book Debts',
overdraft: 'Deposit / Property'
};
/** Sub types whose security is lien-marked on a deposit (rather than pledged / hypothecated). */
private readonly lienSubGroups = [
'fd',
'rd',
'dds',
'lic'
];
/** Default margin % per sub type (bank policy in real use); LTV is derived from it. */
private readonly defaultMarginBySubGroup: Record<string, number> = {
fd: 10,
rd: 10,
dds: 10,
lic: 40
};
readonly eligibleAmountBasisOptions = [
{ id: 'balance_accrued', label: 'Deposit balance + accrued interest' },
{ id: 'balance', label: 'Deposit balance only' },
{ id: 'surrender', label: 'Surrender value (LIC)' }
];
readonly lienMarkingStageOptions = [
{ id: 'disbursement', label: 'At Disbursement' },
{ id: 'sanction', label: 'At Sanction' }
];
readonly depositProductOptions = [
'Fixed Deposit – Regular',
'Fixed Deposit – Senior Citizen',
'Recurring Deposit – Regular',
'Daily Deposit Scheme'
];
readonly yesNoOptions = [
'YES',
'NO'
];
constructor() {
this.createForm();
}
ngOnInit() {
this.applySubGroup(this.productTypeSubGroup?.value);
// Keep Security Type and the default margin in step with the sub type chosen on the Details step.
this.productTypeSubGroup?.valueChanges.subscribe((subGroup: string) => this.applySubGroup(subGroup));
}
private applySubGroup(subGroup: string): void {
this.loanProductSecurityLienForm
.get('securityType')
.setValue(this.securityTypeBySubGroup[subGroup] ?? '', { emitEvent: false });
const margin = this.defaultMarginBySubGroup[subGroup];
if (margin != null && !this.loanProductSecurityLienForm.get('margin').dirty) {
this.loanProductSecurityLienForm.get('margin').setValue(margin, { emitEvent: false });
}
}
/** This whole step is only relevant to an Advances A/C product. */
get isAdvance(): boolean {
return this.productTypeGroup?.value === 'advances';
}
/** The lien block shows only for deposit / policy sub types; gold, warehouse, CC etc. are pledged. */
get isLienSubType(): boolean {
return this.lienSubGroups.includes(this.productTypeSubGroup?.value);
}
/** LTV = 100 − margin, shown read-only next to the margin. */
get ltv(): number | null {
const margin = this.loanProductSecurityLienForm.get('margin').value;
if (margin === null || margin === '' || isNaN(margin)) {
return null;
}
return 100 - Number(margin);
}
createForm() {
this.loanProductSecurityLienForm = this.formBuilder.group({
securityType: [{ value: '', disabled: true }],
eligibleAmountBasis: [
'balance_accrued',
Validators.required
],
margin: [
10,
[
Validators.required,
Validators.min(0),
Validators.max(100)
]
],
lienMarkingStage: [
'disbursement',
Validators.required
],
eligibleDepositProducts: [
['Fixed Deposit – Regular'],
Validators.required
],
tenureNotExceedMaturity: [true],
autoReleaseOnClosure: [true],
allowMultipleDeposits: [false],
adjustFromMaturity: [true],
allowThirdPartyDeposit: [false],
allowRenewalUnderLien: [true],
lienCertificateRequired: ['YES'],
lienCertificatePrefix: [
'LC/FD/',
Validators.required
],
printOnDisbursement: ['YES']
});
}
/** Flat values for the payload. Not wired into submit yet — see the class comment. */
get loanProductSecurityLien() {
const value = this.loanProductSecurityLienForm.getRawValue();
return {
securityType: value.securityType,
eligibleAmountBasis: value.eligibleAmountBasis,
margin: value.margin,
ltv: this.ltv,
lienMarkingStage: value.lienMarkingStage,
eligibleDepositProducts: value.eligibleDepositProducts,
lienRules: {
tenureNotExceedMaturity: value.tenureNotExceedMaturity,
autoReleaseOnClosure: value.autoReleaseOnClosure,
allowMultipleDeposits: value.allowMultipleDeposits,
adjustFromMaturity: value.adjustFromMaturity,
allowThirdPartyDeposit: value.allowThirdPartyDeposit,
allowRenewalUnderLien: value.allowRenewalUnderLien
},
lienCertificateRequired: value.lienCertificateRequired,
lienCertificatePrefix: value.lienCertificatePrefix,
printOnDisbursement: value.printOnDisbursement
};
}
}
@@ -71,6 +71,16 @@
}
}
.lps-panel {
& + & {
margin-top: 12px;
}
.lps-section__body {
padding-top: 8px;
}
}
.lps-full-width {
grid-column: 1 / -1;
}
@@ -22,6 +22,12 @@ import { MatTooltip } from '@angular/material/tooltip';
import { MatCheckbox } from '@angular/material/checkbox';
import { MatIconButton } from '@angular/material/button';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import {
MatAccordion,
MatExpansionPanel,
MatExpansionPanelHeader,
MatExpansionPanelTitle
} from '@angular/material/expansion';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@Component({
@@ -35,7 +41,11 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
MatCheckbox,
MatIconButton,
FaIconComponent,
ReactiveFormsModule
ReactiveFormsModule,
MatAccordion,
MatExpansionPanel,
MatExpansionPanelHeader,
MatExpansionPanelTitle
]
})
export class LoanProductSettingsStepComponent implements OnInit {
@@ -44,9 +54,35 @@ export class LoanProductSettingsStepComponent implements OnInit {
@Input() toEdit: boolean;
@Input() loanProductsTemplate: any;
@Input() isLinkedToFloatingInterestRates: UntypedFormControl;
@Input() productTypeGroup: UntypedFormControl;
@Output() advancePaymentStrategy = new EventEmitter<string>();
/**
* Provided from the Terms step's control. It can arrive after this component's ngOnInit (the Terms tab
* resolves later now that the tabs are conditional), so subscribe from the setter when it is present
* rather than assuming it in ngOnInit — otherwise a null control blanks the whole wizard.
*/
private _isLinkedToFloatingInterestRates: UntypedFormControl;
private isLinkedSubscribed = false;
@Input()
set isLinkedToFloatingInterestRates(control: UntypedFormControl) {
this._isLinkedToFloatingInterestRates = control;
if (control && !this.isLinkedSubscribed) {
this.isLinkedSubscribed = true;
control.valueChanges.subscribe((isLinked: any) => {
if (isLinked) {
this.loanProductSettingsForm.get('isInterestRecalculationEnabled').setValue(true);
this.loanProductSettingsForm.get('allowPartialPeriodInterestCalculation').setValue(true);
}
});
}
}
get isLinkedToFloatingInterestRates(): UntypedFormControl {
return this._isLinkedToFloatingInterestRates;
}
loanProductSettingsForm: UntypedFormGroup;
amortizationTypeData: any;
@@ -74,6 +110,15 @@ export class LoanProductSettingsStepComponent implements OnInit {
daysInYearCustomStrategyOptions: OptionData[] = [];
useDaysInYearCustomStrategy = false;
/** True when the product being created is an "Advances A/C" (no-EMI, limit-based) product. */
isAdvanceAccount = false;
/** UI-only for now; not yet wired into the submitted payload (see loan-products.ts buildPayload). */
readonly loanPeriodOptions = [
{ value: 12, label: '12 Months' },
{ value: 15, label: '15 Months' }
];
/** Values to Days for Repayments */
defaultConfigValues: GlobalConfiguration[] = [];
@@ -84,12 +129,13 @@ export class LoanProductSettingsStepComponent implements OnInit {
ngOnInit() {
this.defaultConfigValues = this.loanProductsTemplate['itemsByDefault'];
this.isLinkedToFloatingInterestRates.valueChanges.subscribe((isLinkedToFloatingInterestRates: any) => {
if (isLinkedToFloatingInterestRates) {
this.loanProductSettingsForm.get('isInterestRecalculationEnabled').setValue(true);
this.loanProductSettingsForm.get('allowPartialPeriodInterestCalculation').setValue(true);
}
});
if (this.productTypeGroup) {
this.applyAdvanceAccountRules(this.productTypeGroup.value === 'advances');
this.productTypeGroup.valueChanges.subscribe((productTypeGroup: string) => {
this.applyAdvanceAccountRules(productTypeGroup === 'advances');
});
}
this.amortizationTypeData = this.loanProductsTemplate.amortizationTypeOptions;
this.interestTypeData = this.loanProductsTemplate.interestTypeOptions;
@@ -334,6 +380,8 @@ export class LoanProductSettingsStepComponent implements OnInit {
Validators.required
],
allowAccrualPostingInArrears: [false],
// Advances A/C only; UI-only for now, stripped from the payload in loan-products.ts buildPayload().
loanPeriod: [''],
// Cheque Book Settings
chequeBookIssueEnabled: ['NO'],
@@ -453,6 +501,28 @@ export class LoanProductSettingsStepComponent implements OnInit {
}
}
/**
* Advances A/C has no EMI, so the Loan Schedule section is simplified: schedule type is locked to
* Cumulative (Progressive only makes sense with Advanced Payment Allocation, which is an EMI concept),
* installment-amount fixing doesn't apply, and Days in year/month are replaced by a placeholder
* Loan Period pick. loanScheduleType/canDefineInstallmentAmount stay enabled (not `.disable()`d) so they
* remain in `loanProductSettingsForm.value` and the submitted payload is unaffected by this UI change.
*/
private applyAdvanceAccountRules(isAdvance: boolean): void {
this.isAdvanceAccount = isAdvance;
const loanPeriodControl = this.loanProductSettingsForm.get('loanPeriod');
if (isAdvance) {
this.loanProductSettingsForm.patchValue({
loanScheduleType: LoanProducts.LOAN_SCHEDULE_TYPE_CUMULATIVE,
canDefineInstallmentAmount: false
});
loanPeriodControl.setValidators(Validators.required);
} else {
loanPeriodControl.clearValidators();
}
loanPeriodControl.updateValueAndValidity();
}
setConditionalControls() {
const allowAttributeOverrides = this.loanProductSettingsForm.get('allowAttributeOverrides');
@@ -264,6 +264,62 @@
}
</div>
<!-- Interest Information (moved here from the Details tab, next to the interest rates it qualifies) -->
<div class="lpt-section">
<div class="lpt-section-header">
<i class="fas fa-percentage lpt-section-icon"></i>
<h3>Interest Information</h3>
</div>
<div class="lpt-grid">
<mat-form-field appearance="outline">
<mat-label>Debit Int. Calculation</mat-label>
<mat-select formControlName="debitIntCalculation">
<mat-option [value]="true">YES</mat-option>
<mat-option [value]="false">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Separate Penal Int.</mat-label>
<mat-select formControlName="separatePenalInterest">
<mat-option [value]="true">YES</mat-option>
<mat-option [value]="false">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Credit Int. Calculation</mat-label>
<mat-select formControlName="creditIntCalculation">
<mat-option [value]="true">YES</mat-option>
<mat-option [value]="false">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Sub A/c Type Balance</mat-label>
<mat-select formControlName="subAccountTypeBalance">
<mat-option [value]="true">YES</mat-option>
<mat-option [value]="false">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Final Int. Rounding</mat-label>
<mat-select formControlName="finalIntRounding">
<mat-option [value]="true">YES</mat-option>
<mat-option [value]="false">NO</mat-option>
</mat-select>
</mat-form-field>
@if (loanProductTermsForm.value.finalIntRounding) {
<mat-form-field appearance="outline">
<mat-label>Rounding UpTo</mat-label>
<input matInput type="number" formControlName="roundingUpTo" min="0" />
</mat-form-field>
}
</div>
</div>
<!-- Interest Rate Slabs Section -->
<div class="lpt-section">
<div class="lpt-section-header">

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