Browse Source

feat: add budget category management ui

pull/7140/head
Yip Shing him 3 weeks ago
parent
commit
77497aac06
  1. 11
      apps/client/src/app/pages/portfolio/budget/budget-page.component.spec.ts
  2. 11
      apps/client/src/app/pages/portfolio/budget/budget-page.component.ts
  3. 30
      apps/client/src/app/pages/portfolio/budget/budget-page.html
  4. 6
      apps/client/src/app/pages/portfolio/budget/budget-page.scss
  5. 164
      apps/client/src/app/pages/portfolio/budget/manage-budget-categories-dialog/manage-budget-categories-dialog.component.spec.ts
  6. 150
      apps/client/src/app/pages/portfolio/budget/manage-budget-categories-dialog/manage-budget-categories-dialog.component.ts
  7. 98
      apps/client/src/app/pages/portfolio/budget/manage-budget-categories-dialog/manage-budget-categories-dialog.html
  8. 35
      apps/client/src/app/pages/portfolio/budget/manage-budget-categories-dialog/manage-budget-categories-dialog.scss

11
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.deleteBudget).toHaveBeenCalledWith('budget-1');
expect(dataService.fetchBudgets).toHaveBeenCalledTimes(2); 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'
});
});
}); });

11
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 { format } from 'date-fns';
import { GfCreateOrUpdateBudgetDialogComponent } from './create-or-update-budget-dialog/create-or-update-budget-dialog.component'; 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({ @Component({
host: { class: 'page' }, 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) { public onUpdateBudget(budget: BudgetResponse) {
this.openBudgetDialog({ this.openBudgetDialog({
budget, budget,

30
apps/client/src/app/pages/portfolio/budget/budget-page.html

@ -135,16 +135,26 @@
</div> </div>
} }
<button <div class="budget-actions mt-3">
class="mt-3" <button
color="primary" color="primary"
i18n i18n
mat-flat-button mat-flat-button
type="button" type="button"
(click)="onCreateBudget()" (click)="onCreateBudget()"
> >
Create budget Create budget
</button> </button>
<button
i18n
mat-stroked-button
type="button"
(click)="onManageCategories()"
>
Manage categories
</button>
</div>
</div> </div>
</div> </div>
</div> </div>

6
apps/client/src/app/pages/portfolio/budget/budget-page.scss

@ -6,6 +6,12 @@
max-width: 11rem; max-width: 11rem;
} }
.budget-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.budget-summary { .budget-summary {
display: grid; display: grid;
gap: 1rem; gap: 1rem;

164
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<MatDialogRef<GfManageBudgetCategoriesDialogComponent>, 'close'>
>;
let fixture: ComponentFixture<GfManageBudgetCategoriesDialogComponent>;
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 });
});
});

150
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<string>('', {
nonNullable: true,
validators: [Validators.pattern(/^#[0-9a-fA-F]{6}$/)]
}),
name: new FormControl<string>('', {
nonNullable: true,
validators: [Validators.required]
})
});
public dataSource = new MatTableDataSource<ExpenseCategoryResponse>([]);
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<GfManageBudgetCategoriesDialogComponent>
) {}
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
};
}
}

98
apps/client/src/app/pages/portfolio/budget/manage-budget-categories-dialog/manage-budget-categories-dialog.html

@ -0,0 +1,98 @@
<h2 i18n mat-dialog-title>Manage categories</h2>
<mat-dialog-content>
<form
class="category-form"
[formGroup]="categoryForm"
(ngSubmit)="onSubmit()"
>
<mat-form-field appearance="outline">
<mat-label i18n>Name</mat-label>
<input formControlName="name" matInput />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label i18n>Color</mat-label>
<input formControlName="color" matInput placeholder="#0055aa" />
</mat-form-field>
<div class="category-form-actions">
@if (editingCategory) {
<button i18n mat-button type="button" (click)="onCancelEdit()">
Cancel edit
</button>
}
<button
color="primary"
i18n
mat-flat-button
type="submit"
[disabled]="categoryForm.invalid"
>
@if (editingCategory) {
Update
} @else {
Create
}
</button>
</div>
</form>
@if (dataSource.data.length > 0) {
<table class="gf-table w-100" mat-table [dataSource]="dataSource">
<ng-container matColumnDef="name">
<th *matHeaderCellDef i18n mat-header-cell>Name</th>
<td *matCellDef="let category" mat-cell>{{ category.name }}</td>
</ng-container>
<ng-container matColumnDef="color">
<th *matHeaderCellDef i18n mat-header-cell>Color</th>
<td *matCellDef="let category" mat-cell>
@if (category.color) {
<span class="category-color">
<span
class="category-color-swatch"
[style.background-color]="category.color"
></span>
{{ category.color }}
</span>
}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th *matHeaderCellDef class="text-right" mat-header-cell></th>
<td *matCellDef="let category" class="text-right" mat-cell>
<button
mat-icon-button
type="button"
[attr.aria-label]="'Edit category'"
(click)="onEditCategory(category)"
>
<mat-icon>edit</mat-icon>
</button>
<button
mat-icon-button
type="button"
[attr.aria-label]="'Delete category'"
(click)="onDeleteCategory(category.id)"
>
<mat-icon>delete</mat-icon>
</button>
</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-4 text-center text-muted" i18n>
No categories have been created.
</div>
}
</mat-dialog-content>
<mat-dialog-actions align="end">
<button i18n mat-button type="button" (click)="onClose()">Close</button>
</mat-dialog-actions>

35
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;
}
}
Loading…
Cancel
Save