Browse Source

feat: add budget page

pull/7140/head
Yip Shing him 3 weeks ago
parent
commit
2c8a858e42
  1. 117
      apps/client/src/app/pages/portfolio/budget/budget-page.component.spec.ts
  2. 97
      apps/client/src/app/pages/portfolio/budget/budget-page.component.ts
  3. 117
      apps/client/src/app/pages/portfolio/budget/budget-page.html
  4. 13
      apps/client/src/app/pages/portfolio/budget/budget-page.scss

117
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<Pick<DataService, 'fetchBudgets'>>;
let fixture: ComponentFixture<GfBudgetPageComponent>;
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');
});
});

97
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<BudgetResponse>([]);
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);
}
}

117
apps/client/src/app/pages/portfolio/budget/budget-page.html

@ -0,0 +1,117 @@
<div class="container">
<div class="row">
<div class="col">
<h1 class="d-none d-sm-block h3 mb-3 text-center" i18n>Budget</h1>
<div class="align-items-center d-flex flex-wrap mb-3">
<label class="mb-2 mr-2" for="budget-month" i18n>Month</label>
<input
class="budget-month-input form-control mb-2"
id="budget-month"
type="month"
[formControl]="monthControl"
/>
</div>
<div class="budget-summary mb-3">
<div>
<div class="text-muted" i18n>Budgeted</div>
<gf-value
[isCurrency]="true"
[locale]="user?.settings?.locale"
[unit]="user?.settings?.baseCurrency"
[value]="totalBudgeted"
/>
</div>
<div>
<div class="text-muted" i18n>Spent</div>
<gf-value
[isCurrency]="true"
[locale]="user?.settings?.locale"
[unit]="user?.settings?.baseCurrency"
[value]="totalSpent"
/>
</div>
<div>
<div class="text-muted" i18n>Remaining</div>
<gf-value
[isCurrency]="true"
[locale]="user?.settings?.locale"
[unit]="user?.settings?.baseCurrency"
[value]="totalRemaining"
/>
</div>
</div>
@if (dataSource.data.length > 0) {
<table class="gf-table w-100" mat-table [dataSource]="dataSource">
<ng-container matColumnDef="category">
<th *matHeaderCellDef i18n mat-header-cell>Category</th>
<td *matCellDef="let budget" mat-cell>
{{ budget.category?.name ?? budget.categoryId }}
</td>
</ng-container>
<ng-container matColumnDef="amount">
<th *matHeaderCellDef class="text-right" i18n mat-header-cell>
Budget
</th>
<td *matCellDef="let budget" class="text-right" mat-cell>
<gf-value
[isCurrency]="true"
[locale]="user?.settings?.locale"
[unit]="user?.settings?.baseCurrency"
[value]="budget.amount"
/>
</td>
</ng-container>
<ng-container matColumnDef="spent">
<th *matHeaderCellDef class="text-right" i18n mat-header-cell>
Spent
</th>
<td *matCellDef="let budget" class="text-right" mat-cell>
<gf-value
[isCurrency]="true"
[locale]="user?.settings?.locale"
[unit]="user?.settings?.baseCurrency"
[value]="budget.spent"
/>
</td>
</ng-container>
<ng-container matColumnDef="remaining">
<th *matHeaderCellDef class="text-right" i18n mat-header-cell>
Remaining
</th>
<td *matCellDef="let budget" class="text-right" mat-cell>
<gf-value
[isCurrency]="true"
[locale]="user?.settings?.locale"
[unit]="user?.settings?.baseCurrency"
[value]="budget.remaining"
/>
</td>
</ng-container>
<ng-container matColumnDef="progress">
<th *matHeaderCellDef i18n mat-header-cell>Progress</th>
<td *matCellDef="let budget" mat-cell>
<mat-progress-bar
mode="determinate"
[value]="getProgress(budget)"
/>
</td>
</ng-container>
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
</table>
} @else if (!isLoading) {
<div class="py-5 text-center text-muted" i18n>
No budgets have been created for this month.
</div>
}
</div>
</div>
</div>

13
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));
}
Loading…
Cancel
Save