From 2c8a858e42b4d60d44e93fe1c42743ad70556a79 Mon Sep 17 00:00:00 2001 From: Yip Shing him Date: Tue, 23 Jun 2026 19:07:46 +0100 Subject: [PATCH] feat: add budget page --- .../budget/budget-page.component.spec.ts | 117 ++++++++++++++++++ .../portfolio/budget/budget-page.component.ts | 97 ++++++++++++++- .../pages/portfolio/budget/budget-page.html | 117 ++++++++++++++++++ .../pages/portfolio/budget/budget-page.scss | 13 ++ 4 files changed, 341 insertions(+), 3 deletions(-) create mode 100644 apps/client/src/app/pages/portfolio/budget/budget-page.component.spec.ts create mode 100644 apps/client/src/app/pages/portfolio/budget/budget-page.html create mode 100644 apps/client/src/app/pages/portfolio/budget/budget-page.scss diff --git a/apps/client/src/app/pages/portfolio/budget/budget-page.component.spec.ts b/apps/client/src/app/pages/portfolio/budget/budget-page.component.spec.ts new file mode 100644 index 000000000..40b52d799 --- /dev/null +++ b/apps/client/src/app/pages/portfolio/budget/budget-page.component.spec.ts @@ -0,0 +1,117 @@ +import { DataService } from '@ghostfolio/ui/services'; + +import { provideHttpClient } from '@angular/common/http'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { of } from 'rxjs'; + +(global as any).$localize = ( + messageParts: TemplateStringsArray, + ...expressions: any[] +) => { + return String.raw({ raw: messageParts }, ...expressions); +}; + +jest.mock('@angular/localize', () => { + return {}; +}); + +jest.mock('@ghostfolio/client/services/user/user.service', () => { + return { + UserService: class UserService {} + }; +}); + +jest.mock('@ghostfolio/ui/value', () => { + const { Component, Input } = require('@angular/core'); + + @Component({ + selector: 'gf-value', + template: '{{ value }}' + }) + class GfValueComponent { + @Input() public isCurrency = false; + @Input() public locale: string; + @Input() public unit: string; + @Input() public value: number; + } + + return { GfValueComponent }; +}); + +const { + UserService +} = require('@ghostfolio/client/services/user/user.service'); +const { GfBudgetPageComponent } = require('./budget-page.component'); + +describe('GfBudgetPageComponent', () => { + let dataService: jest.Mocked>; + let fixture: ComponentFixture; + + beforeEach(async () => { + dataService = { + fetchBudgets: jest.fn().mockReturnValue( + of({ + budgets: [ + { + amount: 500, + category: { + id: 'food', + name: 'Food' + }, + categoryId: 'food', + createdAt: new Date('2026-06-01'), + currency: 'USD', + id: 'budget-1', + month: '2026-06', + remaining: 125, + spent: 375, + updatedAt: new Date('2026-06-01') + } + ], + totalBudgeted: 500, + totalRemaining: 125, + totalSpent: 375 + }) + ) + }; + + await TestBed.configureTestingModule({ + imports: [GfBudgetPageComponent, NoopAnimationsModule], + providers: [ + provideHttpClient(), + { + provide: DataService, + useValue: dataService + }, + { + provide: UserService, + useValue: { + stateChanged: of({ + user: { + permissions: [], + settings: { + baseCurrency: 'USD', + locale: 'en-US' + } + } + }) + } + } + ] + }).compileComponents(); + + fixture = TestBed.createComponent(GfBudgetPageComponent); + fixture.autoDetectChanges(); + }); + + it('loads and renders budgets for the selected month', async () => { + await fixture.whenStable(); + + expect(dataService.fetchBudgets).toHaveBeenCalledWith({ + month: expect.stringMatching(/^\d{4}-\d{2}$/) + }); + expect(fixture.nativeElement.textContent).toContain('Budget'); + expect(fixture.nativeElement.textContent).toContain('Food'); + }); +}); diff --git a/apps/client/src/app/pages/portfolio/budget/budget-page.component.ts b/apps/client/src/app/pages/portfolio/budget/budget-page.component.ts index 9fa53d6fc..4e38c6b02 100644 --- a/apps/client/src/app/pages/portfolio/budget/budget-page.component.ts +++ b/apps/client/src/app/pages/portfolio/budget/budget-page.component.ts @@ -1,8 +1,99 @@ -import { Component } from '@angular/core'; +import { UserService } from '@ghostfolio/client/services/user/user.service'; +import { BudgetResponse, User } from '@ghostfolio/common/interfaces'; +import { DataService } from '@ghostfolio/ui/services'; +import { GfValueComponent } from '@ghostfolio/ui/value'; + +import { CommonModule } from '@angular/common'; +import { + ChangeDetectorRef, + Component, + DestroyRef, + OnInit +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; +import { MatTableDataSource, MatTableModule } from '@angular/material/table'; +import { format } from 'date-fns'; @Component({ host: { class: 'page' }, + imports: [ + CommonModule, + GfValueComponent, + MatProgressBarModule, + MatTableModule, + ReactiveFormsModule + ], selector: 'gf-budget-page', - template: '' + styleUrls: ['./budget-page.scss'], + templateUrl: './budget-page.html' }) -export class GfBudgetPageComponent {} +export class GfBudgetPageComponent implements OnInit { + public dataSource = new MatTableDataSource([]); + public displayedColumns = [ + 'category', + 'amount', + 'spent', + 'remaining', + 'progress' + ]; + public isLoading = true; + public monthControl = new FormControl(format(new Date(), 'yyyy-MM'), { + nonNullable: true + }); + public totalBudgeted = 0; + public totalRemaining = 0; + public totalSpent = 0; + public user: User; + + public constructor( + private changeDetectorRef: ChangeDetectorRef, + private dataService: DataService, + private destroyRef: DestroyRef, + private userService: UserService + ) {} + + public ngOnInit() { + this.userService.stateChanged + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((state) => { + if (state?.user) { + this.user = state.user; + this.changeDetectorRef.markForCheck(); + } + }); + + this.monthControl.valueChanges + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.fetchBudgets(); + }); + + this.fetchBudgets(); + } + + public fetchBudgets() { + this.isLoading = true; + + this.dataService + .fetchBudgets({ month: this.monthControl.value }) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(({ budgets, totalBudgeted, totalRemaining, totalSpent }) => { + this.dataSource = new MatTableDataSource(budgets); + this.totalBudgeted = totalBudgeted; + this.totalRemaining = totalRemaining; + this.totalSpent = totalSpent; + this.isLoading = false; + this.changeDetectorRef.markForCheck(); + }); + } + + public getProgress({ amount, spent }: BudgetResponse) { + if (amount <= 0) { + return 0; + } + + return Math.min((spent / amount) * 100, 100); + } +} diff --git a/apps/client/src/app/pages/portfolio/budget/budget-page.html b/apps/client/src/app/pages/portfolio/budget/budget-page.html new file mode 100644 index 000000000..5af1afeff --- /dev/null +++ b/apps/client/src/app/pages/portfolio/budget/budget-page.html @@ -0,0 +1,117 @@ +
+
+
+

Budget

+ +
+ + +
+ +
+
+
Budgeted
+ +
+
+
Spent
+ +
+
+
Remaining
+ +
+
+ + @if (dataSource.data.length > 0) { + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Category + {{ budget.category?.name ?? budget.categoryId }} + + Budget + + + + Spent + + + + Remaining + + + Progress + +
+ } @else if (!isLoading) { +
+ No budgets have been created for this month. +
+ } +
+
+
diff --git a/apps/client/src/app/pages/portfolio/budget/budget-page.scss b/apps/client/src/app/pages/portfolio/budget/budget-page.scss new file mode 100644 index 000000000..25f57162f --- /dev/null +++ b/apps/client/src/app/pages/portfolio/budget/budget-page.scss @@ -0,0 +1,13 @@ +:host { + display: block; +} + +.budget-month-input { + max-width: 11rem; +} + +.budget-summary { + display: grid; + gap: 1rem; + grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); +}