diff --git a/backend/exence/src/main/java/com/exence/finance/modules/category/controller/CategoryController.java b/backend/exence/src/main/java/com/exence/finance/modules/category/controller/CategoryController.java index 3ec63894..81844f3a 100644 --- a/backend/exence/src/main/java/com/exence/finance/modules/category/controller/CategoryController.java +++ b/backend/exence/src/main/java/com/exence/finance/modules/category/controller/CategoryController.java @@ -1,6 +1,8 @@ package com.exence.finance.modules.category.controller; import com.exence.finance.modules.category.dto.CategoryDTO; +import com.exence.finance.modules.category.dto.CategorySummaryResponse; +import com.exence.finance.modules.transaction.dto.request.CategoryFilter; import java.util.List; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PathVariable; @@ -10,6 +12,8 @@ public interface CategoryController { ResponseEntity> getCategories(); + ResponseEntity> getTopCategoreiesByTotalAmount(CategoryFilter filter); + ResponseEntity createCategory(CategoryDTO categoryDTO); ResponseEntity updateCategory(Long id, CategoryDTO categoryDTO); diff --git a/backend/exence/src/main/java/com/exence/finance/modules/category/controller/impl/CategoryControllerImpl.java b/backend/exence/src/main/java/com/exence/finance/modules/category/controller/impl/CategoryControllerImpl.java index bf7c0c30..607e1381 100644 --- a/backend/exence/src/main/java/com/exence/finance/modules/category/controller/impl/CategoryControllerImpl.java +++ b/backend/exence/src/main/java/com/exence/finance/modules/category/controller/impl/CategoryControllerImpl.java @@ -5,6 +5,7 @@ import com.exence.finance.modules.category.dto.CategoryDTO; import com.exence.finance.modules.category.dto.CategorySummaryResponse; import com.exence.finance.modules.category.service.CategoryService; +import com.exence.finance.modules.transaction.dto.request.CategoryFilter; import jakarta.validation.Valid; import java.util.List; import lombok.RequiredArgsConstructor; @@ -12,6 +13,7 @@ import org.springframework.web.bind.annotation.CrossOrigin; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; @@ -38,10 +40,11 @@ public ResponseEntity> getCategories() { return ResponseFactory.ok(categoryDTOs); } - @GetMapping("/top4") - public ResponseEntity> getTop4CategoriesByTotalAmount() { - List top4Categories = categoryService.getTop4CategoriesByTotalAmount(); - return ResponseFactory.ok(top4Categories); + @GetMapping("/top") + public ResponseEntity> getTopCategoreiesByTotalAmount( + @Valid @ModelAttribute CategoryFilter filter) { + List topCategories = categoryService.getTopCategoriesByTotalAmount(filter); + return ResponseFactory.ok(topCategories); } @PostMapping() diff --git a/backend/exence/src/main/java/com/exence/finance/modules/category/entity/Category.java b/backend/exence/src/main/java/com/exence/finance/modules/category/entity/Category.java index 9d9ca163..6596b110 100644 --- a/backend/exence/src/main/java/com/exence/finance/modules/category/entity/Category.java +++ b/backend/exence/src/main/java/com/exence/finance/modules/category/entity/Category.java @@ -38,11 +38,11 @@ @NoArgsConstructor @AllArgsConstructor @EqualsAndHashCode( - callSuper = false, - exclude = {"user", "transactions"}) + callSuper = false, + exclude = {"user", "transactions"}) @ToString( - callSuper = true, - exclude = {"user", "transactions"}) + callSuper = true, + exclude = {"user", "transactions"}) @Table(name = "category") @Filter(name = "userFilter", condition = "user_id = :userId") public class Category extends BaseAuditableEntity { diff --git a/backend/exence/src/main/java/com/exence/finance/modules/category/repository/CategoryRepository.java b/backend/exence/src/main/java/com/exence/finance/modules/category/repository/CategoryRepository.java index b39e0930..d14b70e7 100644 --- a/backend/exence/src/main/java/com/exence/finance/modules/category/repository/CategoryRepository.java +++ b/backend/exence/src/main/java/com/exence/finance/modules/category/repository/CategoryRepository.java @@ -1,11 +1,13 @@ package com.exence.finance.modules.category.repository; import com.exence.finance.modules.category.dto.CategorySummaryResponse; +import com.exence.finance.modules.category.dto.CategoryType; import com.exence.finance.modules.category.entity.Category; import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @Repository @@ -15,15 +17,19 @@ public interface CategoryRepository extends JpaRepository { @Query( """ - SELECT new com.exence.finance.modules.category.dto.CategorySummaryResponse( - c.id, c.name, CAST(c.icon AS string), c.color, COALESCE(SUM(t.amount), 0) - ) - FROM Category c - LEFT JOIN c.transactions t - WHERE (t.type = 'EXPENSE') - GROUP BY c.id, c.name, c.icon, c.color - ORDER BY COALESCE(SUM(t.amount), 0) DESC - LIMIT 4 - """) - List findTop4CategoriesByTotalAmount(); + SELECT new com.exence.finance.modules.category.dto.CategorySummaryResponse( + c.id, c.name, CAST(c.icon AS string), c.color, COALESCE(SUM(t.amount), 0) + ) + FROM Category c + left JOIN c.transactions t + WHERE c.type = :type + AND ( + (:type = 'INCOME' AND t.type = 'INCOME') OR + (:type IN ('EXPENSE', 'MIXED') AND t.type = 'EXPENSE') + ) + GROUP BY c.id, c.name, c.icon, c.color + ORDER BY COALESCE(SUM(t.amount), 0) DESC + LIMIT 4 + """) + List findTopCategoriesByTotalAmount(@Param("type") CategoryType type); } diff --git a/backend/exence/src/main/java/com/exence/finance/modules/category/service/CategoryService.java b/backend/exence/src/main/java/com/exence/finance/modules/category/service/CategoryService.java index 10d8ba33..aafb2ec6 100644 --- a/backend/exence/src/main/java/com/exence/finance/modules/category/service/CategoryService.java +++ b/backend/exence/src/main/java/com/exence/finance/modules/category/service/CategoryService.java @@ -2,6 +2,7 @@ import com.exence.finance.modules.category.dto.CategoryDTO; import com.exence.finance.modules.category.dto.CategorySummaryResponse; +import com.exence.finance.modules.transaction.dto.request.CategoryFilter; import java.util.List; import org.springframework.web.bind.annotation.PathVariable; @@ -10,7 +11,7 @@ public interface CategoryService { List getCategories(); - List getTop4CategoriesByTotalAmount(); + List getTopCategoriesByTotalAmount(CategoryFilter filter); CategoryDTO createCategory(CategoryDTO categoryDTO); diff --git a/backend/exence/src/main/java/com/exence/finance/modules/category/service/impl/CategoryServiceImpl.java b/backend/exence/src/main/java/com/exence/finance/modules/category/service/impl/CategoryServiceImpl.java index 7fd65808..f18c3818 100644 --- a/backend/exence/src/main/java/com/exence/finance/modules/category/service/impl/CategoryServiceImpl.java +++ b/backend/exence/src/main/java/com/exence/finance/modules/category/service/impl/CategoryServiceImpl.java @@ -10,6 +10,7 @@ import com.exence.finance.modules.category.mapper.CategoryMapper; import com.exence.finance.modules.category.repository.CategoryRepository; import com.exence.finance.modules.category.service.CategoryService; +import com.exence.finance.modules.transaction.dto.request.CategoryFilter; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -35,8 +36,8 @@ public List getCategories() { return categoryMapper.mapToCategoryDTOList(categories); } - public List getTop4CategoriesByTotalAmount() { - return categoryRepository.findTop4CategoriesByTotalAmount(); + public List getTopCategoriesByTotalAmount(CategoryFilter filter) { + return categoryRepository.findTopCategoriesByTotalAmount(filter.getType()); } @Transactional diff --git a/backend/exence/src/main/java/com/exence/finance/modules/transaction/dto/request/CategoryFilter.java b/backend/exence/src/main/java/com/exence/finance/modules/transaction/dto/request/CategoryFilter.java new file mode 100644 index 00000000..948e0363 --- /dev/null +++ b/backend/exence/src/main/java/com/exence/finance/modules/transaction/dto/request/CategoryFilter.java @@ -0,0 +1,27 @@ +package com.exence.finance.modules.transaction.dto.request; + +import com.exence.finance.modules.category.dto.CategoryType; +import java.io.Serializable; +import java.util.Objects; +import java.util.stream.Stream; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.ToString; +import lombok.experimental.SuperBuilder; + +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Data +@EqualsAndHashCode(callSuper = false) +@ToString(callSuper = true) +public class CategoryFilter implements Serializable { + private CategoryType type; + + public boolean hasActiveFilter() { + return Stream.of(type).anyMatch(Objects::nonNull); + } +} diff --git a/frontend/Exence/src/app/data-model/modules/category/CategoryFilter.ts b/frontend/Exence/src/app/data-model/modules/category/CategoryFilter.ts new file mode 100644 index 00000000..d4b4e67a --- /dev/null +++ b/frontend/Exence/src/app/data-model/modules/category/CategoryFilter.ts @@ -0,0 +1,5 @@ +import { CategoryType } from './CategoryType'; + +export interface CategoryFilter { + type?: CategoryType; +} diff --git a/frontend/Exence/src/app/private/dashboard/categories/categories.component.html b/frontend/Exence/src/app/private/dashboard/categories/categories.component.html index 3e3584b3..3109c2f1 100644 --- a/frontend/Exence/src/app/private/dashboard/categories/categories.component.html +++ b/frontend/Exence/src/app/private/dashboard/categories/categories.component.html @@ -8,7 +8,7 @@

Top categories

- +
@for (category of topCategories(); track category.id) {
@@ -64,4 +64,17 @@

Add categories to discover your spending patter } } + + + @for (type of categoryTypes | enumValue; track type) { + + {{ type }} + + } + + diff --git a/frontend/Exence/src/app/private/dashboard/categories/categories.component.ts b/frontend/Exence/src/app/private/dashboard/categories/categories.component.ts index dadaab78..55757654 100644 --- a/frontend/Exence/src/app/private/dashboard/categories/categories.component.ts +++ b/frontend/Exence/src/app/private/dashboard/categories/categories.component.ts @@ -1,15 +1,20 @@ -import { Component, computed, inject, input } from '@angular/core'; +import { Component, computed, inject, input, signal } from '@angular/core'; +import { ReactiveFormsModule } from '@angular/forms'; +import { MatButtonToggleModule } from '@angular/material/button-toggle'; import { MatCardModule } from '@angular/material/card'; +import { MatIconModule } from '@angular/material/icon'; import { MatProgressBarModule } from '@angular/material/progress-bar'; +import { Category } from 'src/app/data-model/modules/category/Category'; import { CategorySummaryResponse } from '../../../data-model/modules/category/CategorySummaryResponse'; +import { CategoryType } from '../../../data-model/modules/category/CategoryType'; import { BaseComponent } from '../../../shared/base-component/base.component'; import { ButtonComponent } from '../../../shared/button/button.component'; import { DialogService } from '../../../shared/dialog/dialog.service'; import { DisplaySizeService } from '../../../shared/display-size.service'; +import { EnumValuePipe } from '../../../shared/pipes/enum-value.pipe'; +import { CategoryStore } from '../../transactions-and-categories/category.store'; import { CreateCategoryDialogComponent } from '../../transactions-and-categories/create-category-dialog/create-category-dialog.component'; import { CreateTransactionDialogComponent } from '../../transactions-and-categories/create-transaction-dialog/create-transaction-dialog.component'; -import { Category } from 'src/app/data-model/modules/category/Category'; -import { MatIconModule } from '@angular/material/icon'; // TODO move to interval filter component when created export enum DateInterval { @@ -27,19 +32,33 @@ export interface IntervalInfo { selector: 'ex-categories', templateUrl: './categories.component.html', styleUrl: './categories.component.scss', - imports: [MatProgressBarModule, MatCardModule, MatIconModule, ButtonComponent], + imports: [ + MatProgressBarModule, + MatCardModule, + MatIconModule, + MatButtonToggleModule, + ReactiveFormsModule, + ButtonComponent, + EnumValuePipe, + ], }) export class CategoriesComponent extends BaseComponent { - public display = inject(DisplaySizeService); private readonly dialog = inject(DialogService); + private readonly categoryStore = inject(CategoryStore); + readonly display = inject(DisplaySizeService); totalExpense = input.required(); + totalIncome = input.required(); topCategories = input.required(); categories = input.required(); + categoryType = signal(CategoryType.EXPENSE); + hasCategories = computed(() => !!this.categories().length); hasTransactions = computed(() => !!this.topCategories().length); + categoryTypes = CategoryType; + async openCreateCategoryDialog(): Promise { await this.dialog.openNonModal(CreateCategoryDialogComponent, undefined); } @@ -49,6 +68,14 @@ export class CategoriesComponent extends BaseComponent { } calcPercentage(amount: number): number { + if (this.categoryType() === CategoryType.INCOME) { + return Math.floor((amount / this.totalIncome()) * 100); + } return Math.floor((amount / this.totalExpense()) * 100); } + + onTypeChanged(type: CategoryType): void { + this.categoryType.set(type); + this.categoryStore.toggleTopCategoriesType(type); + } } diff --git a/frontend/Exence/src/app/private/dashboard/dashboard.component.html b/frontend/Exence/src/app/private/dashboard/dashboard.component.html index f1059e1b..5cb63ac4 100644 --- a/frontend/Exence/src/app/private/dashboard/dashboard.component.html +++ b/frontend/Exence/src/app/private/dashboard/dashboard.component.html @@ -29,6 +29,7 @@

diff --git a/frontend/Exence/src/app/private/transactions-and-categories/category.service.ts b/frontend/Exence/src/app/private/transactions-and-categories/category.service.ts index 0aeb20d8..4936be4f 100644 --- a/frontend/Exence/src/app/private/transactions-and-categories/category.service.ts +++ b/frontend/Exence/src/app/private/transactions-and-categories/category.service.ts @@ -3,6 +3,8 @@ import { lastValueFrom } from 'rxjs'; import { HttpService } from '../../shared/http/http.service'; import { Category } from '../../data-model/modules/category/Category'; import { CategorySummaryResponse } from '../../data-model/modules/category/CategorySummaryResponse'; +import { CategoryFilter } from '../../data-model/modules/category/CategoryFilter'; +import { getFilters } from '../../shared/util/http-request-utils'; @Injectable({ providedIn: 'root', @@ -20,8 +22,10 @@ export class CategoryService { return lastValueFrom(this.http.get(this.baseUrl)); } - public listTop4(): Promise { - return lastValueFrom(this.http.get(`${this.baseUrl}/top4`)); + public listTop(filters: CategoryFilter): Promise { + return lastValueFrom( + this.http.get(`${this.baseUrl}/top`, { ...getFilters(filters) }), + ); } public create(request: Category): Promise { diff --git a/frontend/Exence/src/app/private/transactions-and-categories/category.store.ts b/frontend/Exence/src/app/private/transactions-and-categories/category.store.ts index 39f75027..4954647d 100644 --- a/frontend/Exence/src/app/private/transactions-and-categories/category.store.ts +++ b/frontend/Exence/src/app/private/transactions-and-categories/category.store.ts @@ -1,30 +1,39 @@ import { inject, resource } from '@angular/core'; -import { signalStore, withMethods, withProps } from '@ngrx/signals'; +import { patchState, signalStore, withMethods, withProps, withState } from '@ngrx/signals'; import { Category } from '../../data-model/modules/category/Category'; import { CategorySummaryResponse } from '../../data-model/modules/category/CategorySummaryResponse'; import { SnackbarService } from '../../shared/snackbar/snackbar.service'; import { CategoryService } from './category.service'; +import { CategoryFilter } from '../../data-model/modules/category/CategoryFilter'; +import { CategoryType } from '../../data-model/modules/category/CategoryType'; + +interface CategoryStoreData { + topCategoriesFilter: CategoryFilter; +} + +const initialState: CategoryStoreData = { + topCategoriesFilter: { type: CategoryType.EXPENSE }, +}; export const CategoryStore = signalStore( // TODO when private.component is created provide it there and user change will recreate the instance and reset data { providedIn: 'root' }, - withProps(() => { - const categoryService = inject(CategoryService); + withState(initialState), + + withProps((store, categoryService = inject(CategoryService)) => { return { categoryResource: resource({ loader: async () => await categoryService.list(), }), - topCategoriesResource: resource({ - loader: async () => await categoryService.listTop4(), + topCategoriesResource: resource({ + params: () => ({ filters: store.topCategoriesFilter() }), + loader: async ({ params }) => await categoryService.listTop(params.filters), }), }; }), - withMethods(store => { - const categoryService = inject(CategoryService); - const snackbarService = inject(SnackbarService); - + withMethods((store, categoryService = inject(CategoryService), snackbarService = inject(SnackbarService)) => { function triggerReload(): void { store.categoryResource.reload(); store.topCategoriesResource.reload(); @@ -41,6 +50,13 @@ export const CategoryStore = signalStore( snackbarService.showSuccess('Category deleted successfully!'); triggerReload(); }, + + toggleTopCategoriesType(type?: CategoryType): void { + patchState(store, state => ({ + ...state, + topCategoriesFilter: { ...store.topCategoriesFilter(), type: type ?? CategoryType.EXPENSE }, + })); + }, }; }), ); diff --git a/frontend/Exence/src/app/shared/util/http-request-utils.ts b/frontend/Exence/src/app/shared/util/http-request-utils.ts index c5740bdd..4407f569 100644 --- a/frontend/Exence/src/app/shared/util/http-request-utils.ts +++ b/frontend/Exence/src/app/shared/util/http-request-utils.ts @@ -1,6 +1,7 @@ +import { CategoryFilter } from '../../data-model/modules/category/CategoryFilter'; import { TransactionFilter } from '../../data-model/modules/transaction/TransactionFilter'; -export function getFilters(filters?: TransactionFilter): Record { +export function getFilters(filters?: TransactionFilter | CategoryFilter): Record { if (filters) return JSON.parse(JSON.stringify(filters)) as Record; return {} satisfies Record; }