mirror of https://github.com/ghostfolio/ghostfolio
4 changed files with 341 additions and 3 deletions
@ -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'); |
||||
|
}); |
||||
|
}); |
||||
@ -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({ |
@Component({ |
||||
host: { class: 'page' }, |
host: { class: 'page' }, |
||||
|
imports: [ |
||||
|
CommonModule, |
||||
|
GfValueComponent, |
||||
|
MatProgressBarModule, |
||||
|
MatTableModule, |
||||
|
ReactiveFormsModule |
||||
|
], |
||||
selector: 'gf-budget-page', |
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); |
||||
|
} |
||||
|
} |
||||
|
|||||
@ -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> |
||||
@ -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…
Reference in new issue