Skip to content

Commit 6ea36cd

Browse files
committed
feat: Add widget for remaining budget with loading state and styling
1 parent bb1c071 commit 6ea36cd

9 files changed

Lines changed: 256 additions & 14 deletions
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
:host {
2+
display: block;
3+
width: 100%;
4+
height: 100%;
5+
}
6+
7+
gridster {
8+
background-color: var(--mat-sys-surface);
9+
}
10+
11+
gridster-item {
12+
background-color: transparent;
13+
}
14+
15+
.placeholder-widget {
16+
display: flex;
17+
flex-direction: column;
18+
align-items: center;
19+
justify-content: center;
20+
height: 100%;
21+
padding: 16px;
22+
border: 2px dashed var(--mat-sys-outline-variant);
23+
border-radius: 8px;
24+
background-color: var(--mat-sys-surface-container-low);
25+
color: var(--mat-sys-on-surface-variant);
26+
text-align: center;
27+
}
28+
29+
.placeholder-widget p {
30+
margin: 4px 0;
31+
font: var(--mat-sys-body-medium);
32+
}
Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,20 @@
11
<gridster [options]="options">
2-
@for (widget of widgets; track widget) {
3-
<gridster-item [item]="widget"> </gridster-item>
2+
@for (widget of widgets(); track widget) {
3+
<gridster-item [item]="widget">
4+
@switch (widget['component']) {
5+
@case ('widget-remaining') {
6+
<app-widget-remaining
7+
[record]="widget['data']"
8+
[loading]="loading()"
9+
></app-widget-remaining>
10+
}
11+
@default {
12+
<div class="placeholder-widget">
13+
<p>Widget: {{ widget['component'] || 'Empty' }}</p>
14+
<p>{{ widget.cols }}x{{ widget.rows }}</p>
15+
</div>
16+
}
17+
}
18+
</gridster-item>
419
}
520
</gridster>

frontend/src/app/components/dashboard-component/dashboard-component.component.ts

Lines changed: 45 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
import { Component, OnInit } from '@angular/core';
1+
import {
2+
ChangeDetectionStrategy,
3+
Component,
4+
computed,
5+
inject,
6+
OnInit,
7+
signal,
8+
} from '@angular/core';
29
import {
310
CompactType,
411
DisplayGrid,
@@ -9,19 +16,30 @@ import {
916
GridsterItemComponentInterface,
1017
GridType,
1118
} from 'angular-gridster2';
19+
import { WidgetRemainingComponent } from '../../widgets/widget-remaining/widget-remaining.component';
20+
import { FinanceRecordService } from '../../models/finance-record/finance-record-service';
1221

1322
@Component({
1423
standalone: true,
15-
imports: [GridsterComponent, GridsterItemComponent],
24+
imports: [GridsterComponent, GridsterItemComponent, WidgetRemainingComponent],
1625
selector: 'app-dashboard',
1726
templateUrl: './dashboard-component.component.html',
1827
styleUrls: ['./dashboard-component.component.css'],
28+
changeDetection: ChangeDetectionStrategy.OnPush,
1929
})
2030
export class DashboardComponent implements OnInit {
31+
readonly financeRecords = inject(FinanceRecordService);
32+
2133
options!: GridsterConfig;
22-
widgets!: Array<GridsterItem>;
34+
widgets = signal<GridsterItem[]>([]);
35+
readonly financeRecordsComputed = computed(() => this.financeRecords.financeRecords());
36+
readonly loading = computed(() => this.financeRecords.loading());
2337

2438
ngOnInit() {
39+
const startDate = new Date(new Date().getFullYear(), new Date().getMonth(), 1);
40+
const endDate = new Date();
41+
this.financeRecords.getFinanceRecords(startDate, endDate);
42+
2543
this.options = {
2644
gridType: GridType.Fit,
2745
compactType: CompactType.None,
@@ -35,7 +53,7 @@ export class DashboardComponent implements OnInit {
3553
mobileBreakpoint: 768,
3654
minCols: 12,
3755
maxCols: 12,
38-
minRows: 1,
56+
minRows: 20,
3957
maxRows: 100,
4058
maxItemCols: 12,
4159
minItemCols: 1,
@@ -60,12 +78,12 @@ export class DashboardComponent implements OnInit {
6078
emptyCellDragMaxRows: 50,
6179
ignoreMarginInRow: false,
6280
draggable: {
63-
enabled: false,
81+
enabled: true,
6482
ignoreContent: true,
6583
dragHandleClass: 'drag-handle',
6684
},
6785
resizable: {
68-
enabled: false,
86+
enabled: true,
6987
},
7088
swap: false,
7189
pushItems: true,
@@ -81,10 +99,25 @@ export class DashboardComponent implements OnInit {
8199
itemResizeCallback: this.itemResize.bind(this),
82100
};
83101

84-
this.widgets = [
85-
{ cols: 1, rows: 1, y: 0, x: 0, dragEnabled: true, resizeEnabled: true },
86-
{ cols: 2, rows: 2, y: 0, x: 2 },
87-
];
102+
this.widgets.set([
103+
{
104+
cols: 2,
105+
rows: 1,
106+
y: 0,
107+
x: 0,
108+
component: 'widget-remaining',
109+
data: this.financeRecordsComputed(),
110+
dragEnabled: true,
111+
resizeEnabled: true,
112+
},
113+
{
114+
cols: 4,
115+
rows: 2,
116+
y: 0,
117+
x: 4,
118+
component: 'widget-placeholder',
119+
},
120+
]);
88121
}
89122

90123
itemChange(item: GridsterItem, itemComponent: GridsterItemComponentInterface) {
@@ -100,11 +133,11 @@ export class DashboardComponent implements OnInit {
100133
}
101134

102135
removeItem(item: GridsterItem) {
103-
this.widgets.splice(this.widgets.indexOf(item), 1);
136+
this.widgets().splice(this.widgets().indexOf(item), 1);
104137
}
105138

106139
addItem() {
107-
this.widgets.push({
140+
this.widgets().push({
108141
x: 0,
109142
y: 0,
110143
rows: 0,
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
.widget-content {
2+
padding: 16px;
3+
text-align: center;
4+
}
5+
6+
.amount {
7+
font: var(--mat-sys-headline-large);
8+
color: var(--mat-sys-primary);
9+
margin-bottom: 8px;
10+
}
11+
12+
.label {
13+
font: var(--mat-sys-body-medium);
14+
color: var(--mat-sys-on-surface-variant);
15+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<app-widget-wrapper
2+
[title]="'Budget Overview'"
3+
[subtitle]="'Remaining balance'"
4+
[icon]="'account_balance_wallet'"
5+
[loading]="loading()"
6+
>
7+
<div class="widget-content">
8+
<div class="amount">{{ remainingBudget() | currency: currency() }}</div>
9+
<div class="label">Remaining Budget</div>
10+
</div>
11+
</app-widget-wrapper>
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { Component, computed, input } from '@angular/core';
2+
import { FinanceRecord } from '../../models/finance-record/finance-record';
3+
import { CurrencyPipe } from '@angular/common';
4+
import { WidgetWrapperComponent } from '../widget-wrapper/widget-wrapper.component';
5+
6+
@Component({
7+
selector: 'app-widget-remaining',
8+
templateUrl: './widget-remaining.component.html',
9+
styleUrls: ['./widget-remaining.component.css'],
10+
imports: [CurrencyPipe, WidgetWrapperComponent],
11+
})
12+
export class WidgetRemainingComponent {
13+
readonly record = input<FinanceRecord[]>([]);
14+
readonly currency = computed(() => this.record()[0]?.currency || 'USD');
15+
readonly remainingBudget = computed(() =>
16+
this.record().reduce((sum, record) => sum + record.amount, 0),
17+
);
18+
readonly loading = input<boolean>(false);
19+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
.widget-card {
2+
display: block;
3+
position: relative;
4+
height: 100%;
5+
}
6+
7+
.loading-overlay {
8+
position: absolute;
9+
inset: 0;
10+
display: flex;
11+
align-items: center;
12+
justify-content: center;
13+
background-color: rgba(0, 0, 0, 0.05);
14+
z-index: 1;
15+
pointer-events: all;
16+
border-radius: inherit;
17+
}
18+
19+
mat-card-header {
20+
margin-bottom: 1rem;
21+
}
22+
23+
[mat-card-avatar] {
24+
background-color: var(--mat-sys-primary);
25+
color: var(--mat-sys-on-primary);
26+
display: flex;
27+
align-items: center;
28+
justify-content: center;
29+
border-radius: 50%;
30+
width: 40px;
31+
height: 40px;
32+
}
33+
34+
mat-card-title {
35+
color: var(--mat-sys-on-surface);
36+
font: var(--mat-sys-title-large);
37+
margin: 0;
38+
}
39+
40+
mat-card-subtitle {
41+
color: var(--mat-sys-on-surface-variant);
42+
font: var(--mat-sys-body-medium);
43+
}
44+
45+
mat-card-content {
46+
padding: 0 16px 16px;
47+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<mat-card appearance="outlined" class="widget-card">
2+
@if (loading()) {
3+
<div class="loading-overlay" aria-live="polite">
4+
<mat-progress-spinner
5+
mode="indeterminate"
6+
diameter="48"
7+
strokeWidth="4"
8+
[attr.aria-label]="'Loading ' + title()"
9+
></mat-progress-spinner>
10+
</div>
11+
}
12+
13+
@if ((title() || subtitle() || icon()) && !loading()) {
14+
<mat-card-header>
15+
@if (icon()) {
16+
<app-flex align="center" justify="center">
17+
<mat-icon mat-card-avatar aria-hidden="true">{{ icon() }}</mat-icon>
18+
</app-flex>
19+
}
20+
<app-flex justify="space-around" align="center" style="width: 100%">
21+
<app-flex gap="4px" [vertical]="true">
22+
@if (title()) {
23+
<mat-card-title>{{ title() }}</mat-card-title>
24+
}
25+
@if (subtitle()) {
26+
<mat-card-subtitle>{{ subtitle() }}</mat-card-subtitle>
27+
}
28+
</app-flex>
29+
</app-flex>
30+
</mat-card-header>
31+
32+
<mat-card-content>
33+
<ng-content></ng-content>
34+
</mat-card-content>
35+
}
36+
</mat-card>
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
2+
import {
3+
MatCard,
4+
MatCardContent,
5+
MatCardHeader,
6+
MatCardSubtitle,
7+
MatCardTitle,
8+
} from '@angular/material/card';
9+
import { MatIcon } from '@angular/material/icon';
10+
import { MatProgressSpinner } from '@angular/material/progress-spinner';
11+
import { FlexComponent } from '../../components/ui-library/flex-component/flex-component';
12+
13+
@Component({
14+
selector: 'app-widget-wrapper',
15+
imports: [
16+
MatCard,
17+
MatCardHeader,
18+
MatCardTitle,
19+
MatCardSubtitle,
20+
MatIcon,
21+
MatCardContent,
22+
MatProgressSpinner,
23+
FlexComponent,
24+
],
25+
templateUrl: './widget-wrapper.component.html',
26+
styleUrl: './widget-wrapper.component.css',
27+
changeDetection: ChangeDetectionStrategy.OnPush,
28+
})
29+
export class WidgetWrapperComponent {
30+
readonly title = input<string>('');
31+
readonly subtitle = input<string>('');
32+
readonly icon = input<string>('');
33+
readonly loading = input<boolean>(false);
34+
}

0 commit comments

Comments
 (0)