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({ |
|||
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); |
|||
} |
|||
} |
|||
|
|||
@ -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