feat: recurring deposit enhancements, dashboard, chart, and account view updates

This commit is contained in:
2026-10-12 10:23:10 +05:30
parent 6b0f218b70
commit 2d2eac8d03
69 changed files with 3619 additions and 595 deletions
@@ -0,0 +1,29 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { Route } from '../core/route/route.service';
import { RdDashboardComponent } from './rd-dashboard.component';
const routes: Routes = [
Route.withShell([
{
path: 'rd-dashboard',
component: RdDashboardComponent,
data: { title: 'RD Dashboard', breadcrumb: 'RD Dashboard' }
}
])
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class RdDashboardRoutingModule {}
@@ -0,0 +1,256 @@
<!--
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="rdd-container">
<!-- Header -->
<div class="rdd-header">
<div class="rdd-header__left">
<div class="rdd-header__icon">
<fa-icon icon="piggy-bank"></fa-icon>
</div>
<div>
<h2 class="rdd-header__title">RD Dashboard</h2>
<p class="rdd-header__sub">
All running Recurring Deposits as of <strong>{{ businessDate || '—' }}</strong> (business date). Click any
card to see those RDs in the list below.
</p>
</div>
</div>
<button class="rdd-btn rdd-btn--slim" (click)="load()" [disabled]="loading">
<fa-icon icon="sync-alt" [class.fa-spin]="loading"></fa-icon>
<span>{{ loading ? 'Loading...' : 'Reload' }}</span>
</button>
</div>
@if (errorMessage) {
<div class="rdd-alert">
<fa-icon icon="exclamation-triangle"></fa-icon>
<span>{{ errorMessage }}</span>
</div>
}
<!-- 1. Summary -->
<section class="rdd-section">
<h3 class="rdd-section__title">RD Book at a Glance</h3>
<div class="rdd-cards">
<button class="rdd-card rdd-card--success" [class.rdd-card--active]="stageFilter === 'REGULAR'" (click)="showStage('REGULAR')">
<span class="rdd-card__label"><fa-icon icon="chart-line"></fa-icon> Active RDs</span>
<span class="rdd-card__val">{{ summary.activeCount }}</span>
<span class="rdd-card__sub">Balance ₹{{ summary.activeBalance | number: '1.2-2' }}</span>
</button>
<button class="rdd-card rdd-card--info" [class.rdd-card--active]="stageFilter === 'MATURED'" (click)="showStage('MATURED')">
<span class="rdd-card__label"><fa-icon icon="calendar-check"></fa-icon> Matured – Close Pending</span>
<span class="rdd-card__val">{{ summary.maturedCount }}</span>
<span class="rdd-card__sub">To pay ₹{{ summary.maturedAmount | number: '1.2-2' }}</span>
</button>
<div class="rdd-card rdd-card--neutral rdd-card--static">
<span class="rdd-card__label"><fa-icon icon="check-circle"></fa-icon> Closed This Month</span>
<span class="rdd-card__val">{{ maturedClosedThisMonth + prematureClosedThisMonth }}</span>
<span class="rdd-card__sub">On maturity {{ maturedClosedThisMonth }} · Premature {{ prematureClosedThisMonth }}</span>
</div>
<div class="rdd-card rdd-card--neutral rdd-card--static">
<span class="rdd-card__label"><fa-icon icon="hand-holding-usd"></fa-icon> This Month's Installments</span>
<span class="rdd-card__val">{{ summary.collectionPercent }}%</span>
<div class="rdd-progress">
<div class="rdd-progress__bar" [style.width.%]="summary.collectionPercent"></div>
</div>
<span class="rdd-card__sub"
>Received ₹{{ summary.receivedThisMonth | number: '1.0-0' }} of ₹{{
summary.dueThisMonth | number: '1.0-0'
}}</span
>
</div>
<button class="rdd-card rdd-card--danger" [class.rdd-card--active]="extraFilter === 'overdue'" (click)="showExtra('overdue')">
<span class="rdd-card__label"><fa-icon icon="exclamation-circle"></fa-icon> Overdue RDs</span>
<span class="rdd-card__val">{{ summary.overdueCount }}</span>
<span class="rdd-card__sub">Pending ₹{{ summary.overdueAmount | number: '1.2-2' }}</span>
</button>
<button class="rdd-card rdd-card--warn" [class.rdd-card--active]="extraFilter === 'lien'" (click)="showExtra('lien')">
<span class="rdd-card__label"><fa-icon icon="lock"></fa-icon> Under Lien (Loan)</span>
<span class="rdd-card__val">{{ summary.lienCount }}</span>
<span class="rdd-card__sub">Lien ₹{{ summary.lienAmount | number: '1.2-2' }}</span>
</button>
</div>
</section>
<!-- 2. Installment health -->
<section class="rdd-section">
<h3 class="rdd-section__title">Installment Health</h3>
<div class="rdd-health">
<div class="rdd-tiles">
<button class="rdd-tile" [class.rdd-tile--active]="stageFilter === 'DUE_TODAY'" (click)="showStage('DUE_TODAY')">
<span class="rdd-tile__dot rdd-tile__dot--neutral"></span>
<span class="rdd-tile__label">Due today</span>
<span class="rdd-tile__val">{{ summary.dueTodayCount }}</span>
<span class="rdd-tile__sub">{{ summary.dueTodayPaidCount }} paid · {{ summary.dueTodayCount - summary.dueTodayPaidCount }} pending</span>
</button>
<button class="rdd-tile" [class.rdd-tile--active]="stageFilter === 'IN_GRACE'" (click)="showStage('IN_GRACE')">
<span class="rdd-tile__dot rdd-tile__dot--warn"></span>
<span class="rdd-tile__label">Late, in grace</span>
<span class="rdd-tile__val">{{ summary.inGraceCount }}</span>
<span class="rdd-tile__sub">₹{{ summary.inGraceAmount | number: '1.0-0' }} · no penalty yet</span>
</button>
<button class="rdd-tile" [class.rdd-tile--active]="stageFilter === 'PAST_GRACE'" (click)="showStage('PAST_GRACE')">
<span class="rdd-tile__dot rdd-tile__dot--danger"></span>
<span class="rdd-tile__label">Past grace</span>
<span class="rdd-tile__val">{{ summary.pastGraceCount }}</span>
<span class="rdd-tile__sub">₹{{ summary.pastGraceAmount | number: '1.0-0' }} · penalty applies</span>
</button>
<div class="rdd-tile rdd-tile--static">
<span class="rdd-tile__dot rdd-tile__dot--success"></span>
<span class="rdd-tile__label">Penalty income</span>
<span class="rdd-tile__val">₹{{ penaltyIncomeThisMonth | number: '1.0-0' }}</span>
<span class="rdd-tile__sub">This month · ₹{{ penaltyIncomeTotal | number: '1.0-0' }} in all</span>
</div>
</div>
<div class="rdd-ageing">
<div class="rdd-ageing__title">How late are overdue RDs?</div>
@for (b of ageingBuckets; track b.key) {
<button class="rdd-ageing__row" [class.rdd-ageing__row--active]="extraFilter === b.key" (click)="showExtra(b.key)">
<span class="rdd-ageing__label">{{ b.label }}</span>
<span class="rdd-ageing__track">
<span class="rdd-ageing__bar" [style.width.%]="ageingWidth(b.key)"></span>
</span>
<span class="rdd-ageing__count">{{ summary.ageing[b.key] || 0 }}</span>
</button>
}
</div>
</div>
</section>
<!-- 3. Action list -->
<section class="rdd-section">
<h3 class="rdd-section__title">RD List – What to Do Next</h3>
<div class="rdd-toolbar">
<div class="rdd-search">
<fa-icon icon="search" class="rdd-search__icon"></fa-icon>
<input class="rdd-search__input" type="text" placeholder="Account no. or client name" [(ngModel)]="search" />
</div>
<select class="rdd-select" [(ngModel)]="officeFilter">
<option [ngValue]="''">All branches</option>
@for (o of offices; track o.id) {
<option [ngValue]="o.id">{{ o.name }}</option>
}
</select>
<select class="rdd-select" [(ngModel)]="productFilter">
<option [ngValue]="''">All products</option>
@for (p of products; track p.id) {
<option [ngValue]="p.id">{{ p.name }}</option>
}
</select>
<select class="rdd-select" [(ngModel)]="stageFilter" (ngModelChange)="extraFilter = ''">
<option [ngValue]="''">All statuses</option>
@for (k of stageKeys; track k) {
<option [ngValue]="k">{{ stages[k].label }}</option>
}
</select>
@if (anyFilter) {
<button class="rdd-btn rdd-btn--slim" (click)="resetFilters()"><fa-icon icon="undo"></fa-icon> Clear</button>
}
<span class="rdd-toolbar__spacer"></span>
<span class="rdd-count"
><strong>{{ visibleRows.length }}</strong> of {{ rows.length }} RDs</span
>
</div>
@if (loading) {
<div class="rdd-state"><fa-icon icon="spinner" [class.fa-spin]="true"></fa-icon> Loading RDs...</div>
} @else if (visibleRows.length === 0) {
<div class="rdd-state">
<fa-icon icon="inbox"></fa-icon>
{{ rows.length ? 'No RDs match these filters.' : 'No running Recurring Deposits.' }}
</div>
} @else {
<div class="rdd-table-card">
<table class="rdd-table">
<thead>
<tr>
<th>Account</th>
<th>Client</th>
<th>Product</th>
<th class="text-right">Installment</th>
<th>Next Due</th>
<th class="text-right">Overdue</th>
<th>Maturity</th>
<th>Status</th>
<th class="text-center">Auto Debit</th>
<th class="text-right">Lien</th>
<th class="text-center">Action</th>
</tr>
</thead>
<tbody>
@for (r of visibleRows; track r.id) {
<tr class="rdd-row" (click)="openAccount(r)">
<td>
<span class="rdd-mono">{{ r.accountNo }}</span>
<div class="rdd-sub">{{ r.officeName }}</div>
</td>
<td class="rdd-strong">{{ r.clientName }}</td>
<td class="rdd-muted">{{ r.productName }}</td>
<td class="text-right">
<span class="rdd-mono">₹{{ r.installmentAmount | number: '1.2-2' }}</span>
<div class="rdd-sub">{{ r.paidInstallments }} of {{ r.totalInstallments }} paid</div>
</td>
<td>
<span class="rdd-mono">{{ r.nextDueDate || '—' }}</span>
<div class="rdd-sub" [class.rdd-sub--danger]="r.daysLate > 0">{{ dueText(r) }}</div>
</td>
<td class="text-right">
@if (r.overdueInstallments > 0) {
<span class="rdd-mono rdd-danger">₹{{ r.overdueAmount | number: '1.2-2' }}</span>
<div class="rdd-sub">
{{ r.overdueInstallments }} installment{{ r.overdueInstallments === 1 ? '' : 's' }}
</div>
} @else {
<span class="rdd-muted">—</span>
}
</td>
<td>
<span class="rdd-mono">{{ r.maturityDate || '—' }}</span>
<div class="rdd-sub">{{ maturityText(r) }}</div>
</td>
<td>
<span [class]="stageClass(r.stage)" [title]="r.meta.hint">{{ r.meta.label }}</span>
@if (r.penaltyCount > 0) {
<div class="rdd-sub">Penalty ₹{{ r.penaltyAmount | number: '1.0-0' }} ({{ r.penaltyCount }}x)</div>
}
</td>
<td class="text-center">
@if (r.autoDebit) {
<span class="rdd-yes" [title]="'From ' + (r.linkedAccountNo || 'linked savings')">On</span>
} @else {
<span class="rdd-muted">Off</span>
}
</td>
<td class="text-right">
@if (r.lienAmount > 0) {
<span class="rdd-mono">₹{{ r.lienAmount | number: '1.0-0' }}</span>
} @else {
<span class="rdd-muted">—</span>
}
</td>
<td class="text-center rdd-actions" (click)="$event.stopPropagation()">
@if (r.stage === 'MATURED') {
<button class="rdd-btn rdd-btn--primary rdd-btn--xs" (click)="closeAccount(r)">Close</button>
}
<button class="rdd-btn rdd-btn--xs" (click)="openAccount(r)">View</button>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</section>
</div>
@@ -0,0 +1,620 @@
/**
* 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/.
*/
/** RD Dashboard - same light, compact look as the FD Dashboard. */
:host {
display: block;
font-family:
'DM Sans',
system-ui,
-apple-system,
sans-serif;
color: #0f172a;
background: #f8fafc;
min-height: 100vh;
padding: 12px 16px;
box-sizing: border-box;
}
.rdd-container {
max-width: 1400px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 14px;
}
/* ── Header ─────────────────────────────────────────────────────────────── */
.rdd-header {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 10px 16px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
box-shadow: 0 1px 3px rgb(0 0 0 / 4%);
&__left {
display: flex;
align-items: center;
gap: 12px;
}
&__icon {
width: 36px;
height: 36px;
border-radius: 8px;
background: #ecfdf5;
color: #059669;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
flex-shrink: 0;
}
&__title {
margin: 0;
font-size: 16px;
font-weight: 700;
}
&__sub {
margin: 2px 0 0;
font-size: 11.5px;
color: #64748b;
}
}
.rdd-alert {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 8px;
font-size: 12px;
font-weight: 600;
background: #fef2f2;
border: 1px solid #fca5a5;
color: #dc2626;
}
/* ── Sections ───────────────────────────────────────────────────────────── */
.rdd-section {
display: flex;
flex-direction: column;
gap: 8px;
&__title {
margin: 0;
font-size: 13px;
font-weight: 700;
color: #334155;
}
}
/* ── 1. Summary cards ───────────────────────────────────────────────────── */
.rdd-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: 10px;
}
.rdd-card {
display: flex;
flex-direction: column;
gap: 3px;
background: #fff;
border: 1px solid #e2e8f0;
border-left: 4px solid #cbd5e1;
border-radius: 10px;
padding: 10px 14px;
text-align: left;
font-family: inherit;
cursor: pointer;
transition: all 0.15s ease;
&:hover:not(.rdd-card--static) {
box-shadow: 0 4px 12px rgb(15 23 42 / 6%);
}
&--static {
cursor: default;
}
&__label {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 700;
color: #334155;
}
&__val {
font-size: 22px;
font-weight: 800;
line-height: 1.15;
}
&__sub {
font-size: 11px;
color: #64748b;
}
&--success {
border-left-color: #10b981;
.rdd-card__label fa-icon {
color: #10b981;
}
&.rdd-card--active {
background: #ecfdf5;
border-color: #6ee7b7;
}
}
&--info {
border-left-color: #3b82f6;
.rdd-card__label fa-icon {
color: #3b82f6;
}
&.rdd-card--active {
background: #eff6ff;
border-color: #93c5fd;
}
}
&--danger {
border-left-color: #ef4444;
.rdd-card__label fa-icon {
color: #ef4444;
}
&.rdd-card--active {
background: #fef2f2;
border-color: #fca5a5;
}
}
&--warn {
border-left-color: #f59e0b;
.rdd-card__label fa-icon {
color: #f59e0b;
}
&.rdd-card--active {
background: #fffbeb;
border-color: #fcd34d;
}
}
&--neutral {
border-left-color: #64748b;
.rdd-card__label fa-icon {
color: #64748b;
}
}
}
.rdd-progress {
height: 6px;
border-radius: 3px;
background: #e2e8f0;
overflow: hidden;
&__bar {
height: 100%;
background: #10b981;
border-radius: 3px;
}
}
/* ── 2. Installment health ──────────────────────────────────────────────── */
.rdd-health {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 10px;
@media (width <= 900px) {
grid-template-columns: 1fr;
}
}
.rdd-tiles {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 10px;
}
.rdd-tile {
display: grid;
grid-template-columns: 10px 1fr;
column-gap: 8px;
row-gap: 2px;
align-items: center;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 10px 12px;
text-align: left;
font-family: inherit;
cursor: pointer;
transition: all 0.15s ease;
&:hover:not(.rdd-tile--static) {
box-shadow: 0 4px 12px rgb(15 23 42 / 6%);
}
&--active {
border-color: #2563eb;
background: #f8fafc;
}
&--static {
cursor: default;
}
&__dot {
width: 10px;
height: 10px;
border-radius: 50%;
&--neutral {
background: #64748b;
}
&--warn {
background: #f59e0b;
}
&--danger {
background: #ef4444;
}
&--success {
background: #10b981;
}
}
&__label {
font-size: 12px;
font-weight: 700;
color: #334155;
}
&__val,
&__sub {
grid-column: 2;
}
&__val {
font-size: 20px;
font-weight: 800;
}
&__sub {
font-size: 10.5px;
color: #64748b;
}
}
.rdd-ageing {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 4px;
&__title {
font-size: 12px;
font-weight: 700;
color: #334155;
margin-bottom: 2px;
}
&__row {
display: grid;
grid-template-columns: 74px 1fr 28px;
align-items: center;
gap: 8px;
border: none;
background: transparent;
padding: 3px 4px;
border-radius: 6px;
font-family: inherit;
cursor: pointer;
&:hover,
&--active {
background: #f1f5f9;
}
}
&__label {
font-size: 11px;
color: #475569;
text-align: left;
}
&__track {
height: 8px;
background: #f1f5f9;
border-radius: 4px;
overflow: hidden;
}
&__bar {
display: block;
height: 100%;
background: #f97316;
border-radius: 4px;
}
&__count {
font-size: 12px;
font-weight: 700;
text-align: right;
}
}
/* ── 3. Action list ─────────────────────────────────────────────────────── */
.rdd-toolbar {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 6px 10px;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
&__spacer {
flex: 1;
}
}
.rdd-search {
position: relative;
display: flex;
align-items: center;
width: 240px;
&__icon {
position: absolute;
left: 9px;
color: #94a3b8;
font-size: 12px;
}
&__input {
width: 100%;
padding: 5px 8px 5px 27px;
border: 1px solid #cbd5e1;
border-radius: 6px;
font-size: 12px;
font-family: inherit;
&:focus {
outline: none;
border-color: #2563eb;
}
}
}
.rdd-select {
padding: 5px 8px;
border: 1px solid #cbd5e1;
border-radius: 6px;
font-size: 12px;
font-family: inherit;
background: #fff;
color: #0f172a;
}
.rdd-count {
font-size: 11.5px;
color: #64748b;
}
.rdd-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 6px;
font-family: inherit;
font-size: 12px;
font-weight: 600;
cursor: pointer;
border: 1px solid #cbd5e1;
background: #fff;
color: #334155;
&:hover:not(:disabled) {
border-color: #2563eb;
color: #2563eb;
}
&--slim {
padding: 4px 10px;
font-size: 11.5px;
}
&--xs {
padding: 3px 9px;
font-size: 11px;
}
&--primary {
background: #2563eb;
border-color: #2563eb;
color: #fff;
&:hover:not(:disabled) {
background: #1d4ed8;
color: #fff;
}
}
&:disabled {
opacity: 0.55;
cursor: not-allowed;
}
}
.rdd-state {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 24px;
text-align: center;
font-size: 12px;
color: #64748b;
}
.rdd-table-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 10px;
overflow-x: auto;
}
.rdd-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
th {
padding: 8px 10px;
background: #f1f5f9;
color: #475569;
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
text-align: left;
white-space: nowrap;
border-bottom: 1px solid #e2e8f0;
}
td {
padding: 7px 10px;
border-bottom: 1px solid #f1f5f9;
vertical-align: middle;
}
.text-right {
text-align: right;
}
.text-center {
text-align: center;
}
}
.rdd-row {
cursor: pointer;
&:hover {
background: #f8fafc;
}
}
.rdd-mono {
font-family: 'JetBrains Mono', monospace;
font-size: 11.5px;
font-weight: 600;
}
.rdd-strong {
font-weight: 600;
}
.rdd-muted {
color: #64748b;
font-size: 11.5px;
}
.rdd-danger {
color: #dc2626;
}
.rdd-sub {
font-size: 10.5px;
color: #64748b;
&--danger {
color: #dc2626;
font-weight: 700;
}
}
.rdd-yes {
font-size: 11px;
font-weight: 700;
color: #059669;
}
.rdd-actions {
white-space: nowrap;
.rdd-btn + .rdd-btn {
margin-left: 4px;
}
}
.rdd-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 12px;
font-size: 10.5px;
font-weight: 700;
white-space: nowrap;
&--success {
background: #ecfdf5;
color: #047857;
}
&--info {
background: #eff6ff;
color: #2563eb;
}
&--warn {
background: #fffbeb;
color: #b45309;
}
&--danger {
background: #fef2f2;
color: #dc2626;
}
&--neutral {
background: #f1f5f9;
color: #475569;
}
}
@@ -0,0 +1,256 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component, OnInit, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
/** Custom Modules */
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
/** Custom Services */
import { AccountsService } from '../accounts/accounts.service';
/** Dashboard data + computation */
import {
RD_AGEING_BUCKETS,
RD_STAGES,
RdDashboardResponse,
RdRow,
RdStage,
RdSummary,
buildRdRow,
rdSummary,
sortRdRows
} from './rd-dashboard.compute';
/**
* RD Dashboard: every running Recurring Deposit, with its installment position as of the business date.
*
* Three parts, top to bottom: the summary cards (how the RD book stands), installment health (who is late and how
* badly), and the action list (each RD with what to do next). Every figure comes from one call to
* `/recurringdepositaccounts/dashboard`, which works out paid/late installments the same way the nightly penalty job
* does, so this screen and the penalties never disagree.
*/
@Component({
selector: 'mifosx-rd-dashboard',
templateUrl: './rd-dashboard.component.html',
styleUrls: ['./rd-dashboard.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule,
FaIconComponent
]
})
export class RdDashboardComponent implements OnInit {
private accountsService = inject(AccountsService);
private router = inject(Router);
loading = false;
errorMessage = '';
businessDate = '';
maturedClosedThisMonth = 0;
prematureClosedThisMonth = 0;
penaltyIncomeThisMonth = 0;
penaltyIncomeTotal = 0;
rows: RdRow[] = [];
summary: RdSummary = rdSummary([]);
/** Filters */
stageFilter: RdStage | '' = '';
/** Narrower than a stage: 'overdue' (any late installment), 'lien', or an ageing bucket key. */
extraFilter = '';
officeFilter: number | '' = '';
productFilter: number | '' = '';
search = '';
offices: { id: number; name: string }[] = [];
products: { id: number; name: string }[] = [];
readonly stages = RD_STAGES;
readonly stageKeys: RdStage[] = [
'MATURED',
'PAST_GRACE',
'IN_GRACE',
'DUE_TODAY',
'REGULAR'
];
readonly ageingBuckets = RD_AGEING_BUCKETS;
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.errorMessage = '';
this.accountsService.getRecurringDepositDashboard().subscribe({
next: (res: RdDashboardResponse) => {
this.businessDate = res?.businessDate || '';
this.maturedClosedThisMonth = Number(res?.maturedClosedThisMonth) || 0;
this.prematureClosedThisMonth = Number(res?.prematureClosedThisMonth) || 0;
this.penaltyIncomeThisMonth = Number(res?.penaltyIncomeThisMonth) || 0;
this.penaltyIncomeTotal = Number(res?.penaltyIncomeTotal) || 0;
const accounts = Array.isArray(res?.accounts) ? res.accounts : [];
this.rows = sortRdRows(accounts.map((a) => buildRdRow(a, this.businessDate)));
this.summary = rdSummary(this.rows);
this.offices = this.distinct(this.rows.map((r) => ({ id: r.officeId, name: r.officeName })));
this.products = this.distinct(this.rows.map((r) => ({ id: r.productId, name: r.productName })));
this.loading = false;
},
error: (err: any) => {
this.rows = [];
this.summary = rdSummary([]);
this.loading = false;
this.errorMessage =
err?.error?.defaultUserMessage || err?.error?.developerMessage || 'Could not load the RD dashboard.';
}
});
}
private distinct(items: { id: number; name: string }[]): { id: number; name: string }[] {
const seen = new Map<number, string>();
items.forEach((i) => seen.set(i.id, i.name));
return [...seen.entries()].map(([id, name]) => ({ id, name })).sort((a, b) => a.name.localeCompare(b.name));
}
// ── Filtering ──────────────────────────────────────────────────────────────
get visibleRows(): RdRow[] {
const needle = this.search.trim().toLowerCase();
return this.rows.filter((r) => {
if (this.stageFilter && r.stage !== this.stageFilter) {
return false;
}
if (this.extraFilter === 'overdue' && !(r.overdueInstallments > 0 && r.stage !== 'MATURED')) {
return false;
}
if (this.extraFilter === 'lien' && !(r.lienAmount > 0)) {
return false;
}
if (this.isAgeingKey(this.extraFilter) && r.ageingKey !== this.extraFilter) {
return false;
}
if (this.officeFilter !== '' && r.officeId !== this.officeFilter) {
return false;
}
if (this.productFilter !== '' && r.productId !== this.productFilter) {
return false;
}
if (needle && !`${r.accountNo} ${r.clientName}`.toLowerCase().includes(needle)) {
return false;
}
return true;
});
}
private isAgeingKey(key: string): boolean {
return this.ageingBuckets.some((b) => b.key === key);
}
/** Clicking a card or tile shows just those RDs in the list below; clicking it again shows all. */
showStage(stage: RdStage): void {
const same = this.stageFilter === stage && !this.extraFilter;
this.stageFilter = same ? '' : stage;
this.extraFilter = '';
}
showExtra(key: string): void {
const same = this.extraFilter === key && !this.stageFilter;
this.extraFilter = same ? '' : key;
this.stageFilter = '';
}
get anyFilter(): boolean {
return !!(this.stageFilter || this.extraFilter || this.officeFilter !== '' || this.productFilter !== '' || this.search);
}
resetFilters(): void {
this.stageFilter = '';
this.extraFilter = '';
this.officeFilter = '';
this.productFilter = '';
this.search = '';
}
/** Width of an ageing bar, relative to the largest bucket. */
ageingWidth(key: string): number {
const max = Math.max(1, ...this.ageingBuckets.map((b) => this.summary.ageing[b.key] || 0));
return Math.round(((this.summary.ageing[key] || 0) / max) * 100);
}
// ── Navigation ─────────────────────────────────────────────────────────────
openAccount(row: RdRow): void {
this.router.navigate([
'/clients',
row.clientId,
'recurring-deposits-accounts',
row.id,
'general'
]);
}
closeAccount(row: RdRow): void {
this.router.navigate([
'/clients',
row.clientId,
'recurring-deposits-accounts',
row.id,
'actions',
'Close'
]);
}
// ── Presentation ───────────────────────────────────────────────────────────
stageClass(stage: RdStage): string {
return `rdd-badge rdd-badge--${RD_STAGES[stage].tone}`;
}
/** "5 days late" / "Due today" / "Due in 12 days", as a branch officer would say it. */
dueText(row: RdRow): string {
if (row.stage === 'MATURED') {
return 'Term complete';
}
if (!row.nextDueDate) {
return 'All installments paid';
}
if (row.daysLate > 0) {
return `${row.daysLate} day${row.daysLate === 1 ? '' : 's'} late`;
}
const days = this.daysUntil(row.nextDueDate);
if (days === 0) {
return 'Due today';
}
return `Due in ${days} day${days === 1 ? '' : 's'}`;
}
maturityText(row: RdRow): string {
if (row.daysToMaturity == null) {
return '';
}
if (row.daysToMaturity < 0) {
return `Matured ${-row.daysToMaturity} day${row.daysToMaturity === -1 ? '' : 's'} ago`;
}
if (row.daysToMaturity === 0) {
return 'Matures today';
}
return `In ${row.daysToMaturity} day${row.daysToMaturity === 1 ? '' : 's'}`;
}
private daysUntil(iso: string): number {
const a = new Date(`${this.businessDate}T00:00:00Z`).getTime();
const b = new Date(`${iso}T00:00:00Z`).getTime();
return Math.round((b - a) / 86400000);
}
}
@@ -0,0 +1,214 @@
/**
* 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/.
*/
/**
* Where a running Recurring Deposit stands today, most urgent first:
* - MATURED: the term is over - the deposit is waiting to be closed and paid out.
* - PAST_GRACE: an installment is late beyond the product's grace days - the penalty applies.
* - IN_GRACE: an installment is late but still inside the grace days - no penalty yet.
* - DUE_TODAY: today's installment has not come in yet.
* - REGULAR: nothing due or late.
*/
export type RdStage = 'MATURED' | 'PAST_GRACE' | 'IN_GRACE' | 'DUE_TODAY' | 'REGULAR';
export interface RdStageMeta {
label: string;
tone: 'danger' | 'warn' | 'info' | 'success' | 'neutral';
hint: string;
}
export const RD_STAGES: Record<RdStage, RdStageMeta> = {
MATURED: { label: 'Matured – Close Pending', tone: 'info', hint: 'Term complete; close it to pay the client.' },
PAST_GRACE: { label: 'Overdue – Penalty', tone: 'danger', hint: 'Late beyond the grace days; penalty applies.' },
IN_GRACE: { label: 'Late – In Grace', tone: 'warn', hint: 'Late, but still within the grace days.' },
DUE_TODAY: { label: 'Due Today', tone: 'neutral', hint: "Today's installment has not come in yet." },
REGULAR: { label: 'Regular', tone: 'success', hint: 'All installments paid on time.' }
};
/** Late-installment ageing buckets, by days since the oldest unpaid installment fell due. */
export const RD_AGEING_BUCKETS = [
{ key: '1-30', label: '1–30 days', from: 1, to: 30 },
{ key: '31-60', label: '31–60 days', from: 31, to: 60 },
{ key: '61-90', label: '61–90 days', from: 61, to: 90 },
{ key: '90+', label: '90+ days', from: 91, to: Infinity }
];
/** SavingsAccountStatusType.MATURED. */
const STATUS_MATURED = 800;
/** One account as `/recurringdepositaccounts/dashboard` returns it. */
export interface RdDashboardAccount {
id: number;
accountNo: string;
clientId: number;
clientName: string;
officeId: number;
officeName: string;
productId: number;
productName: string;
status: number;
balance: number;
installmentAmount: number;
maturityDate: string | null;
maturityAmount: number | null;
autoDebit: boolean;
linkedAccountNo: string | null;
graceDays: number;
penaltyEnabled: boolean;
totalInstallments: number;
paidInstallments: number;
nextDueDate: string | null;
overdueInstallments: number;
overdueAmount: number;
dueTodayCount: number;
dueTodayPaid: number;
dueThisMonth: number;
receivedThisMonth: number;
paidThisMonth: number;
penaltyCount: number;
penaltyAmount: number;
lienAmount: number;
}
export interface RdDashboardResponse {
businessDate: string;
maturedClosedThisMonth: number;
prematureClosedThisMonth: number;
penaltyIncomeThisMonth: number;
penaltyIncomeTotal: number;
accounts: RdDashboardAccount[];
}
export interface RdRow extends RdDashboardAccount {
stage: RdStage;
meta: RdStageMeta;
/** Days since the oldest unpaid installment fell due; 0 when nothing is late. */
daysLate: number;
/** Days left until maturity; negative once past it. */
daysToMaturity: number | null;
ageingKey: string | null;
}
function daysBetween(earlier: string, later: string): number {
const a = new Date(`${earlier}T00:00:00Z`).getTime();
const b = new Date(`${later}T00:00:00Z`).getTime();
return Math.round((b - a) / 86400000);
}
export function buildRdRow(a: RdDashboardAccount, today: string): RdRow {
const num = (v: any) => Number(v) || 0;
const account: RdDashboardAccount = {
...a,
balance: num(a.balance),
installmentAmount: num(a.installmentAmount),
maturityAmount: a.maturityAmount == null ? null : num(a.maturityAmount),
graceDays: num(a.graceDays),
totalInstallments: num(a.totalInstallments),
paidInstallments: num(a.paidInstallments),
overdueInstallments: num(a.overdueInstallments),
overdueAmount: num(a.overdueAmount),
dueTodayCount: num(a.dueTodayCount),
dueTodayPaid: num(a.dueTodayPaid),
dueThisMonth: num(a.dueThisMonth),
receivedThisMonth: num(a.receivedThisMonth),
paidThisMonth: num(a.paidThisMonth),
penaltyCount: num(a.penaltyCount),
penaltyAmount: num(a.penaltyAmount),
lienAmount: num(a.lienAmount)
};
// When anything is late, the earliest unpaid installment is the oldest late one.
const daysLate = account.overdueInstallments > 0 && account.nextDueDate ? daysBetween(account.nextDueDate, today) : 0;
const daysToMaturity = account.maturityDate ? daysBetween(today, account.maturityDate) : null;
let stage: RdStage;
if (account.status === STATUS_MATURED || (daysToMaturity != null && daysToMaturity <= 0)) {
stage = 'MATURED';
} else if (daysLate > 0) {
stage = daysLate > account.graceDays ? 'PAST_GRACE' : 'IN_GRACE';
} else if (account.dueTodayCount > account.dueTodayPaid) {
stage = 'DUE_TODAY';
} else {
stage = 'REGULAR';
}
const bucket = daysLate > 0 ? RD_AGEING_BUCKETS.find((b) => daysLate >= b.from && daysLate <= b.to) : null;
return {
...account,
stage,
meta: RD_STAGES[stage],
daysLate,
daysToMaturity,
ageingKey: bucket ? bucket.key : null
};
}
export interface RdSummary {
activeCount: number;
activeBalance: number;
maturedCount: number;
maturedAmount: number;
dueThisMonth: number;
receivedThisMonth: number;
collectionPercent: number;
overdueCount: number;
overdueAmount: number;
lienCount: number;
lienAmount: number;
dueTodayCount: number;
dueTodayPaidCount: number;
inGraceCount: number;
inGraceAmount: number;
pastGraceCount: number;
pastGraceAmount: number;
ageing: Record<string, number>;
}
export function rdSummary(rows: RdRow[]): RdSummary {
const sum = (list: RdRow[], f: (r: RdRow) => number) => list.reduce((s, r) => s + f(r), 0);
const running = rows.filter((r) => r.stage !== 'MATURED');
const matured = rows.filter((r) => r.stage === 'MATURED');
const overdue = running.filter((r) => r.overdueInstallments > 0);
const inGrace = rows.filter((r) => r.stage === 'IN_GRACE');
const pastGrace = rows.filter((r) => r.stage === 'PAST_GRACE');
const lien = rows.filter((r) => r.lienAmount > 0);
const dueThisMonth = sum(running, (r) => r.dueThisMonth);
const receivedThisMonth = sum(running, (r) => r.receivedThisMonth);
const ageing: Record<string, number> = {};
RD_AGEING_BUCKETS.forEach((b) => (ageing[b.key] = running.filter((r) => r.ageingKey === b.key).length));
return {
activeCount: running.length,
activeBalance: sum(running, (r) => r.balance),
maturedCount: matured.length,
maturedAmount: sum(matured, (r) => r.maturityAmount ?? r.balance),
dueThisMonth,
receivedThisMonth,
collectionPercent: dueThisMonth > 0 ? Math.round((receivedThisMonth / dueThisMonth) * 100) : 100,
overdueCount: overdue.length,
overdueAmount: sum(overdue, (r) => r.overdueAmount),
lienCount: lien.length,
lienAmount: sum(lien, (r) => r.lienAmount),
dueTodayCount: running.filter((r) => r.dueTodayCount > 0).length,
dueTodayPaidCount: running.filter((r) => r.dueTodayCount > 0 && r.dueTodayPaid >= r.dueTodayCount).length,
inGraceCount: inGrace.length,
inGraceAmount: sum(inGrace, (r) => r.overdueAmount),
pastGraceCount: pastGrace.length,
pastGraceAmount: sum(pastGrace, (r) => r.overdueAmount),
ageing
};
}
/** Most urgent first: matured, then the longest late, then due today, then the rest by next due date. */
export function sortRdRows(rows: RdRow[]): RdRow[] {
const rank: Record<RdStage, number> = { MATURED: 0, PAST_GRACE: 1, IN_GRACE: 2, DUE_TODAY: 3, REGULAR: 4 };
return [...rows].sort(
(a, b) =>
rank[a.stage] - rank[b.stage] ||
b.daysLate - a.daysLate ||
(a.nextDueDate || '9999').localeCompare(b.nextDueDate || '9999') ||
a.accountNo.localeCompare(b.accountNo)
);
}
@@ -0,0 +1,22 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { NgModule } from '@angular/core';
import { SharedModule } from '../shared/shared.module';
import { RdDashboardRoutingModule } from './rd-dashboard-routing.module';
import { RdDashboardComponent } from './rd-dashboard.component';
@NgModule({
imports: [
SharedModule,
RdDashboardRoutingModule,
RdDashboardComponent
]
})
export class RdDashboardModule {}