WEB-634 The dashboard menu should display the full credit traceability. (#3078)
This commit is contained in:
@@ -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>
|
||||
+189
@@ -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']"
|
||||
|
||||
Reference in New Issue
Block a user