WEB-634 The dashboard menu should display the full credit traceability. (#3078)

This commit is contained in:
Soni Jay
2026-02-02 20:35:55 -06:00
committed by GitHub
parent 1577e9dd8e
commit 75a489da51
18 changed files with 746 additions and 0 deletions
+6
View File
@@ -37,6 +37,7 @@ import { ExportTransactionsComponent } from './loans-view/transactions/export-tr
import { GlimAccountComponent } from './glim-account/glim-account.component';
import { CreateGlimAccountComponent } from './glim-account/create-glim-account/create-glim-account.component';
import { LoanBuyDownFeesTabComponent } from './loans-view/loan-buy-down-fees-tab/loan-buy-down-fees-tab.component';
import { LoanAccountDashboardComponent } from './loans-view/loan-account-dashboard/loan-account-dashboard.component';
/** Custom Resolvers */
import { LoanDetailsResolver } from './common-resolvers/loan-details.resolver';
@@ -112,6 +113,11 @@ const routes: Routes = [
data: { title: 'General', breadcrumb: 'General', routeParamBreadcrumb: false },
resolve: {}
},
{
path: 'dashboard',
component: LoanAccountDashboardComponent,
data: { title: 'Dashboard', breadcrumb: 'Dashboard', routeParamBreadcrumb: false }
},
{
path: 'accountdetail',
component: AccountDetailsComponent,
@@ -0,0 +1,90 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="dashboard-container">
<mat-card class="dashboard-header-card">
<mat-card-header class="header">
<h3>
{{ 'labels.heading.Loan Dashboard' | translate }}
</h3>
</mat-card-header>
</mat-card>
<!-- Metrics Cards -->
<div class="metrics-grid">
<mat-card class="metric-card">
<mat-card-content>
<div class="metric-label">{{ 'labels.inputs.Principal Amount' | translate }}</div>
<div class="metric-value">{{ principalAmount | number: '1.2-2' }}</div>
</mat-card-content>
</mat-card>
<mat-card class="metric-card">
<mat-card-content>
<div class="metric-label">{{ 'labels.inputs.Total Repaid' | translate }}</div>
<div class="metric-value success">{{ totalRepaid | number: '1.2-2' }}</div>
<div class="metric-progress">
<div class="progress-bar" [style.width.%]="progressPercentage"></div>
</div>
</mat-card-content>
</mat-card>
<mat-card class="metric-card">
<mat-card-content>
<div class="metric-label">{{ 'labels.inputs.Outstanding Balance' | translate }}</div>
<div class="metric-value warning">{{ outstandingBalance | number: '1.2-2' }}</div>
</mat-card-content>
</mat-card>
<mat-card class="metric-card">
<mat-card-content>
<div class="metric-label">{{ 'labels.inputs.Interest Charged' | translate }}</div>
<div class="metric-value">{{ interestCharged | number: '1.2-2' }}</div>
</mat-card-content>
</mat-card>
</div>
<!-- Charts Section -->
<div class="charts-grid">
<mat-card class="chart-card">
<mat-card-header>
<mat-card-title>
{{ 'labels.heading.Repayment Progress' | translate }}
</mat-card-title>
</mat-card-header>
<mat-card-content>
<div class="chart-container">
<canvas #statusChart></canvas>
@if (!loanData) {
<div class="no-data-message">{{ 'labels.text.Loading data' | translate }}...</div>
}
</div>
</mat-card-content>
</mat-card>
<mat-card class="chart-card">
<mat-card-header>
<mat-card-title>
{{ 'labels.heading.Payment Schedule' | translate }}
</mat-card-title>
</mat-card-header>
<mat-card-content>
<div class="chart-container">
<canvas #paymentsChart></canvas>
@if (!loanData) {
<div class="no-data-message">{{ 'labels.text.Loading data' | translate }}...</div>
} @else if (!loanData.repaymentSchedule?.periods || loanData.repaymentSchedule.periods.length === 0) {
<div class="no-data-message">
{{ 'labels.text.No repayment schedule available' | translate }}
</div>
}
</div>
</mat-card-content>
</mat-card>
</div>
</div>
@@ -0,0 +1,189 @@
/**
* 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/.
*/
.dashboard-container {
padding: 20px;
background: linear-gradient(135deg, #0d47a1 0%, #1565c0 50%, #1976d2 100%);
}
.dashboard-header-card {
margin-bottom: 24px;
padding: 0;
border: none;
background: rgb(255 255 255 / 10%);
backdrop-filter: blur(10px);
.header {
padding: 16px 24px;
background: transparent;
border-bottom: 1px solid rgb(255 255 255 / 20%);
h3 {
margin: 0;
color: white;
font-size: 22px;
font-weight: 600;
display: flex;
align-items: center;
fa-icon {
color: white;
}
}
}
}
.metrics-grid {
display: flex;
flex-wrap: wrap;
gap: 20px;
margin-bottom: 24px;
}
.metrics-grid .metric-card {
flex: 1 1 250px;
min-width: 250px;
}
.metric-card {
background: rgb(255 255 255 / 95%);
border: none;
border-radius: 16px;
box-shadow: 0 8px 24px rgb(0 0 0 / 15%);
transition: all 0.3s ease;
&:hover {
transform: translateY(-4px);
box-shadow: 0 12px 32px rgb(0 0 0 / 20%);
}
mat-card-content {
padding: 24px;
}
.metric-label {
font-size: 12px;
color: #666;
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 1px;
font-weight: 600;
}
.metric-value {
font-size: 32px;
font-weight: 700;
color: #1976d2;
margin-bottom: 12px;
&.success {
color: #4caf50;
}
&.warning {
color: #ff9800;
}
}
.metric-progress {
margin-top: 12px;
height: 8px;
background: linear-gradient(90deg, #e3f2fd 0%, #bbdefb 100%);
border-radius: 4px;
overflow: hidden;
.progress-bar {
height: 100%;
background: linear-gradient(90deg, #4caf50 0%, #66bb6a 100%);
transition: width 0.6s ease;
box-shadow: 0 2px 8px rgb(76 175 80 / 30%);
}
}
}
.charts-grid {
display: flex;
flex-wrap: wrap;
gap: 24px;
}
.charts-grid .chart-card {
flex: 1 1 450px;
min-width: 450px;
}
.chart-card {
background: rgb(255 255 255 / 95%);
border: none;
border-radius: 16px;
box-shadow: 0 8px 24px rgb(0 0 0 / 15%);
overflow: hidden;
mat-card-header {
padding: 20px 24px;
border-bottom: 1px solid #e0e0e0;
background: linear-gradient(135deg, #f5f5f5 0%, #fafafa 100%);
mat-card-title {
font-size: 18px;
font-weight: 600;
color: #1976d2;
display: flex;
align-items: center;
gap: 10px;
margin: 0;
fa-icon {
color: #1976d2;
}
}
}
mat-card-content {
padding: 24px;
background: white;
}
.chart-container {
width: 100%;
height: 350px;
position: relative;
canvas {
max-width: 100%;
height: auto !important;
}
.no-data-message {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #999;
font-size: 14px;
text-align: center;
font-weight: 500;
}
}
}
@media (width <= 768px) {
.metrics-grid .metric-card {
flex: 1 1 100%;
min-width: 100%;
}
.charts-grid .chart-card {
flex: 1 1 100%;
min-width: 100%;
}
.dashboard-container {
padding: 12px;
}
}
@@ -0,0 +1,335 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, AfterViewInit, ViewChild, ElementRef, inject, OnDestroy } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { Subscription } from 'rxjs';
import { MatCard, MatCardHeader, MatCardContent, MatCardTitle } from '@angular/material/card';
import { Chart, registerables } from 'chart.js';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
Chart.register(...registerables);
/**
* Loan Account Dashboard Component
* Displays graphical analysis and metrics for a specific loan account
*/
@Component({
selector: 'mifosx-loan-account-dashboard',
standalone: true,
templateUrl: './loan-account-dashboard.component.html',
styleUrls: ['./loan-account-dashboard.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatCard,
MatCardHeader,
MatCardContent,
MatCardTitle
]
})
export class LoanAccountDashboardComponent implements OnInit, AfterViewInit, OnDestroy {
private route = inject(ActivatedRoute);
private translate = inject(TranslateService);
private langChangeSubscription?: Subscription;
private routeDataSubscription?: Subscription;
@ViewChild('statusChart', { static: false }) statusChartCanvas!: ElementRef<HTMLCanvasElement>;
@ViewChild('paymentsChart', { static: false }) paymentsChartCanvas!: ElementRef<HTMLCanvasElement>;
private statusChart: any;
private paymentsChart: any;
private initTimeout: number | null = null;
/** Loan data */
loanData: any;
loanId: string = '';
/** Metrics */
principalAmount: number = 0;
totalRepaid: number = 0;
outstandingBalance: number = 0;
interestCharged: number = 0;
totalExpected: number = 0;
progressPercentage: number = 0;
ngOnInit(): void {
this.loanId = this.route.parent?.snapshot.paramMap.get('loanId') || '';
this.routeDataSubscription = this.route.parent!.data.subscribe((data: { loanDetailsData: any }) => {
if (data.loanDetailsData) {
this.loanData = data.loanDetailsData;
this.calculateMetrics();
this.initTimeout = window.setTimeout(() => {
this.createStatusChart();
this.createPaymentsChart();
}, 100);
}
});
this.langChangeSubscription = this.translate.onLangChange.subscribe(() => {
if (this.statusChart) {
this.createStatusChart();
}
if (this.paymentsChart) {
this.createPaymentsChart();
}
});
}
ngAfterViewInit(): void {
setTimeout(() => {
this.createStatusChart();
this.createPaymentsChart();
}, 100);
}
calculateMetrics(): void {
if (!this.loanData) return;
this.principalAmount = this.loanData.principal || 0;
this.totalRepaid = this.loanData.summary?.totalRepayment || 0;
this.outstandingBalance = this.loanData.summary?.totalOutstanding || 0;
this.interestCharged = this.loanData.summary?.interestCharged || 0;
this.totalExpected = this.loanData.summary?.totalExpectedRepayment || 0;
if (this.totalExpected === 0) {
this.progressPercentage = 0;
} else {
this.progressPercentage = Math.min(100, Math.max(0, (this.totalRepaid / this.totalExpected) * 100));
}
}
createStatusChart(): void {
if (!this.statusChartCanvas) return;
if (this.statusChart) {
this.statusChart.destroy();
}
const canvas = this.statusChartCanvas.nativeElement;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const repaidPercentage = Math.min(
100,
Math.max(0, this.totalExpected > 0 ? (this.totalRepaid / this.totalExpected) * 100 : 0)
);
const outstandingPercentage = Math.max(0, 100 - repaidPercentage);
this.statusChart = new Chart(ctx, {
type: 'doughnut',
data: {
labels: [
this.translate.instant('labels.inputs.Total Repaid'),
this.translate.instant('labels.inputs.Outstanding Balance')
],
datasets: [
{
data: [
repaidPercentage,
outstandingPercentage
],
backgroundColor: [
'#4CAF50',
'#FF9800'
],
borderWidth: 0,
borderColor: 'transparent',
hoverBorderWidth: 3,
hoverBorderColor: '#fff'
}
]
},
options: {
responsive: true,
maintainAspectRatio: true,
aspectRatio: 1.5,
cutout: '70%',
plugins: {
legend: {
position: 'bottom',
labels: {
padding: 20,
font: {
size: 13,
weight: '600'
},
usePointStyle: true,
pointStyle: 'circle',
generateLabels: (chart: any) => {
const data = chart.data;
if (data.labels.length && data.datasets.length) {
return data.labels.map((label: string, i: number) => {
const value = data.datasets[0].data[i];
return {
text: `${label}: ${value.toFixed(1)}%`,
fillStyle: data.datasets[0].backgroundColor[i],
hidden: false,
index: i
};
});
}
return [];
}
}
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
padding: 12,
titleFont: {
size: 14,
weight: 'bold'
},
bodyFont: {
size: 13
},
callbacks: {
label: function (context: any) {
const label = context.label || '';
const value = context.parsed || 0;
return `${label}: ${value.toFixed(1)}%`;
}
}
}
}
}
});
}
createPaymentsChart(): void {
if (!this.paymentsChartCanvas) return;
if (this.paymentsChart) {
this.paymentsChart.destroy();
}
const canvas = this.paymentsChartCanvas.nativeElement;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const repaymentSchedule = this.loanData?.repaymentSchedule?.periods || [];
const labels: string[] = [];
const principalData: number[] = [];
const interestData: number[] = [];
repaymentSchedule.forEach((period: any) => {
if (period.period && period.period > 0) {
labels.push(`${this.translate.instant('labels.inputs.Period')} ${period.period}`);
principalData.push(period.principalDue || 0);
interestData.push(period.interestDue || 0);
}
});
this.paymentsChart = new Chart(ctx, {
type: 'bar',
data: {
labels: labels.slice(0, 10),
datasets: [
{
label: this.translate.instant('labels.inputs.Principal'),
data: principalData.slice(0, 10),
backgroundColor: '#2196F3',
borderWidth: 0,
borderRadius: 8,
barThickness: 24
},
{
label: this.translate.instant('labels.inputs.Interest'),
data: interestData.slice(0, 10),
backgroundColor: '#FFC107',
borderWidth: 0,
borderRadius: 8,
barThickness: 24
}
]
},
options: {
responsive: true,
maintainAspectRatio: true,
aspectRatio: 2,
plugins: {
legend: {
position: 'top',
labels: {
usePointStyle: true,
pointStyle: 'circle',
padding: 15,
font: {
size: 13,
weight: '600'
}
}
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
padding: 12,
titleFont: {
size: 14,
weight: 'bold'
},
bodyFont: {
size: 13
},
callbacks: {
label: function (context: any) {
const label = context.dataset.label || '';
const value = context.parsed.y || 0;
return `${label}: ${value.toLocaleString()}`;
}
}
}
},
scales: {
x: {
stacked: true,
grid: {
display: false
},
ticks: {
font: {
size: 11
}
}
},
y: {
stacked: true,
beginAtZero: true,
grid: {
color: 'rgba(0, 0, 0, 0.05)'
},
ticks: {
font: {
size: 11
}
}
}
}
}
});
}
ngOnDestroy(): void {
if (this.initTimeout !== null) {
clearTimeout(this.initTimeout);
this.initTimeout = null;
}
if (this.routeDataSubscription) {
this.routeDataSubscription.unsubscribe();
}
if (this.langChangeSubscription) {
this.langChangeSubscription.unsubscribe();
}
if (this.statusChart) {
this.statusChart.destroy();
}
if (this.paymentsChart) {
this.paymentsChart.destroy();
}
}
}
@@ -205,6 +205,15 @@
>
{{ 'labels.inputs.General' | translate }}
</a>
<a
mat-tab-link
[routerLink]="['./dashboard']"
routerLinkActive
#dashboard="routerLinkActive"
[active]="dashboard.isActive"
>
{{ 'labels.inputs.Dashboard' | translate }}
</a>
<a
mat-tab-link
[routerLink]="['./accountdetail']"