feat: recurring deposit enhancements, dashboard, chart, and account view updates
This commit is contained in:
@@ -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 {}
|
||||
Reference in New Issue
Block a user