Skip to content

Commit cbc7cd5

Browse files
authored
WEB-877: add configurable analytics dashboard foundation (#3420)
1 parent 8ec947b commit cbc7cd5

17 files changed

+1365
-155
lines changed
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<!--
2+
Copyright since 2025 Mifos Initiative
3+
4+
This Source Code Form is subject to the terms of the Mozilla Public
5+
License, v. 2.0. If a copy of the MPL was not distributed with this
6+
file, You can obtain one at http://mozilla.org/MPL/2.0/.
7+
-->
8+
9+
@if (filtersForm) {
10+
<div class="dashboard-shell">
11+
<mat-card class="filter-card">
12+
<div class="filter-row">
13+
<mat-form-field class="office-field">
14+
<mat-label>{{ 'labels.inputs.Office' | translate }}</mat-label>
15+
<mat-select [formControl]="filtersForm.controls.officeId">
16+
@for (office of offices; track office.id) {
17+
<mat-option [value]="office.id">
18+
{{ office.name }}
19+
</mat-option>
20+
}
21+
</mat-select>
22+
</mat-form-field>
23+
24+
<mat-button-toggle-group [formControl]="filtersForm.controls.timescale" appearance="legacy">
25+
<mat-button-toggle value="Day">{{ 'labels.buttons.Day' | translate }}</mat-button-toggle>
26+
<mat-button-toggle value="Week">{{ 'labels.buttons.Week' | translate }}</mat-button-toggle>
27+
<mat-button-toggle value="Month">{{ 'labels.buttons.Month' | translate }}</mat-button-toggle>
28+
</mat-button-toggle-group>
29+
30+
<button mat-stroked-button type="button" (click)="reloadDashboard(true)">
31+
{{ 'labels.buttons.Refresh' | translate }}
32+
</button>
33+
</div>
34+
</mat-card>
35+
36+
@if (!visibleWidgets.length) {
37+
<mat-card class="empty-dashboard">
38+
{{ 'labels.text.No Data' | translate }}
39+
</mat-card>
40+
} @else {
41+
<div class="summary-grid">
42+
@for (widget of metricWidgets; track widget.id) {
43+
<mifosx-dashboard-widget [widget]="widget" [state]="widgetStateMap[widget.id]"></mifosx-dashboard-widget>
44+
}
45+
</div>
46+
47+
<div class="chart-grid">
48+
@for (widget of chartWidgets; track widget.id) {
49+
<div [class.widget-wide]="widget.layout === 'wide'" [class.widget-half]="widget.layout === 'half'">
50+
<mifosx-dashboard-widget [widget]="widget" [state]="widgetStateMap[widget.id]"></mifosx-dashboard-widget>
51+
</div>
52+
}
53+
</div>
54+
}
55+
</div>
56+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/**
2+
* Copyright since 2025 Mifos Initiative
3+
*
4+
* This Source Code Form is subject to the terms of the Mozilla Public
5+
* License, v. 2.0. If a copy of the MPL was not distributed with this
6+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
7+
*/
8+
9+
.dashboard-shell {
10+
display: flex;
11+
flex-direction: column;
12+
gap: 24px;
13+
}
14+
15+
.filter-card {
16+
padding: 16px;
17+
}
18+
19+
.filter-row {
20+
display: flex;
21+
flex-wrap: wrap;
22+
align-items: center;
23+
gap: 16px;
24+
}
25+
26+
.office-field {
27+
flex: 1 1 240px;
28+
min-width: 240px;
29+
}
30+
31+
.summary-grid {
32+
display: grid;
33+
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); /* stylelint-disable-line unit-allowed-list */
34+
gap: 16px;
35+
}
36+
37+
.chart-grid {
38+
display: grid;
39+
grid-template-columns: repeat(12, minmax(0, 1fr)); /* stylelint-disable-line unit-allowed-list */
40+
gap: 16px;
41+
}
42+
43+
.widget-wide {
44+
grid-column: span 12;
45+
}
46+
47+
.widget-half {
48+
grid-column: span 6;
49+
}
50+
51+
.empty-dashboard {
52+
padding: 24px;
53+
text-align: center;
54+
}
55+
56+
@media screen and (width <= 1024px) {
57+
.widget-half,
58+
.widget-wide {
59+
grid-column: span 12;
60+
}
61+
}
62+
63+
@media screen and (width <= 768px) {
64+
.filter-row {
65+
align-items: stretch;
66+
}
67+
68+
.office-field,
69+
.filter-row button,
70+
.filter-row mat-button-toggle-group {
71+
width: 100%;
72+
}
73+
}
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
/**
2+
* Copyright since 2025 Mifos Initiative
3+
*
4+
* This Source Code Form is subject to the terms of the Mozilla Public
5+
* License, v. 2.0. If a copy of the MPL was not distributed with this
6+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
7+
*/
8+
9+
/* eslint-disable @angular-eslint/prefer-inject */
10+
/** Angular Imports */
11+
import { Component, Input, OnChanges, OnDestroy, OnInit, SimpleChanges } from '@angular/core';
12+
import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms';
13+
import { Subscription, forkJoin } from 'rxjs';
14+
import { map } from 'rxjs/operators';
15+
import { MatButtonToggle, MatButtonToggleGroup } from '@angular/material/button-toggle';
16+
/** Custom Services */
17+
import { AuthenticationService } from 'app/core/authentication/authentication.service';
18+
import { AnalyticsDataSourceService } from '../services/analytics-data-source.service';
19+
import { AnalyticsVisibilityService } from '../services/analytics-visibility.service';
20+
/** Custom Models */
21+
import {
22+
AnalyticsDashboardDefinition,
23+
AnalyticsFilters,
24+
AnalyticsWidgetDefinition,
25+
AnalyticsWidgetState
26+
} from '../models/analytics-dashboard.model';
27+
/** Custom Imports */
28+
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
29+
import { DashboardWidgetComponent } from '../dashboard-widget/dashboard-widget.component';
30+
31+
@Component({
32+
selector: 'mifosx-analytics-dashboard',
33+
standalone: true,
34+
templateUrl: './dashboard-engine.component.html',
35+
styleUrls: ['./dashboard-engine.component.scss'],
36+
imports: [
37+
...STANDALONE_SHARED_IMPORTS,
38+
MatButtonToggleGroup,
39+
MatButtonToggle,
40+
DashboardWidgetComponent
41+
]
42+
})
43+
export class DashboardEngineComponent implements OnInit, OnChanges, OnDestroy {
44+
@Input({ required: true }) dashboard!: AnalyticsDashboardDefinition;
45+
@Input() offices: any[] = [];
46+
47+
filtersForm!: UntypedFormGroup;
48+
visibleWidgets: AnalyticsWidgetDefinition[] = [];
49+
widgetStateMap: Record<string, AnalyticsWidgetState> = {};
50+
51+
private filtersSubscription?: Subscription;
52+
private loadSubscription?: Subscription;
53+
54+
constructor(
55+
private formBuilder: UntypedFormBuilder,
56+
private authenticationService: AuthenticationService,
57+
private analyticsDataSourceService: AnalyticsDataSourceService,
58+
private analyticsVisibilityService: AnalyticsVisibilityService
59+
) {}
60+
get metricWidgets(): AnalyticsWidgetDefinition[] {
61+
return this.visibleWidgets.filter((widget) => widget.type === 'metric');
62+
}
63+
64+
get chartWidgets(): AnalyticsWidgetDefinition[] {
65+
return this.visibleWidgets.filter((widget) => widget.type === 'chart');
66+
}
67+
68+
ngOnInit(): void {
69+
this.updateVisibleWidgets();
70+
71+
this.filtersForm = this.formBuilder.group({
72+
officeId: [this.resolveDefaultOfficeId()],
73+
timescale: ['Month']
74+
});
75+
76+
this.filtersSubscription = this.filtersForm.valueChanges.subscribe(() => {
77+
this.reloadDashboard();
78+
});
79+
80+
this.reloadDashboard();
81+
}
82+
83+
ngOnChanges(changes: SimpleChanges): void {
84+
if (changes['dashboard']) {
85+
this.updateVisibleWidgets();
86+
if (this.filtersForm) {
87+
this.reloadDashboard();
88+
}
89+
}
90+
if (
91+
changes['offices'] &&
92+
this.filtersForm &&
93+
!this.offices.some((office) => office.id === this.filtersForm.value.officeId)
94+
) {
95+
this.filtersForm.patchValue(
96+
{
97+
officeId: this.resolveDefaultOfficeId()
98+
},
99+
{ emitEvent: false }
100+
);
101+
this.reloadDashboard();
102+
}
103+
}
104+
ngOnDestroy(): void {
105+
if (this.filtersSubscription) {
106+
this.filtersSubscription.unsubscribe();
107+
}
108+
109+
if (this.loadSubscription) {
110+
this.loadSubscription.unsubscribe();
111+
}
112+
}
113+
114+
reloadDashboard(forceRefresh: boolean = false): void {
115+
if (!this.visibleWidgets.length) {
116+
return;
117+
}
118+
119+
if (forceRefresh) {
120+
this.analyticsDataSourceService.clearCache();
121+
}
122+
123+
if (this.loadSubscription) {
124+
this.loadSubscription.unsubscribe();
125+
}
126+
127+
const filters = this.filtersForm.getRawValue() as AnalyticsFilters;
128+
this.widgetStateMap = this.visibleWidgets.reduce(
129+
(accumulator, widget) => ({
130+
...accumulator,
131+
[widget.id]: {
132+
loading: true,
133+
empty: false
134+
}
135+
}),
136+
{}
137+
);
138+
139+
this.loadSubscription = forkJoin(
140+
this.visibleWidgets.map((widget) =>
141+
this.analyticsDataSourceService.loadWidget(widget, filters).pipe(
142+
map((state) => ({
143+
widgetId: widget.id,
144+
state
145+
}))
146+
)
147+
)
148+
).subscribe({
149+
next: (results) => {
150+
this.widgetStateMap = results.reduce(
151+
(accumulator, result) => ({
152+
...accumulator,
153+
[result.widgetId]: result.state
154+
}),
155+
{}
156+
);
157+
},
158+
error: () => {
159+
this.widgetStateMap = this.visibleWidgets.reduce(
160+
(accumulator, widget) => ({
161+
...accumulator,
162+
[widget.id]: {
163+
loading: false,
164+
empty: true
165+
}
166+
}),
167+
{}
168+
);
169+
}
170+
});
171+
}
172+
173+
private resolveDefaultOfficeId(): number | null {
174+
const credentials = this.authenticationService.getCredentials();
175+
const currentOfficeId = credentials?.officeId;
176+
177+
if (currentOfficeId && this.offices.some((office) => office.id === currentOfficeId)) {
178+
return currentOfficeId;
179+
}
180+
181+
return this.offices[0]?.id ?? null;
182+
}
183+
private updateVisibleWidgets(): void {
184+
this.visibleWidgets = (this.dashboard?.widgets || []).filter((widget) =>
185+
this.analyticsVisibilityService.canView(widget.visibleTo)
186+
);
187+
}
188+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<!--
2+
Copyright since 2025 Mifos Initiative
3+
4+
This Source Code Form is subject to the terms of the Mozilla Public
5+
License, v. 2.0. If a copy of the MPL was not distributed with this
6+
file, You can obtain one at http://mozilla.org/MPL/2.0/.
7+
-->
8+
9+
<mat-card class="widget-card">
10+
<div class="widget-header">
11+
<div class="widget-title">
12+
<fa-icon [icon]="widget.icon"></fa-icon>
13+
<span>{{ widget.titleKey | translate }}</span>
14+
</div>
15+
16+
@if (state?.contextKey) {
17+
<span class="widget-context">
18+
{{ state.contextKey | translate }}
19+
</span>
20+
}
21+
</div>
22+
23+
<div class="widget-content">
24+
@if (state?.loading) {
25+
<div class="widget-placeholder"></div>
26+
} @else if (state?.empty) {
27+
<div class="widget-empty">
28+
{{ 'labels.text.No Data' | translate }}
29+
</div>
30+
} @else if (widget.type === 'metric') {
31+
<div class="metric-body">
32+
<div class="metric-value">
33+
{{ state.metricValue | number: '1.0-0' }}
34+
</div>
35+
</div>
36+
} @else {
37+
@if (state?.details?.length) {
38+
<div class="widget-details">
39+
@for (detail of state.details; track detail.labelKey) {
40+
<div class="detail-chip">
41+
<span class="detail-label">{{ detail.labelKey | translate }}</span>
42+
<span class="detail-value">{{ detail.value | number: '1.0-0' }}</span>
43+
</div>
44+
}
45+
</div>
46+
}
47+
48+
<div class="chart-shell">
49+
<canvas #chartCanvas></canvas>
50+
</div>
51+
}
52+
</div>
53+
</mat-card>

0 commit comments

Comments
 (0)