38 KiB
Budget UI Feature Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add a native Portfolio Budget tab that lets authenticated users view, create, edit, and delete monthly category budgets backed by the existing /api/v1/budgets endpoints.
Architecture: Budget UI lives under the existing lazy-loaded Portfolio route so it appears beside Analysis, Activities, Allocations, FIRE, and X-ray. Shared route metadata stays in libs/common, HTTP methods stay in DataService, and the page owns presentation state plus Material dialog orchestration. The first UI pass consumes category ids exposed by the backend contract and displays spent/progress values returned by the budget API.
Tech Stack: Angular 21 standalone components, Angular Router lazy child routes, Angular Material table/dialog/form controls, RxJS, Jest/Nx tests, Ghostfolio DataService, Ghostfolio GfPageTabsComponent, Ghostfolio gf-value.
File Structure
- Modify:
libs/common/src/lib/routes/routes.tsaddsportfolio.subRoutes.budget. - Create:
libs/common/src/lib/routes/routes.spec.tsverifies the Budget route metadata. - Modify:
apps/client/src/app/pages/portfolio/portfolio-page.component.tsadds the Budget tab and registers the wallet icon. - Modify:
apps/client/src/app/pages/portfolio/portfolio-page.routes.tsadds the lazy Budget child route. - Create:
apps/client/src/app/pages/portfolio/budget/budget-page.routes.tsexposes the Budget page as the child route component. - Create:
apps/client/src/app/pages/portfolio/budget/budget-page.component.tsfetches budgets and opens create/edit dialogs. - Create:
apps/client/src/app/pages/portfolio/budget/budget-page.htmlrenders month selector, summary metrics, table, empty state, and floating action button. - Create:
apps/client/src/app/pages/portfolio/budget/budget-page.scsskeeps spacing/table/progress styling local to the Budget page. - Create:
apps/client/src/app/pages/portfolio/budget/budget-page.component.spec.tscovers data loading and dialog-triggered refresh. - Create:
apps/client/src/app/pages/portfolio/budget/create-or-update-budget-dialog/create-or-update-budget-dialog.component.tsprovides the create/edit form. - Create:
apps/client/src/app/pages/portfolio/budget/create-or-update-budget-dialog/create-or-update-budget-dialog.htmlrenders form fields and dialog actions. - Create:
apps/client/src/app/pages/portfolio/budget/create-or-update-budget-dialog/create-or-update-budget-dialog.scsskeeps dialog layout compact. - Create:
apps/client/src/app/pages/portfolio/budget/create-or-update-budget-dialog/create-or-update-budget-dialog.component.spec.tscovers create and update payloads. - Modify:
libs/ui/src/lib/services/data.service.tsadds Budget HTTP methods. - Create:
libs/ui/src/lib/services/data.service.spec.tsverifies the Budget HTTP URLs and payloads.
Assumptions
- The backend foundation from commit
feat: add budget foundationexists oncodex/budget-feature. - Budget responses use the shared
BudgetResponseandBudgetsResponseinterfaces withamount,categoryId,id,month,spent, andremaining. - Create/update DTOs are exported from
@ghostfolio/common/dtos. - The first UI version accepts a category id text value until the Expenses/category management UI lands. This keeps Budget UI independently shippable.
Task 1: Add Budget Route Metadata and Portfolio Tab
Files:
-
Create:
libs/common/src/lib/routes/routes.spec.ts -
Modify:
libs/common/src/lib/routes/routes.ts -
Modify:
apps/client/src/app/pages/portfolio/portfolio-page.component.ts -
Modify:
apps/client/src/app/pages/portfolio/portfolio-page.routes.ts -
Create:
apps/client/src/app/pages/portfolio/budget/budget-page.routes.ts -
Step 1: Write the failing route metadata test
Create libs/common/src/lib/routes/routes.spec.ts:
import { internalRoutes } from './routes';
describe('internalRoutes', () => {
it('defines the portfolio budget route', () => {
expect(internalRoutes.portfolio.subRoutes.budget).toEqual({
path: 'budget',
routerLink: ['/portfolio', 'budget'],
title: 'Budget'
});
});
});
- Step 2: Run the route metadata test and verify it fails
Run:
./node_modules/.bin/nx test common --testFile=routes.spec.ts
Expected: FAIL because internalRoutes.portfolio.subRoutes.budget is not defined.
- Step 3: Add the Budget route metadata
In libs/common/src/lib/routes/routes.ts, add this entry inside internalRoutes.portfolio.subRoutes, after analysis and before fire:
budget: {
path: 'budget',
routerLink: ['/portfolio', 'budget'],
title: $localize`Budget`
},
- Step 4: Add the Portfolio tab
In apps/client/src/app/pages/portfolio/portfolio-page.component.ts, add walletOutline to the icon imports:
import {
analyticsOutline,
calculatorOutline,
pieChartOutline,
scanOutline,
swapVerticalOutline,
walletOutline
} from 'ionicons/icons';
Add this tab after Activities:
{
iconName: 'wallet-outline',
label: internalRoutes.portfolio.subRoutes.budget.title,
routerLink: internalRoutes.portfolio.subRoutes.budget.routerLink
},
Register the icon:
addIcons({
analyticsOutline,
calculatorOutline,
pieChartOutline,
scanOutline,
swapVerticalOutline,
walletOutline
});
- Step 5: Add the lazy child route
In apps/client/src/app/pages/portfolio/portfolio-page.routes.ts, add this child route after Activities:
{
path: internalRoutes.portfolio.subRoutes.budget.path,
loadChildren: () =>
import('./budget/budget-page.routes').then((m) => m.routes)
},
Create apps/client/src/app/pages/portfolio/budget/budget-page.routes.ts:
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { Routes } from '@angular/router';
import { GfBudgetPageComponent } from './budget-page.component';
export const routes: Routes = [
{
canActivate: [AuthGuard],
component: GfBudgetPageComponent,
path: '',
title: internalRoutes.portfolio.subRoutes.budget.title
}
];
- Step 6: Run the route metadata test and verify it passes
Run:
./node_modules/.bin/nx test common --testFile=routes.spec.ts
Expected: PASS.
- Step 7: Commit
git add libs/common/src/lib/routes/routes.ts libs/common/src/lib/routes/routes.spec.ts apps/client/src/app/pages/portfolio/portfolio-page.component.ts apps/client/src/app/pages/portfolio/portfolio-page.routes.ts apps/client/src/app/pages/portfolio/budget/budget-page.routes.ts
git commit -m "feat: add budget portfolio route"
Task 2: Add Budget HTTP Methods to DataService
Files:
-
Create:
libs/ui/src/lib/services/data.service.spec.ts -
Modify:
libs/ui/src/lib/services/data.service.ts -
Step 1: Write the failing DataService test
Create libs/ui/src/lib/services/data.service.spec.ts:
import { CreateBudgetDto, UpdateBudgetDto } from '@ghostfolio/common/dtos';
import {
provideHttpClient,
withInterceptorsFromDi
} from '@angular/common/http';
import {
HttpTestingController,
provideHttpClientTesting
} from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { DataService } from './data.service';
describe('DataService budget methods', () => {
let dataService: DataService;
let httpTestingController: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
DataService,
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting()
]
});
dataService = TestBed.inject(DataService);
httpTestingController = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpTestingController.verify();
});
it('fetches budgets for a month', () => {
dataService.fetchBudgets({ month: '2026-06' }).subscribe();
const request = httpTestingController.expectOne(
'/api/v1/budgets?month=2026-06'
);
expect(request.request.method).toBe('GET');
});
it('creates a budget', () => {
const budget: CreateBudgetDto = {
amount: 500,
categoryId: 'category-1',
month: '2026-06'
};
dataService.createBudget(budget).subscribe();
const request = httpTestingController.expectOne('/api/v1/budgets');
expect(request.request.method).toBe('POST');
expect(request.request.body).toEqual(budget);
});
it('updates a budget', () => {
const budget: UpdateBudgetDto = {
amount: 650,
categoryId: 'category-1',
month: '2026-06'
};
dataService.updateBudget({ budget, id: 'budget-1' }).subscribe();
const request = httpTestingController.expectOne('/api/v1/budgets/budget-1');
expect(request.request.method).toBe('PUT');
expect(request.request.body).toEqual(budget);
});
it('deletes a budget', () => {
dataService.deleteBudget('budget-1').subscribe();
const request = httpTestingController.expectOne('/api/v1/budgets/budget-1');
expect(request.request.method).toBe('DELETE');
});
});
- Step 2: Run the DataService test and verify it fails
Run:
./node_modules/.bin/nx test ui --testFile=data.service.spec.ts
Expected: FAIL because fetchBudgets, createBudget, updateBudget, and deleteBudget do not exist.
- Step 3: Add imports to DataService
In libs/ui/src/lib/services/data.service.ts, extend the DTO import:
CreateBudgetDto,
CreateAccessDto,
and:
UpdateBudgetDto,
UpdateAccessDto,
Extend the interface import:
BenchmarkResponse,
BudgetResponse,
BudgetsResponse,
- Step 4: Add Budget HTTP methods to DataService
Add these methods near the other create/fetch/update/delete methods in libs/ui/src/lib/services/data.service.ts:
public createBudget(budget: CreateBudgetDto) {
return this.http.post<BudgetResponse>('/api/v1/budgets', budget);
}
public deleteBudget(id: string) {
return this.http.delete<void>(`/api/v1/budgets/${id}`);
}
public fetchBudgets({ month }: { month?: string } = {}) {
let params = new HttpParams();
if (month) {
params = params.append('month', month);
}
return this.http.get<BudgetsResponse>('/api/v1/budgets', { params });
}
public updateBudget({
budget,
id
}: {
budget: UpdateBudgetDto;
id: string;
}) {
return this.http.put<BudgetResponse>(`/api/v1/budgets/${id}`, budget);
}
- Step 5: Run the DataService test and verify it passes
Run:
./node_modules/.bin/nx test ui --testFile=data.service.spec.ts
Expected: PASS.
- Step 6: Commit
git add libs/ui/src/lib/services/data.service.ts libs/ui/src/lib/services/data.service.spec.ts
git commit -m "feat: add budget data client"
Task 3: Create Budget Page Data Loading
Files:
-
Create:
apps/client/src/app/pages/portfolio/budget/budget-page.component.spec.ts -
Create:
apps/client/src/app/pages/portfolio/budget/budget-page.component.ts -
Create:
apps/client/src/app/pages/portfolio/budget/budget-page.html -
Create:
apps/client/src/app/pages/portfolio/budget/budget-page.scss -
Step 1: Write the failing page test
Create apps/client/src/app/pages/portfolio/budget/budget-page.component.spec.ts:
import { UserService } from '@ghostfolio/client/services/user/user.service';
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';
import { GfBudgetPageComponent } from './budget-page.component';
describe('GfBudgetPageComponent', () => {
let fixture: ComponentFixture<GfBudgetPageComponent>;
let dataService: jasmine.SpyObj<DataService>;
beforeEach(async () => {
dataService = jasmine.createSpyObj<DataService>('DataService', [
'fetchBudgets'
]);
dataService.fetchBudgets.and.returnValue(
of({
budgets: [
{
amount: 500,
categoryId: 'food',
id: 'budget-1',
month: '2026-06',
remaining: 125,
spent: 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);
});
it('loads and renders budgets for the selected month', async () => {
await fixture.whenStable();
expect(dataService.fetchBudgets).toHaveBeenCalledWith({
month: jasmine.stringMatching(/^\\d{4}-\\d{2}$/)
});
expect(fixture.nativeElement.textContent).toContain('food');
expect(fixture.nativeElement.textContent).toContain('Budget');
});
});
- Step 2: Run the page test and verify it fails
Run:
./node_modules/.bin/nx test client --testFile=budget-page.component.spec.ts
Expected: FAIL because GfBudgetPageComponent does not exist.
- Step 3: Create the Budget page component
Create apps/client/src/app/pages/portfolio/budget/budget-page.component.ts:
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 { MatButtonModule } from '@angular/material/button';
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';
@Component({
host: { class: 'page' },
imports: [
CommonModule,
GfValueComponent,
MatButtonModule,
MatIconModule,
MatProgressBarModule,
MatTableModule,
ReactiveFormsModule
],
selector: 'gf-budget-page',
styleUrls: ['./budget-page.scss'],
templateUrl: './budget-page.html'
})
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 }) => {
this.dataSource = new MatTableDataSource(budgets);
this.totalBudgeted = budgets.reduce((sum, budget) => {
return sum + budget.amount;
}, 0);
this.totalSpent = budgets.reduce((sum, budget) => {
return sum + budget.spent;
}, 0);
this.totalRemaining = budgets.reduce((sum, budget) => {
return sum + budget.remaining;
}, 0);
this.isLoading = false;
this.changeDetectorRef.markForCheck();
});
}
public getProgress({ amount, spent }: BudgetResponse) {
if (amount <= 0) {
return 0;
}
return Math.min((spent / amount) * 100, 100);
}
}
- Step 4: Create the Budget page template
Create apps/client/src/app/pages/portfolio/budget/budget-page.html:
<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="form-control budget-month-input 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.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>
- Step 5: Create the Budget page styles
Create apps/client/src/app/pages/portfolio/budget/budget-page.scss:
:host {
display: block;
}
.budget-month-input {
max-width: 11rem;
}
.budget-summary {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
}
- Step 6: Run the page test and verify it passes
Run:
./node_modules/.bin/nx test client --testFile=budget-page.component.spec.ts
Expected: PASS.
- Step 7: Commit
git add apps/client/src/app/pages/portfolio/budget/budget-page.component.ts apps/client/src/app/pages/portfolio/budget/budget-page.html apps/client/src/app/pages/portfolio/budget/budget-page.scss apps/client/src/app/pages/portfolio/budget/budget-page.component.spec.ts
git commit -m "feat: add budget page"
Task 4: Add Create and Edit Budget Dialog
Files:
-
Create:
apps/client/src/app/pages/portfolio/budget/create-or-update-budget-dialog/create-or-update-budget-dialog.component.spec.ts -
Create:
apps/client/src/app/pages/portfolio/budget/create-or-update-budget-dialog/create-or-update-budget-dialog.component.ts -
Create:
apps/client/src/app/pages/portfolio/budget/create-or-update-budget-dialog/create-or-update-budget-dialog.html -
Create:
apps/client/src/app/pages/portfolio/budget/create-or-update-budget-dialog/create-or-update-budget-dialog.scss -
Modify:
apps/client/src/app/pages/portfolio/budget/budget-page.component.ts -
Modify:
apps/client/src/app/pages/portfolio/budget/budget-page.html -
Step 1: Write the failing dialog test
Create apps/client/src/app/pages/portfolio/budget/create-or-update-budget-dialog/create-or-update-budget-dialog.component.spec.ts:
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 fixture: ComponentFixture<GfCreateOrUpdateBudgetDialogComponent>;
let dataService: jasmine.SpyObj<DataService>;
let dialogRef: jasmine.SpyObj<
MatDialogRef<GfCreateOrUpdateBudgetDialogComponent>
>;
beforeEach(async () => {
dataService = jasmine.createSpyObj<DataService>('DataService', [
'createBudget',
'updateBudget'
]);
dataService.createBudget.and.returnValue(
of({
amount: 500,
categoryId: 'food',
id: 'budget-1',
month: '2026-06',
remaining: 500,
spent: 0
})
);
dataService.updateBudget.and.returnValue(
of({
amount: 650,
categoryId: 'food',
id: 'budget-1',
month: '2026-06',
remaining: 650,
spent: 0
})
);
dialogRef = jasmine.createSpyObj<
MatDialogRef<GfCreateOrUpdateBudgetDialogComponent>
>('MatDialogRef', ['close']);
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;
});
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 });
});
});
- Step 2: Run the dialog test and verify it fails
Run:
./node_modules/.bin/nx test client --testFile=create-or-update-budget-dialog.component.spec.ts
Expected: FAIL because GfCreateOrUpdateBudgetDialogComponent does not exist.
- Step 3: Create the dialog component
Create apps/client/src/app/pages/portfolio/budget/create-or-update-budget-dialog/create-or-update-budget-dialog.component.ts:
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 {
MAT_DIALOG_DATA,
MatDialogModule,
MatDialogRef
} from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
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<number>(this.data.budget?.amount ?? 0, {
nonNullable: true,
validators: [Validators.required, Validators.min(0.01)]
}),
categoryId: new FormControl<string>(this.data.budget?.categoryId ?? '', {
nonNullable: true,
validators: [Validators.required]
}),
month: new FormControl<string>(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<GfCreateOrUpdateBudgetDialogComponent>
) {}
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 });
});
}
}
- Step 4: Create the dialog template
Create apps/client/src/app/pages/portfolio/budget/create-or-update-budget-dialog/create-or-update-budget-dialog.html:
<h2 mat-dialog-title>
@if (data.budget) {
<ng-container i18n>Edit budget</ng-container>
} @else {
<ng-container i18n>Create budget</ng-container>
}
</h2>
<form [formGroup]="budgetForm" (ngSubmit)="onSubmit()">
<mat-dialog-content class="budget-dialog-content">
<mat-form-field appearance="outline">
<mat-label i18n>Month</mat-label>
<input formControlName="month" matInput type="month" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label i18n>Category id</mat-label>
<input formControlName="categoryId" matInput />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label i18n>Amount</mat-label>
<input
formControlName="amount"
matInput
min="0.01"
step="0.01"
type="number"
/>
</mat-form-field>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button i18n mat-button mat-dialog-close type="button">Cancel</button>
<button
color="primary"
i18n
mat-flat-button
type="submit"
[disabled]="budgetForm.invalid"
>
Save
</button>
</mat-dialog-actions>
</form>
- Step 5: Create the dialog styles
Create apps/client/src/app/pages/portfolio/budget/create-or-update-budget-dialog/create-or-update-budget-dialog.scss:
.budget-dialog-content {
display: grid;
gap: 0.5rem;
min-width: min(26rem, 80vw);
}
- Step 6: Wire page actions to the dialog
In apps/client/src/app/pages/portfolio/budget/budget-page.component.ts, add these imports:
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { GfCreateOrUpdateBudgetDialogComponent } from './create-or-update-budget-dialog/create-or-update-budget-dialog.component';
Add MatDialogModule to the component imports.
Add actions to displayedColumns:
public displayedColumns = [
'category',
'amount',
'spent',
'remaining',
'progress',
'actions'
];
Inject the dialog:
private dialog: MatDialog,
Add these methods:
public onCreateBudget() {
this.openBudgetDialog({
month: this.monthControl.value
});
}
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();
}
});
}
In apps/client/src/app/pages/portfolio/budget/budget-page.html, add this table column before the row definitions:
<ng-container matColumnDef="actions">
<th *matHeaderCellDef class="text-right" mat-header-cell></th>
<td *matCellDef="let budget" class="text-right" mat-cell>
<button
mat-icon-button
type="button"
[attr.aria-label]="'Edit budget'"
(click)="onUpdateBudget(budget)"
>
<mat-icon>edit</mat-icon>
</button>
</td>
</ng-container>
Add this button after the table/empty state block:
<button
class="mt-3"
color="primary"
i18n
mat-flat-button
type="button"
(click)="onCreateBudget()"
>
Create budget
</button>
- Step 7: Run the dialog and page tests
Run:
./node_modules/.bin/nx test client --testFile=create-or-update-budget-dialog.component.spec.ts
./node_modules/.bin/nx test client --testFile=budget-page.component.spec.ts
Expected: both commands report PASS.
- Step 8: Commit
git add apps/client/src/app/pages/portfolio/budget
git commit -m "feat: add budget editor dialog"
Task 5: Add Delete Budget Action
Files:
-
Modify:
apps/client/src/app/pages/portfolio/budget/budget-page.component.spec.ts -
Modify:
apps/client/src/app/pages/portfolio/budget/budget-page.component.ts -
Modify:
apps/client/src/app/pages/portfolio/budget/budget-page.html -
Step 1: Extend the failing page test
In apps/client/src/app/pages/portfolio/budget/budget-page.component.spec.ts, add deleteBudget to the DataService spy and return value:
dataService = jasmine.createSpyObj<DataService>('DataService', [
'deleteBudget',
'fetchBudgets'
]);
dataService.deleteBudget.and.returnValue(of(undefined));
Add this test:
it('deletes a budget and reloads the month', async () => {
const component = fixture.componentInstance;
await fixture.whenStable();
component.onDeleteBudget('budget-1');
await fixture.whenStable();
expect(dataService.deleteBudget).toHaveBeenCalledWith('budget-1');
expect(dataService.fetchBudgets).toHaveBeenCalledTimes(2);
});
- Step 2: Run the page test and verify it fails
Run:
./node_modules/.bin/nx test client --testFile=budget-page.component.spec.ts
Expected: FAIL because onDeleteBudget does not exist.
- Step 3: Implement delete in the Budget page
In apps/client/src/app/pages/portfolio/budget/budget-page.component.ts, add:
public onDeleteBudget(id: string) {
this.dataService
.deleteBudget(id)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.fetchBudgets();
});
}
In the actions column in apps/client/src/app/pages/portfolio/budget/budget-page.html, add this button after the edit button:
<button
mat-icon-button
type="button"
[attr.aria-label]="'Delete budget'"
(click)="onDeleteBudget(budget.id)"
>
<mat-icon>delete</mat-icon>
</button>
- Step 4: Run the page test and verify it passes
Run:
./node_modules/.bin/nx test client --testFile=budget-page.component.spec.ts
Expected: PASS.
- Step 5: Commit
git add apps/client/src/app/pages/portfolio/budget/budget-page.component.ts apps/client/src/app/pages/portfolio/budget/budget-page.html apps/client/src/app/pages/portfolio/budget/budget-page.component.spec.ts
git commit -m "feat: add budget delete action"
Task 6: Build and Browser Verification
Files:
-
Modify only files reported by the verification commands.
-
Step 1: Format changed files
Run:
./node_modules/.bin/nx format:write --files=libs/common/src/lib/routes/routes.ts,libs/common/src/lib/routes/routes.spec.ts,libs/ui/src/lib/services/data.service.ts,libs/ui/src/lib/services/data.service.spec.ts,apps/client/src/app/pages/portfolio/portfolio-page.component.ts,apps/client/src/app/pages/portfolio/portfolio-page.routes.ts,apps/client/src/app/pages/portfolio/budget/budget-page.routes.ts,apps/client/src/app/pages/portfolio/budget/budget-page.component.ts,apps/client/src/app/pages/portfolio/budget/budget-page.html,apps/client/src/app/pages/portfolio/budget/budget-page.scss,apps/client/src/app/pages/portfolio/budget/budget-page.component.spec.ts,apps/client/src/app/pages/portfolio/budget/create-or-update-budget-dialog/create-or-update-budget-dialog.component.ts,apps/client/src/app/pages/portfolio/budget/create-or-update-budget-dialog/create-or-update-budget-dialog.html,apps/client/src/app/pages/portfolio/budget/create-or-update-budget-dialog/create-or-update-budget-dialog.scss,apps/client/src/app/pages/portfolio/budget/create-or-update-budget-dialog/create-or-update-budget-dialog.component.spec.ts
Expected: command exits with status 0.
- Step 2: Run focused tests
Run:
./node_modules/.bin/nx test common --testFile=routes.spec.ts
./node_modules/.bin/nx test ui --testFile=data.service.spec.ts
./node_modules/.bin/nx test client --testFile=budget-page.component.spec.ts
./node_modules/.bin/nx test client --testFile=create-or-update-budget-dialog.component.spec.ts
Expected: all commands report PASS.
- Step 3: Build the client
Run:
./node_modules/.bin/nx run client:build:development-en
Expected: Angular build completes without TypeScript, template, or style errors.
- Step 4: Start the dev servers
Terminal 1:
npm run start:server
Terminal 2:
npm run start:client
Expected: API server remains running, and the client prints the local URL for https://localhost:4200/en.
- Step 5: Verify in the browser
Open:
https://localhost:4200/en/portfolio/budget
Verify:
-
The Portfolio tabs include
Budget. -
The Budget tab is selected on
/portfolio/budget. -
The month selector is visible and defaults to the current month.
-
Empty state appears when there are no budgets.
-
Create budgetopens the dialog. -
Saving a valid category id, month, and amount closes the dialog and refreshes the table.
-
Edit opens with the selected row values.
-
Delete removes the row and refreshes the table.
-
No text overlaps at desktop width
1440pxand mobile width390px. -
Step 6: Final commit
git status --short
git add apps/client/src/app/pages/portfolio libs/common/src/lib/routes libs/ui/src/lib/services
git commit -m "feat: add budget portfolio ui"
Expected: commit succeeds. If earlier task commits were already created, this command reports no staged files; keep the earlier task commits.
Self-Review
- Spec coverage: Budget tab, route, page, API client calls, create/edit/delete UI, and verification are covered.
- Placeholder scan: This plan avoids placeholder markers and gives concrete file paths, commands, and code snippets for each code-changing step.
- Type consistency: Route key is
budget, path isbudget, router link is['/portfolio', 'budget'], component class isGfBudgetPageComponent, dialog class isGfCreateOrUpdateBudgetDialogComponent, and DataService methods arefetchBudgets,createBudget,updateBudget, anddeleteBudget.