From 77497aac06c4b848afd3584c1bc715af9bedf1b5 Mon Sep 17 00:00:00 2001 From: Yip Shing him Date: Tue, 23 Jun 2026 23:36:50 +0100 Subject: [PATCH] feat: add budget category management ui --- .../budget/budget-page.component.spec.ts | 11 ++ .../portfolio/budget/budget-page.component.ts | 11 ++ .../pages/portfolio/budget/budget-page.html | 30 ++-- .../pages/portfolio/budget/budget-page.scss | 6 + ...budget-categories-dialog.component.spec.ts | 164 ++++++++++++++++++ ...nage-budget-categories-dialog.component.ts | 150 ++++++++++++++++ .../manage-budget-categories-dialog.html | 98 +++++++++++ .../manage-budget-categories-dialog.scss | 35 ++++ 8 files changed, 495 insertions(+), 10 deletions(-) create mode 100644 apps/client/src/app/pages/portfolio/budget/manage-budget-categories-dialog/manage-budget-categories-dialog.component.spec.ts create mode 100644 apps/client/src/app/pages/portfolio/budget/manage-budget-categories-dialog/manage-budget-categories-dialog.component.ts create mode 100644 apps/client/src/app/pages/portfolio/budget/manage-budget-categories-dialog/manage-budget-categories-dialog.html create mode 100644 apps/client/src/app/pages/portfolio/budget/manage-budget-categories-dialog/manage-budget-categories-dialog.scss 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

+ + +
+ + Name + + + + + Color + + + +
+ @if (editingCategory) { + + } + + +
+
+ + @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; + } +}