+ @if (!loading) {
+
+ }
+
+ @if (error) {
+
{{ error }}
+ }
+ @if (loading) {
+
+
+
Loading loan book…
+
+ @for (st of loadingSteps; track $index) {
+ -
+ {{ $index < loadingStep ? '✓' : $index === loadingStep ? '…' : '' }} {{ st }}
+
+ }
+
+
+
+ }
+
+ @if (summary; as s) {
+
+
+ Active loans
+ {{ s.activeLoans }}
+ ₹{{ s.activeOutstanding | number: '1.0-0' }} outstanding
+
+
+ Running well (Standard + SMA)
+ {{ s.performingLoans }}
+ ₹{{ s.performingOutstanding | number: '1.0-0' }}
+
+
+ Gone bad (NPA)
+ {{ s.npaLoans }}
+ ₹{{ s.npaOutstanding | number: '1.0-0' }} · Gross NPA {{ s.grossNpaPct }}%
+
+
+ Written off / Closed
+ {{ s.writtenOffLoans }} / {{ s.closedLoans }}
+ ₹{{ s.writtenOffAmount | number: '1.0-0' }} written off
+
+
+
+
+
+
+
+
+ @if (view === 'BOOK') {
+
+
Loans by RBI asset classification (days past due)
+
+
+
+ | Class |
+ Days past due |
+ Loans |
+ Outstanding |
+ Overdue |
+ Share of book |
+
+
+
+ @for (st of performingStages; track st.stage) {
+
+ |
+
+ {{ st.label }}
+ |
+ {{ st.dpdRange }} |
+ {{ st.loans }} |
+ ₹{{ st.outstanding | number: '1.2-2' }} |
+ ₹{{ st.overdue | number: '1.2-2' }} |
+
+ {{ share(st) }}%
+ |
+
+ }
+ @for (st of npaStages; track st.stage) {
+
+ | {{ st.label }} (NPA) |
+ {{ st.dpdRange }} |
+ {{ st.loans }} |
+ ₹{{ st.outstanding | number: '1.2-2' }} |
+ ₹{{ st.overdue | number: '1.2-2' }} |
+
+ {{ share(st) }}%
+ |
+
+ }
+ @for (st of closedStages; track st.stage) {
+
+ | {{ st.label }} |
+ {{ st.dpdRange }} |
+ {{ st.loans }} |
+ – |
+ – |
+ |
+
+ }
+
+
+
+
+
+
Active loans by product
+
+
+
+ | Product |
+ Active |
+ Running well |
+ NPA |
+ Outstanding |
+ NPA outstanding |
+
+
+
+ @for (p of s.products; track p.productId) {
+
+ | {{ p.productName }} |
+ {{ p.activeLoans }} |
+ {{ p.performingLoans }} |
+ {{ p.npaLoans }} |
+ ₹{{ p.outstanding | number: '1.2-2' }} |
+ ₹{{ p.npaOutstanding | number: '1.2-2' }} |
+
+ } @empty {
+
+ | No active loans. |
+
+ }
+
+
+
+
+
+
Share linking to borrowing (5% unsecured · 2.5% secured)
+
+ Shares required on running loans: ₹{{ s.shareRequired | number: '1.0-0' }}
+ Shares held by these members: ₹{{ s.shareHeld | number: '1.0-0' }}
+ 0"
+ >Members short: {{ s.shareShortMembers }} (₹{{ s.shareShortfall | number: '1.0-0' }} to collect)
+
+
+ }
+
+ @if (view === 'BORROWERS') {
+
+
+ Borrowers — Good {{ s.goodBorrowers }} · Watch {{ s.watchBorrowers }} · Bad {{ s.badBorrowers }}
+
+
+ @for (c of categories; track c.value) {
+
+ }
+
+
+
+
+ Good: every running loan is regular (0 days past due) and nothing was ever written off. Watch: a loan is
+ overdue up to 90 days (SMA). Bad: a loan is NPA or written off. Eligibility is worked out for good borrowers
+ only; a fresh loan also needs a clean EMI record (at most 2 EMIs ever paid late).
+
+
+
+
+ | Borrower |
+ Category |
+ Running / Closed |
+ EMI record |
+ Outstanding |
+ Max days overdue |
+ Worst class |
+ More loan? |
+ |
+
+
+
+ @if (borrowersLoading) {
+
+ |
+ Fetching borrowers…
+ |
+
+ }
+ @for (b of borrowersLoading ? [] : pagedBorrowers; track b.clientId) {
+
+ |
+ {{ b.clientName }} ({{ b.clientAccountNo }})
+ |
+
+ {{
+ categoryLabel(b.category)
+ }}
+ |
+ {{ b.activeLoans }} / {{ b.closedLoans }} |
+
+ {{ b.emisPaid - b.emisLate }} / {{ b.emisPaid }} on time
+ @if (b.emisLate) {
+ ({{ b.emisLate }} late)
+ }
+ |
+ ₹{{ b.outstanding | number: '1.2-2' }} |
+ {{ b.maxDpd }} |
+ {{ b.worstStage || '–' }} |
+
+ @if (b.eligibleProducts > 0) {
+ Yes · {{ b.eligibleProducts }} products
+ } @else {
+ No
+ }
+ |
+
+
+ |
+
+ } @empty {
+
+ | No borrowers in this category. |
+
+ }
+
+
+ @if (borrowers.length > pageSize) {
+
+ }
+
+ }
+
+ @if (selected; as b) {
+
+
+
+
+ {{ b.clientName }} ({{ b.clientAccountNo }}) — {{ categoryLabel(b.category) }} borrower
+
+
+
+
+ Age: {{ b.age ?? 'not recorded' }}
+ Monthly income: {{ b.monthlyIncome ? '₹' + (b.monthlyIncome | number: '1.0-0') : 'not recorded' }}
+ Current EMIs: ₹{{ b.currentEmi | number: '1.0-0' }}
+ EMI record: {{ b.emisPaid - b.emisLate }} of {{ b.emisPaid }} paid on time
+ Deposits (savings / FD / RD): ₹{{ b.depositBalance | number: '1.0-0' }}
+ Shares held: ₹{{ b.shareHeld | number: '1.0-0' }} (needed ₹{{ b.shareRequired | number: '1.0-0' }} incl.
+ membership)
+
+
+ @for (a of b.accounts ?? []; track a.accountNo) {
+
+ {{ a.type }} account {{ a.accountNo }} — last 6 months (money in, expenses, balance)
+
+
+
+
+ | Month |
+ Credits (in) |
+ Debits (expenses + EMIs) |
+ Left over |
+ Average balance |
+ Cheque / ECS returns |
+
+
+
+ @for (m of a.months; track m.month) {
+
+ | {{ m.month }} |
+ ₹{{ m.credits | number: '1.0-0' }} |
+ ₹{{ m.debits | number: '1.0-0' }} |
+
+ ₹{{ m.credits - m.debits | number: '1.0-0' }}
+ |
+ ₹{{ m.avgBalance | number: '1.0-0' }} |
+ 0">{{ m.returns }} |
+
+ }
+
+ | Monthly average |
+ ₹{{ b.avgCredits | number: '1.0-0' }} |
+ ₹{{ b.avgDebits | number: '1.0-0' }} |
+
+ ₹{{ b.avgCredits - b.avgDebits | number: '1.0-0' }}
+ |
+
+ @if (b.avgCredits > 0) {
+ Spends {{ (b.avgDebits / b.avgCredits) * 100 | number: '1.0-0' }}% of income
+ }
+ |
+ {{ b.returns }} |
+
+
+
+ }
+
+
Loans
+
+
+
+ | Loan |
+ Product |
+ Status |
+ Class |
+ Days overdue |
+ EMIs on time |
+ Disbursed |
+ Outstanding |
+ Overdue |
+ Shares needed |
+
+
+
+ @for (l of b.loans ?? []; track l.loanId) {
+
+ | {{ l.accountNo }} |
+ {{ l.productName }} |
+ {{ l.status }} |
+ {{ l.stage }} |
+ {{ l.dpd }} |
+ {{ l.emisPaid - l.emisLate }} / {{ l.emisPaid }} |
+ ₹{{ l.disbursed | number: '1.2-2' }} |
+ ₹{{ l.outstanding | number: '1.2-2' }} |
+ ₹{{ l.overdue | number: '1.2-2' }} |
+
+ @if (l.shareRequired) {
+ ₹{{ l.shareRequired | number: '1.0-0' }} ({{ l.sharePct }}%)
+ } @else {
+ –
+ }
+ |
+
+ }
+
+
+
+ @if (b.category === 'GOOD') {
+
Eligible loan products
+
+
+
+ | Product |
+ Eligible |
+ Up to |
+ Shares to take |
+ In hand |
+ Why |
+
+
+
+ @for (e of b.eligibility ?? []; track e.productId) {
+
+ | {{ e.productName }} |
+
+ {{
+ e.eligible ? 'Yes' : 'No'
+ }}
+ |
+ {{ e.eligibleAmount ? '₹' + (e.eligibleAmount | number: '1.0-0') : '–' }} |
+
+ @if (e.shareToBuy) {
+ ₹{{ e.shareToBuy | number: '1.0-0' }} ({{ e.sharePct }}%)
+ } @else {
+ –
+ }
+ |
+ {{ e.netInHand ? '₹' + (e.netInHand | number: '1.0-0') : '–' }} |
+
+ @for (f of e.failed; track $index) {
+ ✗ {{ f }}
+ }
+ @for (p of e.passed; track $index) {
+ ✓ {{ p }}
+ }
+ @for (n of e.notes; track $index) {
+ • {{ n }}
+ }
+ |
+
+ } @empty {
+
+ | No active loan products. |
+
+ }
+
+
+ } @else {
+
+ Eligibility is not worked out for this borrower because a loan is overdue, NPA or written off.
+
+ }
+
+
+ }
+
+
+ @for (n of s.notes; track $index) {
+ - {{ n }}
+ }
+
+ }
+
diff --git a/src/app/loan-eligibility/loan-eligibility.component.scss b/src/app/loan-eligibility/loan-eligibility.component.scss
new file mode 100644
index 000000000..cd8d224c6
--- /dev/null
+++ b/src/app/loan-eligibility/loan-eligibility.component.scss
@@ -0,0 +1,312 @@
+/**
+ * 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/.
+ */
+
+.le-kpis {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+ gap: 12px;
+ margin-bottom: 16px;
+}
+
+.le-kpi {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ padding: 14px 16px;
+ border: 1px solid #e4e7ec;
+ border-radius: 10px;
+ background: #fff;
+}
+
+.le-kpi--good {
+ border-left: 4px solid #12b76a;
+}
+
+.le-kpi--bad {
+ border-left: 4px solid #d92d20;
+}
+
+.le-kpi-l {
+ font-size: 12px;
+ color: #667085;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+
+.le-kpi-v {
+ font-size: 26px;
+ font-weight: 700;
+}
+
+.le-kpi-s {
+ font-size: 13px;
+ color: #475467;
+}
+
+.le-dot {
+ display: inline-block;
+ width: 10px;
+ height: 10px;
+ margin-right: 6px;
+ border-radius: 50%;
+}
+
+.le-dot--good {
+ background: #12b76a;
+}
+
+.le-dot--warn {
+ background: #f79009;
+}
+
+.le-dot--bad {
+ background: #d92d20;
+}
+
+.le-bar {
+ display: inline-block;
+ width: 120px;
+ height: 8px;
+ margin-right: 6px;
+ overflow: hidden;
+ border-radius: 4px;
+ background: #f2f4f7;
+ vertical-align: middle;
+
+ span {
+ display: block;
+ height: 100%;
+ background: #12b76a;
+ }
+}
+
+.le-bar--bad span {
+ background: #d92d20;
+}
+
+.le-muted {
+ color: #667085;
+}
+
+.le-tabs {
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 8px;
+ padding: 12px 16px 0;
+}
+
+.le-search {
+ min-width: 220px;
+ padding: 8px 10px;
+ border: 1px solid #d0d5dd;
+ border-radius: 8px;
+}
+
+.le-help {
+ padding: 0 16px;
+}
+
+.le-chip {
+ padding: 2px 10px;
+ border-radius: 999px;
+ font-size: 12px;
+ font-weight: 600;
+}
+
+.le-chip--good {
+ background: #ecfdf3;
+ color: #067647;
+}
+
+.le-chip--watch {
+ background: #fffaeb;
+ color: #b54708;
+}
+
+.le-chip--bad {
+ background: #fef3f2;
+ color: #b42318;
+}
+
+.le-selected {
+ background: #f5f8ff;
+}
+
+.le-facts {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 24px;
+ padding: 8px 16px 12px;
+ color: #344054;
+}
+
+.le-why div {
+ font-size: 13px;
+ line-height: 1.5;
+}
+
+.le-fail {
+ color: #b42318;
+}
+
+.le-pass {
+ color: #067647;
+}
+
+.le-note {
+ color: #667085;
+}
+
+.le-error {
+ color: #b42318;
+}
+
+.le-notes {
+ margin: 8px 0 24px;
+ color: #667085;
+ font-size: 13px;
+}
+
+.le-late {
+ color: #b42318;
+}
+
+.le-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 1000;
+ display: flex;
+ align-items: flex-start;
+ justify-content: center;
+ padding: 48px 16px;
+ overflow-y: auto;
+ background: rgb(15 23 42 / 55%);
+}
+
+.le-modal {
+ width: min(1100px, 100%);
+ padding: 20px 24px;
+ border-radius: 12px;
+ background: #fff;
+ box-shadow: 0 20px 50px rgb(0 0 0 / 30%);
+}
+
+.le-modal-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.le-views {
+ margin-bottom: 16px;
+}
+
+.le-compact td {
+ padding-top: 6px;
+ padding-bottom: 6px;
+}
+
+.le-compact .pg-btn {
+ padding: 4px 14px;
+}
+
+.le-pager {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 6px;
+ margin-top: 12px;
+
+ span {
+ margin-right: auto;
+ color: #667085;
+ }
+}
+
+.le-loader {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 14px;
+ margin: 32px auto;
+ padding: 36px 24px;
+ max-width: 520px;
+ border-radius: 12px;
+ background: #fff;
+ box-shadow: 0 4px 16px rgb(16 24 40 / 8%);
+}
+
+.le-loader-title {
+ font-weight: 600;
+ font-size: 16px;
+ color: #1d2939;
+}
+
+.le-loader-steps {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ font-size: 14px;
+ line-height: 1.9;
+ color: #98a2b3;
+
+ .le-done {
+ color: #067647;
+ }
+
+ .le-now {
+ color: #1d4ed8;
+ font-weight: 600;
+ }
+}
+
+.le-progress {
+ width: 100%;
+ height: 6px;
+ border-radius: 3px;
+ overflow: hidden;
+ background: #eaecf0;
+
+ span {
+ display: block;
+ height: 100%;
+ background: #1d4ed8;
+ transition: width 0.6s ease;
+ }
+}
+
+.le-spinner {
+ width: 44px;
+ height: 44px;
+ border: 4px solid #dbe4ff;
+ border-top-color: #1d4ed8;
+ border-radius: 50%;
+ animation: le-spin 0.8s linear infinite;
+}
+
+.le-spinner--sm {
+ display: inline-block;
+ width: 14px;
+ height: 14px;
+ border-width: 2px;
+ margin-right: 6px;
+ vertical-align: middle;
+}
+
+@keyframes le-spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.le-total td {
+ font-weight: 600;
+ border-top: 2px solid #d0d5dd;
+}
diff --git a/src/app/loan-eligibility/loan-eligibility.component.ts b/src/app/loan-eligibility/loan-eligibility.component.ts
new file mode 100644
index 000000000..937fdc0bb
--- /dev/null
+++ b/src/app/loan-eligibility/loan-eligibility.component.ts
@@ -0,0 +1,187 @@
+/**
+ * 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, HostListener, OnDestroy, OnInit, inject } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { HttpErrorResponse } from '@angular/common/http';
+import {
+ Borrower,
+ BorrowerCategory,
+ LoanEligibilityService,
+ LoanEligibilitySummary,
+ LoanStageRow
+} from './loan-eligibility.service';
+
+/**
+ * Loan Eligibility – how the loan book is running (RBI asset classes) and which loan products the good borrowers
+ * qualify for, using their EMI record and the eligibility rules set on each loan product.
+ */
+@Component({
+ selector: 'mifosx-loan-eligibility',
+ templateUrl: './loan-eligibility.component.html',
+ styleUrls: [
+ '../cashier/cashier-shared.scss',
+ '../investment/investment-shared.scss',
+ './loan-eligibility.component.scss'
+ ],
+ standalone: true,
+ imports: [
+ CommonModule,
+ FormsModule
+ ]
+})
+export class LoanEligibilityComponent implements OnInit, OnDestroy {
+ private readonly service = inject(LoanEligibilityService);
+
+ summary: LoanEligibilitySummary | null = null;
+ borrowers: Borrower[] = [];
+ category: BorrowerCategory | '' = 'GOOD';
+ search = '';
+ selected: Borrower | null = null;
+ /** Which part of the page is shown, so the user need not scroll past the loan book to reach borrowers. */
+ view: 'BOOK' | 'BORROWERS' = 'BOOK';
+ page = 0;
+ readonly pageSize = 10;
+ loading = false;
+ borrowersLoading = false;
+ /** Borrower whose detail is being fetched (View button shows it). */
+ openingId: number | null = null;
+ error = '';
+ /** Steps shown while the book is being read. */
+ readonly loadingSteps = [
+ 'Fetching loan accounts…',
+ 'Reading repayment schedules and overdues…',
+ 'Classifying loans as per RBI IRAC norms…',
+ 'Running product eligibility rules…'
+ ];
+ loadingStep = 0;
+ private stepTimer?: ReturnType