Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -10,6 +12,8 @@ public interface CategoryController {

ResponseEntity<List<CategoryDTO>> getCategories();

ResponseEntity<List<CategorySummaryResponse>> getTopCategoreiesByTotalAmount(CategoryFilter filter);

ResponseEntity<CategoryDTO> createCategory(CategoryDTO categoryDTO);

ResponseEntity<CategoryDTO> updateCategory(Long id, CategoryDTO categoryDTO);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@
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;
import org.springframework.http.ResponseEntity;
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;
Expand All @@ -38,10 +40,11 @@ public ResponseEntity<List<CategoryDTO>> getCategories() {
return ResponseFactory.ok(categoryDTOs);
}

@GetMapping("/top4")
public ResponseEntity<List<CategorySummaryResponse>> getTop4CategoriesByTotalAmount() {
List<CategorySummaryResponse> top4Categories = categoryService.getTop4CategoriesByTotalAmount();
return ResponseFactory.ok(top4Categories);
@GetMapping("/top")
public ResponseEntity<List<CategorySummaryResponse>> getTopCategoreiesByTotalAmount(
@Valid @ModelAttribute CategoryFilter filter) {
List<CategorySummaryResponse> topCategories = categoryService.getTopCategoriesByTotalAmount(filter);
return ResponseFactory.ok(topCategories);
}

@PostMapping()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -15,15 +17,19 @@ public interface CategoryRepository extends JpaRepository<Category, Long> {

@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<CategorySummaryResponse> 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<CategorySummaryResponse> findTopCategoriesByTotalAmount(@Param("type") CategoryType type);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -10,7 +11,7 @@ public interface CategoryService {

List<CategoryDTO> getCategories();

List<CategorySummaryResponse> getTop4CategoriesByTotalAmount();
List<CategorySummaryResponse> getTopCategoriesByTotalAmount(CategoryFilter filter);

CategoryDTO createCategory(CategoryDTO categoryDTO);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -35,8 +36,8 @@ public List<CategoryDTO> getCategories() {
return categoryMapper.mapToCategoryDTOList(categories);
}

public List<CategorySummaryResponse> getTop4CategoriesByTotalAmount() {
return categoryRepository.findTop4CategoriesByTotalAmount();
public List<CategorySummaryResponse> getTopCategoriesByTotalAmount(CategoryFilter filter) {
return categoryRepository.findTopCategoriesByTotalAmount(filter.getType());
}

@Transactional
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { CategoryType } from './CategoryType';

export interface CategoryFilter {
type?: CategoryType;
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ <h2 class="header-2 m-0">Top categories</h2>
<ex-button iconButton matIcon="add" color="accent" (click)="openCreateCategoryDialog()" />
</mat-card-title>
</mat-card-header>
<mat-card-content>
<mat-card-content class="flex-grow-1 d-flex flex-column justify-content-between">
<div class="d-flex flex-column gap-4">
@for (category of topCategories(); track category.id) {
<div class="d-flex flex-column gap-1 align-items-center">
Expand Down Expand Up @@ -64,4 +64,17 @@ <h2 class="header-2 text-center">Add categories to discover your spending patter
</mat-card-actions>
}
}
<mat-card-footer class="p-3">
<mat-button-toggle-group
class="mt-4"
[hideSingleSelectionIndicator]="true"
(change)="onTypeChanged($event.value)"
>
@for (type of categoryTypes | enumValue; track type) {
<mat-button-toggle [value]="type" class="type-{{ type.toLowerCase() }}">
{{ type }}
</mat-button-toggle>
}
</mat-button-toggle-group>
</mat-card-footer>
</mat-card>
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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<number>();
totalIncome = input.required<number>();
topCategories = input.required<CategorySummaryResponse[]>();
categories = input.required<Category[]>();

categoryType = signal<CategoryType>(CategoryType.EXPENSE);

hasCategories = computed(() => !!this.categories().length);
hasTransactions = computed(() => !!this.topCategories().length);

categoryTypes = CategoryType;

async openCreateCategoryDialog(): Promise<void> {
await this.dialog.openNonModal(CreateCategoryDialogComponent, undefined);
}
Expand All @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ <h1 class="m-0 text-center text-md-start {{ display.isSm() ? 'fs-1' : 'fs-2' }}"
<ex-categories
class="h-100"
[totalExpense]="transactionStore.totalExpense()"
[totalIncome]="transactionStore.totalIncome()"
[categories]="categoryStore.categoryResource.value() ?? []"
[topCategories]="categoryStore.topCategoriesResource.value() ?? []"
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -20,8 +22,10 @@ export class CategoryService {
return lastValueFrom(this.http.get<Category[]>(this.baseUrl));
}

public listTop4(): Promise<CategorySummaryResponse[]> {
return lastValueFrom(this.http.get<CategorySummaryResponse[]>(`${this.baseUrl}/top4`));
public listTop(filters: CategoryFilter): Promise<CategorySummaryResponse[]> {
return lastValueFrom(
this.http.get<CategorySummaryResponse[]>(`${this.baseUrl}/top`, { ...getFilters(filters) }),
);
}

public create(request: Category): Promise<Category> {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Category[], undefined>({
loader: async () => await categoryService.list(),
}),
topCategoriesResource: resource<CategorySummaryResponse[], undefined>({
loader: async () => await categoryService.listTop4(),
topCategoriesResource: resource<CategorySummaryResponse[], { filters: CategoryFilter }>({
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();
Expand All @@ -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 },
}));
},
};
}),
);
3 changes: 2 additions & 1 deletion frontend/Exence/src/app/shared/util/http-request-utils.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> {
export function getFilters(filters?: TransactionFilter | CategoryFilter): Record<string, string> {
if (filters) return JSON.parse(JSON.stringify(filters)) as Record<string, string>;
return {} satisfies Record<string, string>;
}
Loading