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 index 40b52d799..be6472c48 100644 --- 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 @@ -2,6 +2,7 @@ import { DataService } from '@ghostfolio/ui/services'; import { provideHttpClient } from '@angular/common/http'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatDialog } from '@angular/material/dialog'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { of } from 'rxjs'; @@ -45,11 +46,15 @@ const { const { GfBudgetPageComponent } = require('./budget-page.component'); describe('GfBudgetPageComponent', () => { - let dataService: jest.Mocked>; + let dataService: jest.Mocked< + Pick + >; + let dialog: jest.Mocked>; let fixture: ComponentFixture; beforeEach(async () => { dataService = { + deleteBudget: jest.fn().mockReturnValue(of(undefined)), fetchBudgets: jest.fn().mockReturnValue( of({ budgets: [ @@ -75,6 +80,11 @@ describe('GfBudgetPageComponent', () => { }) ) }; + dialog = { + open: jest.fn().mockReturnValue({ + afterClosed: () => of({ refresh: true }) + }) + }; await TestBed.configureTestingModule({ imports: [GfBudgetPageComponent, NoopAnimationsModule], @@ -84,6 +94,10 @@ describe('GfBudgetPageComponent', () => { provide: DataService, useValue: dataService }, + { + provide: MatDialog, + useValue: dialog + }, { provide: UserService, useValue: { @@ -114,4 +128,24 @@ describe('GfBudgetPageComponent', () => { expect(fixture.nativeElement.textContent).toContain('Budget'); expect(fixture.nativeElement.textContent).toContain('Food'); }); + + it('reloads budgets after creating a budget', async () => { + await fixture.whenStable(); + + fixture.componentInstance.onCreateBudget(); + await fixture.whenStable(); + + expect(dialog.open).toHaveBeenCalled(); + expect(dataService.fetchBudgets).toHaveBeenCalledTimes(2); + }); + + it('deletes a budget and reloads the month', async () => { + await fixture.whenStable(); + + fixture.componentInstance.onDeleteBudget('budget-1'); + await fixture.whenStable(); + + expect(dataService.deleteBudget).toHaveBeenCalledWith('budget-1'); + expect(dataService.fetchBudgets).toHaveBeenCalledTimes(2); + }); }); 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 4e38c6b02..b46a64255 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 @@ -12,15 +12,22 @@ import { } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatDialog } from '@angular/material/dialog'; +import { MatIconModule } from '@angular/material/icon'; import { MatProgressBarModule } from '@angular/material/progress-bar'; import { MatTableDataSource, MatTableModule } from '@angular/material/table'; import { format } from 'date-fns'; +import { GfCreateOrUpdateBudgetDialogComponent } from './create-or-update-budget-dialog/create-or-update-budget-dialog.component'; + @Component({ host: { class: 'page' }, imports: [ CommonModule, GfValueComponent, + MatButtonModule, + MatIconModule, MatProgressBarModule, MatTableModule, ReactiveFormsModule @@ -36,7 +43,8 @@ export class GfBudgetPageComponent implements OnInit { 'amount', 'spent', 'remaining', - 'progress' + 'progress', + 'actions' ]; public isLoading = true; public monthControl = new FormControl(format(new Date(), 'yyyy-MM'), { @@ -51,6 +59,7 @@ export class GfBudgetPageComponent implements OnInit { private changeDetectorRef: ChangeDetectorRef, private dataService: DataService, private destroyRef: DestroyRef, + private dialog: MatDialog, private userService: UserService ) {} @@ -96,4 +105,41 @@ export class GfBudgetPageComponent implements OnInit { return Math.min((spent / amount) * 100, 100); } + + public onCreateBudget() { + this.openBudgetDialog({ + month: this.monthControl.value + }); + } + + public onDeleteBudget(id: string) { + this.dataService + .deleteBudget(id) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.fetchBudgets(); + }); + } + + public onUpdateBudget(budget: BudgetResponse) { + this.openBudgetDialog({ + budget, + month: this.monthControl.value + }); + } + + private openBudgetDialog(data: { budget?: BudgetResponse; month: string }) { + this.dialog + .open(GfCreateOrUpdateBudgetDialogComponent, { + data, + width: '32rem' + }) + .afterClosed() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((result) => { + if (result?.refresh) { + this.fetchBudgets(); + } + }); + } } diff --git a/apps/client/src/app/pages/portfolio/budget/budget-page.html b/apps/client/src/app/pages/portfolio/budget/budget-page.html index 5af1afeff..4e303f5b1 100644 --- a/apps/client/src/app/pages/portfolio/budget/budget-page.html +++ b/apps/client/src/app/pages/portfolio/budget/budget-page.html @@ -104,6 +104,28 @@ + + + + + + + + @@ -112,6 +134,17 @@ No budgets have been created for this month. } + + diff --git a/apps/client/src/app/pages/portfolio/budget/create-or-update-budget-dialog/create-or-update-budget-dialog.component.spec.ts b/apps/client/src/app/pages/portfolio/budget/create-or-update-budget-dialog/create-or-update-budget-dialog.component.spec.ts new file mode 100644 index 000000000..cec54d4f1 --- /dev/null +++ b/apps/client/src/app/pages/portfolio/budget/create-or-update-budget-dialog/create-or-update-budget-dialog.component.spec.ts @@ -0,0 +1,90 @@ +import { DataService } from '@ghostfolio/ui/services'; + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { of } from 'rxjs'; + +import { GfCreateOrUpdateBudgetDialogComponent } from './create-or-update-budget-dialog.component'; + +describe('GfCreateOrUpdateBudgetDialogComponent', () => { + let component: GfCreateOrUpdateBudgetDialogComponent; + let dataService: jest.Mocked< + Pick + >; + let dialogRef: jest.Mocked< + Pick, 'close'> + >; + let fixture: ComponentFixture; + + beforeEach(async () => { + dataService = { + createBudget: jest.fn().mockReturnValue( + of({ + amount: 500, + categoryId: 'food', + id: 'budget-1', + month: '2026-06', + remaining: 500, + spent: 0 + }) + ), + updateBudget: jest.fn().mockReturnValue( + of({ + amount: 650, + categoryId: 'food', + id: 'budget-1', + month: '2026-06', + remaining: 650, + spent: 0 + }) + ) + }; + dialogRef = { + close: jest.fn() + }; + + await TestBed.configureTestingModule({ + imports: [GfCreateOrUpdateBudgetDialogComponent, NoopAnimationsModule], + providers: [ + { + provide: DataService, + useValue: dataService + }, + { + provide: MatDialogRef, + useValue: dialogRef + }, + { + provide: MAT_DIALOG_DATA, + useValue: { + budget: undefined, + month: '2026-06' + } + } + ] + }).compileComponents(); + + fixture = TestBed.createComponent(GfCreateOrUpdateBudgetDialogComponent); + component = fixture.componentInstance; + fixture.autoDetectChanges(); + }); + + it('creates a budget and closes with refresh', async () => { + component.budgetForm.setValue({ + amount: 500, + categoryId: 'food', + month: '2026-06' + }); + + component.onSubmit(); + await fixture.whenStable(); + + expect(dataService.createBudget).toHaveBeenCalledWith({ + amount: 500, + categoryId: 'food', + month: '2026-06' + }); + expect(dialogRef.close).toHaveBeenCalledWith({ refresh: true }); + }); +}); diff --git a/apps/client/src/app/pages/portfolio/budget/create-or-update-budget-dialog/create-or-update-budget-dialog.component.ts b/apps/client/src/app/pages/portfolio/budget/create-or-update-budget-dialog/create-or-update-budget-dialog.component.ts new file mode 100644 index 000000000..b583f0286 --- /dev/null +++ b/apps/client/src/app/pages/portfolio/budget/create-or-update-budget-dialog/create-or-update-budget-dialog.component.ts @@ -0,0 +1,93 @@ +import { CreateBudgetDto, UpdateBudgetDto } from '@ghostfolio/common/dtos'; +import { BudgetResponse } from '@ghostfolio/common/interfaces'; +import { DataService } from '@ghostfolio/ui/services'; + +import { CommonModule } from '@angular/common'; +import { Component, Inject } from '@angular/core'; +import { + FormControl, + FormGroup, + ReactiveFormsModule, + Validators +} from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { + MAT_DIALOG_DATA, + MatDialogModule, + MatDialogRef +} from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; + +export interface CreateOrUpdateBudgetDialogData { + budget?: BudgetResponse; + month: string; +} + +@Component({ + imports: [ + CommonModule, + MatButtonModule, + MatDialogModule, + MatFormFieldModule, + MatInputModule, + ReactiveFormsModule + ], + selector: 'gf-create-or-update-budget-dialog', + styleUrls: ['./create-or-update-budget-dialog.scss'], + templateUrl: './create-or-update-budget-dialog.html' +}) +export class GfCreateOrUpdateBudgetDialogComponent { + public budgetForm = new FormGroup({ + amount: new FormControl(this.data.budget?.amount ?? 0, { + nonNullable: true, + validators: [Validators.required, Validators.min(0.01)] + }), + categoryId: new FormControl(this.data.budget?.categoryId ?? '', { + nonNullable: true, + validators: [Validators.required] + }), + month: new FormControl(this.data.budget?.month ?? this.data.month, { + nonNullable: true, + validators: [Validators.required] + }) + }); + + public constructor( + @Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateBudgetDialogData, + private dataService: DataService, + private dialogRef: MatDialogRef + ) {} + + public onSubmit() { + if (this.budgetForm.invalid) { + this.budgetForm.markAllAsTouched(); + return; + } + + const budget = this.budgetForm.getRawValue(); + + if (this.data.budget) { + this.updateBudget(budget); + } else { + this.createBudget(budget); + } + } + + private createBudget(budget: CreateBudgetDto) { + this.dataService.createBudget(budget).subscribe(() => { + this.dialogRef.close({ refresh: true }); + }); + } + + private updateBudget(budget: UpdateBudgetDto) { + this.dataService + .updateBudget({ + budget, + id: this.data.budget.id + }) + .subscribe(() => { + this.dialogRef.close({ refresh: true }); + }); + } +} diff --git a/apps/client/src/app/pages/portfolio/budget/create-or-update-budget-dialog/create-or-update-budget-dialog.html b/apps/client/src/app/pages/portfolio/budget/create-or-update-budget-dialog/create-or-update-budget-dialog.html new file mode 100644 index 000000000..c525b0fc4 --- /dev/null +++ b/apps/client/src/app/pages/portfolio/budget/create-or-update-budget-dialog/create-or-update-budget-dialog.html @@ -0,0 +1,45 @@ +

+ @if (data.budget) { + Edit budget + } @else { + Create budget + } +

+ +
+ + + Month + + + + + Category id + + + + + Amount + + + + + + + + +
diff --git a/apps/client/src/app/pages/portfolio/budget/create-or-update-budget-dialog/create-or-update-budget-dialog.scss b/apps/client/src/app/pages/portfolio/budget/create-or-update-budget-dialog/create-or-update-budget-dialog.scss new file mode 100644 index 000000000..fb8c36d47 --- /dev/null +++ b/apps/client/src/app/pages/portfolio/budget/create-or-update-budget-dialog/create-or-update-budget-dialog.scss @@ -0,0 +1,5 @@ +.budget-dialog-content { + display: grid; + gap: 0.5rem; + min-width: min(26rem, 80vw); +}