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 a3a70a0c4..550ead68c 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
@@ -155,4 +155,15 @@ describe('GfBudgetPageComponent', () => {
expect(dataService.deleteBudget).toHaveBeenCalledWith('budget-1');
expect(dataService.fetchBudgets).toHaveBeenCalledTimes(2);
});
+
+ it('opens the category management dialog', async () => {
+ await fixture.whenStable();
+
+ fixture.componentInstance.onManageCategories();
+ await fixture.whenStable();
+
+ expect(dialog.open).toHaveBeenCalledWith(expect.any(Function), {
+ width: '36rem'
+ });
+ });
});
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 6a33c238c..bfb7aa894 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
@@ -20,6 +20,7 @@ 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';
+import { GfManageBudgetCategoriesDialogComponent } from './manage-budget-categories-dialog/manage-budget-categories-dialog.component';
@Component({
host: { class: 'page' },
@@ -122,6 +123,16 @@ export class GfBudgetPageComponent implements OnInit {
});
}
+ public onManageCategories() {
+ this.dialog
+ .open(GfManageBudgetCategoriesDialogComponent, {
+ width: '36rem'
+ })
+ .afterClosed()
+ .pipe(takeUntilDestroyed(this.destroyRef))
+ .subscribe();
+ }
+
public onUpdateBudget(budget: BudgetResponse) {
this.openBudgetDialog({
budget,
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 4e303f5b1..84f514b4b 100644
--- a/apps/client/src/app/pages/portfolio/budget/budget-page.html
+++ b/apps/client/src/app/pages/portfolio/budget/budget-page.html
@@ -135,16 +135,26 @@
}
-
+
+
+
+
+
diff --git a/apps/client/src/app/pages/portfolio/budget/budget-page.scss b/apps/client/src/app/pages/portfolio/budget/budget-page.scss
index 25f57162f..469db050c 100644
--- a/apps/client/src/app/pages/portfolio/budget/budget-page.scss
+++ b/apps/client/src/app/pages/portfolio/budget/budget-page.scss
@@ -6,6 +6,12 @@
max-width: 11rem;
}
+.budget-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+}
+
.budget-summary {
display: grid;
gap: 1rem;
diff --git a/apps/client/src/app/pages/portfolio/budget/manage-budget-categories-dialog/manage-budget-categories-dialog.component.spec.ts b/apps/client/src/app/pages/portfolio/budget/manage-budget-categories-dialog/manage-budget-categories-dialog.component.spec.ts
new file mode 100644
index 000000000..33979fa9c
--- /dev/null
+++ b/apps/client/src/app/pages/portfolio/budget/manage-budget-categories-dialog/manage-budget-categories-dialog.component.spec.ts
@@ -0,0 +1,164 @@
+import { DataService } from '@ghostfolio/ui/services';
+
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { MatDialogRef } from '@angular/material/dialog';
+import { NoopAnimationsModule } from '@angular/platform-browser/animations';
+import { of } from 'rxjs';
+
+import { GfManageBudgetCategoriesDialogComponent } from './manage-budget-categories-dialog.component';
+
+describe('GfManageBudgetCategoriesDialogComponent', () => {
+ const createdAt = new Date('2026-06-01');
+ const updatedAt = new Date('2026-06-01');
+
+ let component: GfManageBudgetCategoriesDialogComponent;
+ let dataService: jest.Mocked<
+ Pick<
+ DataService,
+ | 'createExpenseCategory'
+ | 'deleteExpenseCategory'
+ | 'fetchExpenseCategories'
+ | 'updateExpenseCategory'
+ >
+ >;
+ let dialogRef: jest.Mocked<
+ Pick, 'close'>
+ >;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ dataService = {
+ createExpenseCategory: jest.fn().mockReturnValue(
+ of({
+ color: '#0055aa',
+ createdAt,
+ id: 'category-2',
+ name: 'Transport',
+ updatedAt
+ })
+ ),
+ deleteExpenseCategory: jest.fn().mockReturnValue(of(undefined)),
+ fetchExpenseCategories: jest.fn().mockReturnValue(
+ of([
+ {
+ color: '#0055aa',
+ createdAt,
+ id: 'category-1',
+ name: 'Groceries',
+ updatedAt
+ }
+ ])
+ ),
+ updateExpenseCategory: jest.fn().mockReturnValue(
+ of({
+ color: '#aa5500',
+ createdAt,
+ id: 'category-1',
+ name: 'Food',
+ updatedAt
+ })
+ )
+ };
+ dialogRef = {
+ close: jest.fn()
+ };
+
+ await TestBed.configureTestingModule({
+ imports: [GfManageBudgetCategoriesDialogComponent, NoopAnimationsModule],
+ providers: [
+ {
+ provide: DataService,
+ useValue: dataService
+ },
+ {
+ provide: MatDialogRef,
+ useValue: dialogRef
+ }
+ ]
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(GfManageBudgetCategoriesDialogComponent);
+ component = fixture.componentInstance;
+ fixture.autoDetectChanges();
+ });
+
+ it('loads expense categories', async () => {
+ await fixture.whenStable();
+
+ expect(dataService.fetchExpenseCategories).toHaveBeenCalled();
+ expect(component.dataSource.data).toEqual([
+ {
+ color: '#0055aa',
+ createdAt,
+ id: 'category-1',
+ name: 'Groceries',
+ updatedAt
+ }
+ ]);
+ });
+
+ it('creates an expense category and reloads categories', async () => {
+ await fixture.whenStable();
+
+ component.categoryForm.setValue({
+ color: '#0055aa',
+ name: 'Transport'
+ });
+
+ component.onSubmit();
+ await fixture.whenStable();
+
+ expect(dataService.createExpenseCategory).toHaveBeenCalledWith({
+ color: '#0055aa',
+ name: 'Transport'
+ });
+ expect(dataService.fetchExpenseCategories).toHaveBeenCalledTimes(2);
+ });
+
+ it('updates an expense category and reloads categories', async () => {
+ await fixture.whenStable();
+
+ component.onEditCategory({
+ color: '#0055aa',
+ createdAt,
+ id: 'category-1',
+ name: 'Groceries',
+ updatedAt
+ });
+ component.categoryForm.setValue({
+ color: '#aa5500',
+ name: 'Food'
+ });
+
+ component.onSubmit();
+ await fixture.whenStable();
+
+ expect(dataService.updateExpenseCategory).toHaveBeenCalledWith({
+ category: {
+ color: '#aa5500',
+ id: 'category-1',
+ name: 'Food'
+ },
+ id: 'category-1'
+ });
+ expect(dataService.fetchExpenseCategories).toHaveBeenCalledTimes(2);
+ });
+
+ it('deletes an expense category and reloads categories', async () => {
+ await fixture.whenStable();
+
+ component.onDeleteCategory('category-1');
+ await fixture.whenStable();
+
+ expect(dataService.deleteExpenseCategory).toHaveBeenCalledWith(
+ 'category-1'
+ );
+ expect(dataService.fetchExpenseCategories).toHaveBeenCalledTimes(2);
+ });
+
+ it('closes with a refresh result', () => {
+ component.onClose();
+
+ expect(dialogRef.close).toHaveBeenCalledWith({ refresh: true });
+ });
+});
diff --git a/apps/client/src/app/pages/portfolio/budget/manage-budget-categories-dialog/manage-budget-categories-dialog.component.ts b/apps/client/src/app/pages/portfolio/budget/manage-budget-categories-dialog/manage-budget-categories-dialog.component.ts
new file mode 100644
index 000000000..b00dbe107
--- /dev/null
+++ b/apps/client/src/app/pages/portfolio/budget/manage-budget-categories-dialog/manage-budget-categories-dialog.component.ts
@@ -0,0 +1,150 @@
+import { ExpenseCategoryResponse } from '@ghostfolio/common/interfaces';
+import { DataService } from '@ghostfolio/ui/services';
+
+import { CommonModule } from '@angular/common';
+import {
+ ChangeDetectorRef,
+ Component,
+ DestroyRef,
+ OnInit
+} from '@angular/core';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
+import {
+ FormControl,
+ FormGroup,
+ ReactiveFormsModule,
+ Validators
+} from '@angular/forms';
+import { MatButtonModule } from '@angular/material/button';
+import { MatDialogModule, MatDialogRef } from '@angular/material/dialog';
+import { MatFormFieldModule } from '@angular/material/form-field';
+import { MatIconModule } from '@angular/material/icon';
+import { MatInputModule } from '@angular/material/input';
+import { MatTableDataSource, MatTableModule } from '@angular/material/table';
+
+@Component({
+ imports: [
+ CommonModule,
+ MatButtonModule,
+ MatDialogModule,
+ MatFormFieldModule,
+ MatIconModule,
+ MatInputModule,
+ MatTableModule,
+ ReactiveFormsModule
+ ],
+ selector: 'gf-manage-budget-categories-dialog',
+ styleUrls: ['./manage-budget-categories-dialog.scss'],
+ templateUrl: './manage-budget-categories-dialog.html'
+})
+export class GfManageBudgetCategoriesDialogComponent implements OnInit {
+ public categoryForm = new FormGroup({
+ color: new FormControl('', {
+ nonNullable: true,
+ validators: [Validators.pattern(/^#[0-9a-fA-F]{6}$/)]
+ }),
+ name: new FormControl('', {
+ nonNullable: true,
+ validators: [Validators.required]
+ })
+ });
+ public dataSource = new MatTableDataSource([]);
+ public displayedColumns = ['name', 'color', 'actions'];
+ public editingCategory: ExpenseCategoryResponse | undefined;
+ public isLoading = true;
+
+ public constructor(
+ private changeDetectorRef: ChangeDetectorRef,
+ private dataService: DataService,
+ private destroyRef: DestroyRef,
+ private dialogRef: MatDialogRef
+ ) {}
+
+ public ngOnInit() {
+ this.fetchCategories();
+ }
+
+ public onCancelEdit() {
+ this.editingCategory = undefined;
+ this.categoryForm.reset({
+ color: '',
+ name: ''
+ });
+ }
+
+ public onClose() {
+ this.dialogRef.close({ refresh: true });
+ }
+
+ public onDeleteCategory(id: string) {
+ this.dataService
+ .deleteExpenseCategory(id)
+ .pipe(takeUntilDestroyed(this.destroyRef))
+ .subscribe(() => {
+ this.fetchCategories();
+ });
+ }
+
+ public onEditCategory(category: ExpenseCategoryResponse) {
+ this.editingCategory = category;
+ this.categoryForm.setValue({
+ color: category.color ?? '',
+ name: category.name
+ });
+ }
+
+ public onSubmit() {
+ if (this.categoryForm.invalid) {
+ this.categoryForm.markAllAsTouched();
+ return;
+ }
+
+ const category = this.toCategoryPayload();
+
+ if (this.editingCategory) {
+ this.dataService
+ .updateExpenseCategory({
+ category: {
+ ...category,
+ id: this.editingCategory.id
+ },
+ id: this.editingCategory.id
+ })
+ .pipe(takeUntilDestroyed(this.destroyRef))
+ .subscribe(() => {
+ this.onCancelEdit();
+ this.fetchCategories();
+ });
+ } else {
+ this.dataService
+ .createExpenseCategory(category)
+ .pipe(takeUntilDestroyed(this.destroyRef))
+ .subscribe(() => {
+ this.onCancelEdit();
+ this.fetchCategories();
+ });
+ }
+ }
+
+ private fetchCategories() {
+ this.isLoading = true;
+
+ this.dataService
+ .fetchExpenseCategories()
+ .pipe(takeUntilDestroyed(this.destroyRef))
+ .subscribe((categories) => {
+ this.dataSource = new MatTableDataSource(categories);
+ this.isLoading = false;
+ this.changeDetectorRef.markForCheck();
+ });
+ }
+
+ private toCategoryPayload() {
+ const { color, name } = this.categoryForm.getRawValue();
+
+ return {
+ color: color || undefined,
+ name
+ };
+ }
+}
diff --git a/apps/client/src/app/pages/portfolio/budget/manage-budget-categories-dialog/manage-budget-categories-dialog.html b/apps/client/src/app/pages/portfolio/budget/manage-budget-categories-dialog/manage-budget-categories-dialog.html
new file mode 100644
index 000000000..a12f575bc
--- /dev/null
+++ b/apps/client/src/app/pages/portfolio/budget/manage-budget-categories-dialog/manage-budget-categories-dialog.html
@@ -0,0 +1,98 @@
+Manage categories
+
+
+
+
+ @if (dataSource.data.length > 0) {
+
+
+ | Name |
+ {{ category.name }} |
+
+
+
+ Color |
+
+ @if (category.color) {
+
+
+ {{ category.color }}
+
+ }
+ |
+
+
+
+ |
+
+
+
+ |
+
+
+
+
+
+ } @else if (!isLoading) {
+
+ No categories have been created.
+
+ }
+
+
+
+
+
diff --git a/apps/client/src/app/pages/portfolio/budget/manage-budget-categories-dialog/manage-budget-categories-dialog.scss b/apps/client/src/app/pages/portfolio/budget/manage-budget-categories-dialog/manage-budget-categories-dialog.scss
new file mode 100644
index 000000000..7b34e313e
--- /dev/null
+++ b/apps/client/src/app/pages/portfolio/budget/manage-budget-categories-dialog/manage-budget-categories-dialog.scss
@@ -0,0 +1,35 @@
+.category-form {
+ display: grid;
+ gap: 1rem;
+ grid-template-columns: minmax(0, 1fr) minmax(0, 10rem) auto;
+}
+
+.category-form-actions {
+ align-items: center;
+ display: flex;
+ gap: 0.5rem;
+ justify-content: flex-end;
+}
+
+.category-color {
+ align-items: center;
+ display: inline-flex;
+ gap: 0.5rem;
+}
+
+.category-color-swatch {
+ border: 1px solid var(--border-color);
+ display: inline-block;
+ height: 1rem;
+ width: 1rem;
+}
+
+@media (max-width: 767.98px) {
+ .category-form {
+ grid-template-columns: 1fr;
+ }
+
+ .category-form-actions {
+ justify-content: flex-start;
+ }
+}