Browse Source

fix: select budget categories by name

pull/7140/head
Yip Shing him 3 weeks ago
parent
commit
1b86d7804a
  1. 15
      apps/api/src/app/budgets/budgets.controller.ts
  2. 33
      apps/api/src/app/budgets/budgets.service.spec.ts
  3. 21
      apps/api/src/app/budgets/budgets.service.ts
  4. 17
      apps/client/src/app/pages/portfolio/budget/create-or-update-budget-dialog/create-or-update-budget-dialog.component.spec.ts
  5. 25
      apps/client/src/app/pages/portfolio/budget/create-or-update-budget-dialog/create-or-update-budget-dialog.component.ts
  6. 16
      apps/client/src/app/pages/portfolio/budget/create-or-update-budget-dialog/create-or-update-budget-dialog.html
  7. 13
      libs/ui/src/lib/services/data.service.spec.ts
  8. 7
      libs/ui/src/lib/services/data.service.ts

15
apps/api/src/app/budgets/budgets.controller.ts

@ -2,7 +2,11 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorat
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { JwtOrApiKeyAuthGuard } from '@ghostfolio/api/guards/jwt-or-api-key-auth.guard';
import { CreateBudgetDto, UpdateBudgetDto } from '@ghostfolio/common/dtos';
import { BudgetResponse, BudgetsResponse } from '@ghostfolio/common/interfaces';
import {
BudgetResponse,
BudgetsResponse,
ExpenseCategoryResponse
} from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
@ -63,6 +67,15 @@ export class BudgetsController {
});
}
@Get('categories')
@HasPermission(permissions.readExpenseCategories)
@UseGuards(JwtOrApiKeyAuthGuard, HasPermissionGuard)
public async getCategories(): Promise<ExpenseCategoryResponse[]> {
return this.budgetsService.getCategories({
userId: this.request.user.id
});
}
@Get(':id')
@HasPermission(permissions.readBudgets)
@UseGuards(JwtOrApiKeyAuthGuard, HasPermissionGuard)

33
apps/api/src/app/budgets/budgets.service.spec.ts

@ -25,6 +25,7 @@ describe('BudgetsService', () => {
};
expenseCategory: {
findFirst: jest.Mock;
findMany: jest.Mock;
};
};
@ -41,7 +42,8 @@ describe('BudgetsService', () => {
groupBy: jest.fn()
},
expenseCategory: {
findFirst: jest.fn()
findFirst: jest.fn(),
findMany: jest.fn()
}
};
@ -129,6 +131,35 @@ describe('BudgetsService', () => {
});
});
it('lists expense categories for the current user', async () => {
prismaService.expenseCategory.findMany.mockResolvedValue([
{
color: '#0055aa',
createdAt,
id: categoryId,
name: 'Groceries',
updatedAt,
userId
}
]);
const response = await budgetsService.getCategories({ userId });
expect(prismaService.expenseCategory.findMany).toHaveBeenCalledWith({
orderBy: { name: 'asc' },
where: { userId }
});
expect(response).toEqual([
{
color: '#0055aa',
createdAt,
id: categoryId,
name: 'Groceries',
updatedAt
}
]);
});
it('creates a budget for a category owned by the current user', async () => {
prismaService.expenseCategory.findFirst.mockResolvedValue({
id: categoryId,

21
apps/api/src/app/budgets/budgets.service.ts

@ -1,6 +1,10 @@
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { CreateBudgetDto, UpdateBudgetDto } from '@ghostfolio/common/dtos';
import { BudgetResponse, BudgetsResponse } from '@ghostfolio/common/interfaces';
import {
BudgetResponse,
BudgetsResponse,
ExpenseCategoryResponse
} from '@ghostfolio/common/interfaces';
import {
ConflictException,
@ -85,6 +89,21 @@ export class BudgetsService {
return this.toBudgetResponse({ budget, spent });
}
public async getCategories({
userId
}: {
userId: string;
}): Promise<ExpenseCategoryResponse[]> {
const categories = await this.prismaService.expenseCategory.findMany({
orderBy: { name: 'asc' },
where: { userId }
});
return categories.map(({ color, createdAt, id, name, updatedAt }) => {
return { color, createdAt, id, name, updatedAt };
});
}
public async getBudgets({
month,
userId

17
apps/client/src/app/pages/portfolio/budget/create-or-update-budget-dialog/create-or-update-budget-dialog.component.spec.ts

@ -13,7 +13,10 @@ import {
describe('GfCreateOrUpdateBudgetDialogComponent', () => {
let component: GfCreateOrUpdateBudgetDialogComponent;
let dataService: jest.Mocked<
Pick<DataService, 'createBudget' | 'updateBudget'>
Pick<
DataService,
'createBudget' | 'fetchExpenseCategories' | 'updateBudget'
>
>;
let dialogRef: jest.Mocked<
Pick<MatDialogRef<GfCreateOrUpdateBudgetDialogComponent>, 'close'>
@ -32,6 +35,17 @@ describe('GfCreateOrUpdateBudgetDialogComponent', () => {
spent: 0
})
),
fetchExpenseCategories: jest.fn().mockReturnValue(
of([
{
color: '#0055aa',
createdAt: new Date('2026-06-01'),
id: 'food',
name: 'Food',
updatedAt: new Date('2026-06-01')
}
])
),
updateBudget: jest.fn().mockReturnValue(
of({
amount: 650,
@ -86,6 +100,7 @@ describe('GfCreateOrUpdateBudgetDialogComponent', () => {
component.onSubmit();
await fixture.whenStable();
expect(dataService.fetchExpenseCategories).toHaveBeenCalled();
expect(dataService.createBudget).toHaveBeenCalledWith({
amount: 500,
categoryId: 'food',

25
apps/client/src/app/pages/portfolio/budget/create-or-update-budget-dialog/create-or-update-budget-dialog.component.ts

@ -1,9 +1,13 @@
import { CreateBudgetDto, UpdateBudgetDto } from '@ghostfolio/common/dtos';
import { BudgetResponse } from '@ghostfolio/common/interfaces';
import {
BudgetResponse,
ExpenseCategoryResponse
} from '@ghostfolio/common/interfaces';
import { DataService } from '@ghostfolio/ui/services';
import { CommonModule } from '@angular/common';
import { Component, Inject } from '@angular/core';
import { Component, DestroyRef, Inject, OnInit } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import {
FormControl,
FormGroup,
@ -18,6 +22,7 @@ import {
} from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
export interface CreateOrUpdateBudgetDialogData {
budget?: BudgetResponse;
@ -32,13 +37,14 @@ export interface CreateOrUpdateBudgetDialogData {
MatDialogModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
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 {
export class GfCreateOrUpdateBudgetDialogComponent implements OnInit {
public budgetForm = new FormGroup({
amount: new FormControl<number>(this.data.budget?.amount ?? 0, {
nonNullable: true,
@ -53,13 +59,26 @@ export class GfCreateOrUpdateBudgetDialogComponent {
validators: [Validators.required]
})
});
public categories: ExpenseCategoryResponse[] = [];
public isLoadingCategories = true;
public constructor(
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateBudgetDialogData,
private dataService: DataService,
private destroyRef: DestroyRef,
private dialogRef: MatDialogRef<GfCreateOrUpdateBudgetDialogComponent>
) {}
public ngOnInit() {
this.dataService
.fetchExpenseCategories()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((categories) => {
this.categories = categories;
this.isLoadingCategories = false;
});
}
public onSubmit() {
if (this.budgetForm.invalid) {
this.budgetForm.markAllAsTouched();

16
apps/client/src/app/pages/portfolio/budget/create-or-update-budget-dialog/create-or-update-budget-dialog.html

@ -14,8 +14,20 @@
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label i18n>Category id</mat-label>
<input formControlName="categoryId" matInput />
<mat-label i18n>Category</mat-label>
<mat-select formControlName="categoryId">
@if (isLoadingCategories) {
<mat-option disabled i18n>Loading categories...</mat-option>
} @else if (categories.length === 0) {
<mat-option disabled i18n>No expense categories available</mat-option>
} @else {
@for (category of categories; track category.id) {
<mat-option [value]="category.id">
{{ category.name }}
</mat-option>
}
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">

13
libs/ui/src/lib/services/data.service.spec.ts

@ -47,6 +47,7 @@ describe('DataService budget methods', () => {
const budget: CreateBudgetDto = {
amount: 500,
categoryId: 'category-1',
currency: 'USD',
month: '2026-06'
};
@ -62,6 +63,8 @@ describe('DataService budget methods', () => {
const budget: UpdateBudgetDto = {
amount: 650,
categoryId: 'category-1',
currency: 'USD',
id: 'budget-1',
month: '2026-06'
};
@ -73,6 +76,16 @@ describe('DataService budget methods', () => {
expect(request.request.body).toEqual(budget);
});
it('fetches expense categories', () => {
dataService.fetchExpenseCategories().subscribe();
const request = httpTestingController.expectOne(
'/api/v1/budgets/categories'
);
expect(request.request.method).toBe('GET');
});
it('deletes a budget', () => {
dataService.deleteBudget('budget-1').subscribe();

7
libs/ui/src/lib/services/data.service.ts

@ -40,6 +40,7 @@ import {
CreateStripeCheckoutSessionResponse,
DataProviderHealthResponse,
DataProviderHistoricalResponse,
ExpenseCategoryResponse,
ExportResponse,
Filter,
ImportResponse,
@ -401,6 +402,12 @@ export class DataService {
return this.http.get<BudgetsResponse>('/api/v1/budgets', { params });
}
public fetchExpenseCategories() {
return this.http.get<ExpenseCategoryResponse[]>(
'/api/v1/budgets/categories'
);
}
public fetchAssetProfiles({
filters,
skip,

Loading…
Cancel
Save