From 75a489da51589b2d75ba00b817c00a4d81d7efeb Mon Sep 17 00:00:00 2001 From: Soni Jay Date: Tue, 3 Feb 2026 08:05:55 +0530 Subject: [PATCH] WEB-634 The dashboard menu should display the full credit traceability. (#3078) --- src/app/loans/loans-routing.module.ts | 6 + .../loan-account-dashboard.component.html | 90 +++++ .../loan-account-dashboard.component.scss | 189 ++++++++++ .../loan-account-dashboard.component.ts | 335 ++++++++++++++++++ .../loans-view/loans-view.component.html | 9 + src/assets/translations/cs-CS.json | 9 + src/assets/translations/de-DE.json | 9 + src/assets/translations/en-US.json | 9 + src/assets/translations/es-CL.json | 9 + src/assets/translations/es-MX.json | 9 + src/assets/translations/fr-FR.json | 9 + src/assets/translations/it-IT.json | 9 + src/assets/translations/ko-KO.json | 9 + src/assets/translations/lt-LT.json | 9 + src/assets/translations/lv-LV.json | 9 + src/assets/translations/ne-NE.json | 9 + src/assets/translations/pt-PT.json | 9 + src/assets/translations/sw-SW.json | 9 + 18 files changed, 746 insertions(+) create mode 100644 src/app/loans/loans-view/loan-account-dashboard/loan-account-dashboard.component.html create mode 100644 src/app/loans/loans-view/loan-account-dashboard/loan-account-dashboard.component.scss create mode 100644 src/app/loans/loans-view/loan-account-dashboard/loan-account-dashboard.component.ts diff --git a/src/app/loans/loans-routing.module.ts b/src/app/loans/loans-routing.module.ts index 4a551deb5..23dc4b007 100644 --- a/src/app/loans/loans-routing.module.ts +++ b/src/app/loans/loans-routing.module.ts @@ -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, diff --git a/src/app/loans/loans-view/loan-account-dashboard/loan-account-dashboard.component.html b/src/app/loans/loans-view/loan-account-dashboard/loan-account-dashboard.component.html new file mode 100644 index 000000000..fa016797d --- /dev/null +++ b/src/app/loans/loans-view/loan-account-dashboard/loan-account-dashboard.component.html @@ -0,0 +1,90 @@ + + +
+ + +

+ {{ 'labels.heading.Loan Dashboard' | translate }} +

+
+
+ + +
+ + +
{{ 'labels.inputs.Principal Amount' | translate }}
+
{{ principalAmount | number: '1.2-2' }}
+
+
+ + + +
{{ 'labels.inputs.Total Repaid' | translate }}
+
{{ totalRepaid | number: '1.2-2' }}
+
+
+
+
+
+ + + +
{{ 'labels.inputs.Outstanding Balance' | translate }}
+
{{ outstandingBalance | number: '1.2-2' }}
+
+
+ + + +
{{ 'labels.inputs.Interest Charged' | translate }}
+
{{ interestCharged | number: '1.2-2' }}
+
+
+
+ + +
+ + + + {{ 'labels.heading.Repayment Progress' | translate }} + + + +
+ + @if (!loanData) { +
{{ 'labels.text.Loading data' | translate }}...
+ } +
+
+
+ + + + + {{ 'labels.heading.Payment Schedule' | translate }} + + + +
+ + @if (!loanData) { +
{{ 'labels.text.Loading data' | translate }}...
+ } @else if (!loanData.repaymentSchedule?.periods || loanData.repaymentSchedule.periods.length === 0) { +
+ {{ 'labels.text.No repayment schedule available' | translate }} +
+ } +
+
+
+
+
diff --git a/src/app/loans/loans-view/loan-account-dashboard/loan-account-dashboard.component.scss b/src/app/loans/loans-view/loan-account-dashboard/loan-account-dashboard.component.scss new file mode 100644 index 000000000..97d4c3c7d --- /dev/null +++ b/src/app/loans/loans-view/loan-account-dashboard/loan-account-dashboard.component.scss @@ -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; + } +} diff --git a/src/app/loans/loans-view/loan-account-dashboard/loan-account-dashboard.component.ts b/src/app/loans/loans-view/loan-account-dashboard/loan-account-dashboard.component.ts new file mode 100644 index 000000000..0618838d8 --- /dev/null +++ b/src/app/loans/loans-view/loan-account-dashboard/loan-account-dashboard.component.ts @@ -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; + @ViewChild('paymentsChart', { static: false }) paymentsChartCanvas!: ElementRef; + + 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(); + } + } +} diff --git a/src/app/loans/loans-view/loans-view.component.html b/src/app/loans/loans-view/loans-view.component.html index 108197f72..16b9ba92f 100644 --- a/src/app/loans/loans-view/loans-view.component.html +++ b/src/app/loans/loans-view/loans-view.component.html @@ -205,6 +205,15 @@ > {{ 'labels.inputs.General' | translate }} + + {{ 'labels.inputs.Dashboard' | translate }} +