diff --git a/frontend/Exence/src/app/private/transactions-and-categories/transactions-and-categories.component.scss b/frontend/Exence/src/app/private/transactions-and-categories/transactions-and-categories.component.scss
index b9cae63..1beab8b 100644
--- a/frontend/Exence/src/app/private/transactions-and-categories/transactions-and-categories.component.scss
+++ b/frontend/Exence/src/app/private/transactions-and-categories/transactions-and-categories.component.scss
@@ -3,10 +3,13 @@
@include displayMd {
.smaller-row {
flex-basis: 30%;
- height: 30%;
}
.bigger-row {
flex-basis: 70%;
}
+
+ mat-form-field {
+ width: 225px;
+ }
}
\ No newline at end of file
diff --git a/frontend/Exence/src/app/private/transactions-and-categories/transactions-and-categories.component.ts b/frontend/Exence/src/app/private/transactions-and-categories/transactions-and-categories.component.ts
index 8b650e5..d0d3dfc 100644
--- a/frontend/Exence/src/app/private/transactions-and-categories/transactions-and-categories.component.ts
+++ b/frontend/Exence/src/app/private/transactions-and-categories/transactions-and-categories.component.ts
@@ -1,18 +1,30 @@
import { CommonModule } from '@angular/common';
import { Component, inject, OnInit } from '@angular/core';
+import { NonNullableFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
+import { MatBadgeModule } from '@angular/material/badge';
+import { MatCheckboxModule } from '@angular/material/checkbox';
+import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
+import { MatError, MatFormFieldModule, MatLabel } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
+import { MatInputModule } from '@angular/material/input';
+import { MatSelectModule } from '@angular/material/select';
import { MatTabsModule } from '@angular/material/tabs';
import { MatTooltipModule } from '@angular/material/tooltip';
import { Category } from '../../data-model/modules/category/Category';
import { PagedResponse } from '../../data-model/modules/common/PagedResponse';
import { RecurringTransactionsResponse } from '../../data-model/modules/transaction/RecurringTransactionsResponse';
import { Transaction } from '../../data-model/modules/transaction/Transaction';
+import { TransactionFilter } from '../../data-model/modules/transaction/TransactionFilter';
import { TransactionType } from '../../data-model/modules/transaction/TransactionType';
+import { BaseComponent } from '../../shared/base-component/base.component';
import { ButtonComponent } from '../../shared/button/button.component';
import { DataTableComponent } from '../../shared/data-table/data-table.component';
import { DisplaySizeService } from '../../shared/display-size.service';
+import { FilterMenuComponent } from '../../shared/filter-menu/filter-menu.component';
+import { InputClearButtonComponent } from '../../shared/input-clear-button/input-clear-button.component';
import { SnackbarService } from '../../shared/snackbar/snackbar.service';
+import { ValidatorComponent } from '../../shared/validator/validator.component';
import { CategoryService } from '../category.service';
import { CreateCategoryDialogComponent } from './create-category-dialog/create-category-dialog.component';
import { CreateTransactionDialogComponent, CreateTransactionDialogData } from './create-transaction-dialog/create-transaction-dialog.component';
@@ -24,21 +36,49 @@ import { TransactionService } from './transaction.service';
styleUrl: './transactions-and-categories.component.scss',
imports: [
CommonModule,
+ ReactiveFormsModule,
+ MatFormFieldModule,
MatDialogModule,
MatTabsModule,
MatIconModule,
MatTooltipModule,
+ MatInputModule,
+ MatCheckboxModule,
+ MatSelectModule,
+ MatBadgeModule,
+ MatDatepickerModule,
+ MatLabel,
+ MatError,
DataTableComponent,
ButtonComponent,
+ FilterMenuComponent,
+ ValidatorComponent,
+ InputClearButtonComponent,
],
})
-export class TransactionsAndCategoriesComponent implements OnInit {
+export class TransactionsAndCategoriesComponent extends BaseComponent implements OnInit {
private readonly transactionService = inject(TransactionService);
private readonly categoryService = inject(CategoryService);
private readonly dialog = inject(MatDialog);
private readonly snackbarService = inject(SnackbarService);
+ private readonly fb = inject(NonNullableFormBuilder);
readonly display = inject(DisplaySizeService);
+ transactionFilterForm = this.fb.group({
+ searchText: this.fb.control
('', [Validators.maxLength(100)]),
+ dateRange: this.fb.group({
+ dateFrom: this.fb.control(null),
+ dateTo: this.fb.control(null),
+ }),
+ amountRange: this.fb.group({
+ min: this.fb.control(null),
+ max: this.fb.control(null),
+ }),
+ category: this.fb.control(null),
+ type: this.fb.control(null),
+ recurring: this.fb.control(false),
+ });
+
transactions: PagedResponse = {} as PagedResponse;
recurringTransactions: RecurringTransactionsResponse = {} as RecurringTransactionsResponse;
categories: Category[] = [];
@@ -46,13 +86,29 @@ export class TransactionsAndCategoriesComponent implements OnInit {
selectedIndex = 0;
transactionTypes = TransactionType;
+ transactionTypesArr = Object.values(this.transactionTypes);
+
+ get canCreateTransaction(): boolean { return !!this.categories.length; }
- get canCreateTransaction(): boolean {
- return !!this.categories.length;
+ get appliedFiltersCount(): number {
+ return Object.entries(this.transactionFilterForm.controls).reduce((sum, [key, control]) => {
+ if (key === 'dateRange' || key === 'amountRange') {
+ const groupValue = control.value as Record;
+ const hasValue = Object.values(groupValue).some(v => !!v);
+ return sum + (hasValue ? 1 : 0);
+ }
+ if (control.value) return sum + 1;
+ return sum;
+ }, 0);
}
async ngOnInit(): Promise {
await this.initialize();
+
+ this.addSubscription(this.transactionFilterForm.valueChanges.subscribe(async () => {
+ if (this.transactionFilterForm.invalid) return;
+ this.transactions = await this.getTransactions();
+ }));
}
async initialize(): Promise {
@@ -67,7 +123,7 @@ export class TransactionsAndCategoriesComponent implements OnInit {
});
}
- public openCreateTransactionDialog(): void {
+ openCreateTransactionDialog(): void {
this.dialog.open(
CreateTransactionDialogComponent, undefined
).afterClosed().subscribe(
@@ -80,7 +136,7 @@ export class TransactionsAndCategoriesComponent implements OnInit {
);
}
- public openCreateCategoryDialog(): void {
+ openCreateCategoryDialog(): void {
this.dialog.open(
CreateCategoryDialogComponent, undefined
).afterClosed().subscribe(
@@ -96,4 +152,19 @@ export class TransactionsAndCategoriesComponent implements OnInit {
async onDataChanged(): Promise {
await this.initialize();
}
+
+ getTransactions(): Promise> {
+ const formValue = this.transactionFilterForm.getRawValue();
+ const filters = {
+ keyword: formValue.searchText,
+ dateFrom: formValue.dateRange.dateFrom?.toISOString(),
+ dateTo: formValue.dateRange.dateTo?.toISOString(),
+ categoryId: formValue.category?.id,
+ type: formValue.type,
+ amountFrom: formValue.amountRange.min,
+ amountTo: formValue.amountRange.max,
+ recurring: formValue.recurring,
+ } as TransactionFilter;
+ return this.transactionService.list(filters);
+ }
}
diff --git a/frontend/Exence/src/app/shared/auth/guard/logged-in.guard.ts b/frontend/Exence/src/app/shared/auth/guard/logged-in.guard.ts
index 67b4d30..2ae794e 100644
--- a/frontend/Exence/src/app/shared/auth/guard/logged-in.guard.ts
+++ b/frontend/Exence/src/app/shared/auth/guard/logged-in.guard.ts
@@ -17,7 +17,6 @@ export const loggedInGuard: CanActivateFn = (
filter(user => user !== null),
take(1),
map((user) => {
- console.log(user)
if (user && currentUserService.isAuthenticated()) {
return true;
} else {
diff --git a/frontend/Exence/src/app/shared/data-table/data-table.component.scss b/frontend/Exence/src/app/shared/data-table/data-table.component.scss
index 8cf83b1..51b3a48 100644
--- a/frontend/Exence/src/app/shared/data-table/data-table.component.scss
+++ b/frontend/Exence/src/app/shared/data-table/data-table.component.scss
@@ -1,7 +1,7 @@
@import '../../../styles/imports.scss';
:host {
- height: 100%;
+ flex-grow: 1;
}
.table-container {
@@ -174,7 +174,6 @@ td.mat-column-actions mat-icon {
background-color: var(--primary-color);
}
-// TODO details
.detail-container {
display: flex;
diff --git a/frontend/Exence/src/app/shared/data-table/data-table.component.ts b/frontend/Exence/src/app/shared/data-table/data-table.component.ts
index 0994676..45ee761 100644
--- a/frontend/Exence/src/app/shared/data-table/data-table.component.ts
+++ b/frontend/Exence/src/app/shared/data-table/data-table.component.ts
@@ -233,7 +233,6 @@ export class DataTableComponent extends BaseComponent {
// }
}
- // TODO refactor
openCreateDialog(): void {
// All transactions
if (!this.type()) {
diff --git a/frontend/Exence/src/app/shared/error.service.ts b/frontend/Exence/src/app/shared/error.service.ts
index 56561e5..e6590f6 100644
--- a/frontend/Exence/src/app/shared/error.service.ts
+++ b/frontend/Exence/src/app/shared/error.service.ts
@@ -1,9 +1,9 @@
-import { inject, Injectable } from "@angular/core";
-import { SnackbarService } from "./snackbar/snackbar.service";
-import { HttpErrorResponse } from "@angular/common/http";
-import { ErrorResponse } from "../data-model/modules/ErrorResponse";
-import { SUPPRESS_ERROR_SNACKBAR } from "./auth/interceptors/refresh-token.interceptor";
-import { HttpSettings } from "./http/http.service";
+import { inject, Injectable } from '@angular/core';
+import { SnackbarService } from './snackbar/snackbar.service';
+import { HttpErrorResponse } from '@angular/common/http';
+import { ErrorResponse } from '../data-model/modules/ErrorResponse';
+import { SUPPRESS_ERROR_SNACKBAR } from './auth/interceptors/refresh-token.interceptor';
+import { HttpSettings } from './http/http.service';
@Injectable({
providedIn: 'root'
@@ -14,6 +14,7 @@ export class ErrorService {
handleError(errorResponse: HttpErrorResponse, settings?: HttpSettings): void {
settings = settings ?? {};
+ // eslint-disable-next-line
const suppressFromInterceptor = (errorResponse as any).context?.get?.(SUPPRESS_ERROR_SNACKBAR) ?? false;
if (!settings.suppressErrorMessage && !suppressFromInterceptor) {
diff --git a/frontend/Exence/src/app/shared/filter-menu/filter-menu.component.html b/frontend/Exence/src/app/shared/filter-menu/filter-menu.component.html
new file mode 100644
index 0000000..87141c5
--- /dev/null
+++ b/frontend/Exence/src/app/shared/filter-menu/filter-menu.component.html
@@ -0,0 +1,85 @@
+@if (display.isMd()) {
+
+ Add filter
+
+} @else {
+
+}
+
+
+
+
+
+
+
+
+
Filters
+
+
+
+
+
+
+
+ Show ({{ appliedFiltersCount() }})
+
+
+ Clear all
+
+
+
+
+
+
+
+
diff --git a/frontend/Exence/src/app/shared/filter-menu/filter-menu.component.scss b/frontend/Exence/src/app/shared/filter-menu/filter-menu.component.scss
new file mode 100644
index 0000000..52d506c
--- /dev/null
+++ b/frontend/Exence/src/app/shared/filter-menu/filter-menu.component.scss
@@ -0,0 +1,3 @@
+.menu {
+ max-width: 266px;
+}
\ No newline at end of file
diff --git a/frontend/Exence/src/app/shared/filter-menu/filter-menu.component.ts b/frontend/Exence/src/app/shared/filter-menu/filter-menu.component.ts
new file mode 100644
index 0000000..f444624
--- /dev/null
+++ b/frontend/Exence/src/app/shared/filter-menu/filter-menu.component.ts
@@ -0,0 +1,42 @@
+import { Component, inject, input, TemplateRef, viewChild } from '@angular/core';
+import { FormGroup } from '@angular/forms';
+import { MatBadgeModule } from '@angular/material/badge';
+import { MatBottomSheet, MatBottomSheetModule } from '@angular/material/bottom-sheet';
+import { MatMenuModule } from '@angular/material/menu';
+import { ButtonComponent } from '../button/button.component';
+import { DisplaySizeService } from '../display-size.service';
+import { CommonModule } from '@angular/common';
+
+@Component({
+ selector: 'ex-filter-menu',
+ templateUrl: './filter-menu.component.html',
+ styleUrl: './filter-menu.component.scss',
+ imports: [
+ CommonModule,
+ MatMenuModule,
+ MatBadgeModule,
+ MatBottomSheetModule,
+ ButtonComponent,
+ ],
+})
+export class FilterMenuComponent {
+ private readonly bottomSheet = inject(MatBottomSheet);
+ readonly display = inject(DisplaySizeService);
+
+ form = input.required();
+ appliedFiltersCount = input.required();
+
+ filterSheet = viewChild>('filterSheet');
+
+ openBottomSheet(): void {
+ this.bottomSheet.open(this.filterSheet()!);
+ }
+
+ closeSheet(): void {
+ this.bottomSheet.dismiss();
+ }
+
+ clearFilters(): void {
+ this.form().reset();
+ }
+}
\ No newline at end of file
diff --git a/frontend/Exence/src/app/shared/user/current-user.service.ts b/frontend/Exence/src/app/shared/user/current-user.service.ts
index c410849..fb01f1a 100644
--- a/frontend/Exence/src/app/shared/user/current-user.service.ts
+++ b/frontend/Exence/src/app/shared/user/current-user.service.ts
@@ -7,10 +7,10 @@ import { User } from '../../data-model/modules/auth/User';
export class CurrentUserService {
private _user: WritableSignal = signal(null);
+ isAuthenticated = computed(() => !!this._user());
+
get user(): Signal { return this._user.asReadonly() as Signal; }
set user(user: User | null | undefined) { this._user.set(user); }
-
- isAuthenticated = computed(() => !!this._user());
clearUser(): void {
this.user = undefined;
diff --git a/frontend/Exence/src/app/shared/validator/validator.component.html b/frontend/Exence/src/app/shared/validator/validator.component.html
index aff4a5b..57b0883 100644
--- a/frontend/Exence/src/app/shared/validator/validator.component.html
+++ b/frontend/Exence/src/app/shared/validator/validator.component.html
@@ -1,5 +1,5 @@
-@if (errorKey) {
- @switch (errorKey) {
+@if (errorKey()) {
+ @switch (errorKey()) {
@case ('required') {
Field required
}
@@ -13,13 +13,13 @@
Invalid email format!
}
@case ('maxlength') {
- Max length is {{ errorValue?.requiredLength }}!
+ Max length is {{ errorValue()?.requiredLength }}!
}
@case ('min') {
- Minimum value is {{ errorValue?.min }}!
+ Minimum value is {{ errorValue()?.min }}!
}
@case ('max') {
- MaximumValue value is {{ errorValue?.max }}!
+ Maximum value is {{ errorValue()?.max }}!
}
}
}
diff --git a/frontend/Exence/src/app/shared/validator/validator.component.ts b/frontend/Exence/src/app/shared/validator/validator.component.ts
index ce18598..3d1d6bb 100644
--- a/frontend/Exence/src/app/shared/validator/validator.component.ts
+++ b/frontend/Exence/src/app/shared/validator/validator.component.ts
@@ -1,5 +1,5 @@
-import { Component, input, OnInit } from '@angular/core';
-import { FormControl } from '@angular/forms';
+import { Component, input, OnInit, signal } from '@angular/core';
+import { AbstractControl } from '@angular/forms';
import { merge, of } from 'rxjs';
import { BaseComponent } from '../base-component/base.component';
@@ -15,17 +15,21 @@ interface ErrorInfo {
imports: [],
})
export class ValidatorComponent extends BaseComponent implements OnInit {
- control = input.required();
+ control = input.required();
- errorKey?: string;
- errorValue?: ErrorInfo;
+ errorKey = signal('');
+ errorValue = signal(null);
ngOnInit(): void {
this.addSubscription(merge(of(this.control().dirty), this.control().statusChanges).subscribe(
() => {
- if (!this.control().errors) return;
- this.errorKey = Object.keys(this.control().errors!)[0];
- this.errorValue = this.control().errors![this.errorKey];
+ if (!this.control().errors) {
+ this.errorKey.set('');
+ this.errorValue.set(null);
+ return;
+ }
+ this.errorKey.set(Object.keys(this.control().errors!)[0]);
+ this.errorValue.set(this.control().errors![this.errorKey()] as ErrorInfo);
}
));
}
diff --git a/frontend/Exence/src/styles/components/badge.scss b/frontend/Exence/src/styles/components/badge.scss
new file mode 100644
index 0000000..c0e343c
--- /dev/null
+++ b/frontend/Exence/src/styles/components/badge.scss
@@ -0,0 +1,7 @@
+.mat-badge-medium.mat-badge-overlap span.mat-badge-content {
+ --mat-badge-container-overlap-offset: -10px;
+ --mat-badge-text-size: 10px;
+ --mat-badge-container-padding: 1px 7px;
+ --mat-badge-background-color: var(--default-text-color);
+ --mat-badge-text-color: var(--secondary-text-color);
+}
\ No newline at end of file
diff --git a/frontend/Exence/src/styles/components/bottom-sheet.scss b/frontend/Exence/src/styles/components/bottom-sheet.scss
new file mode 100644
index 0000000..a933df6
--- /dev/null
+++ b/frontend/Exence/src/styles/components/bottom-sheet.scss
@@ -0,0 +1,7 @@
+mat-bottom-sheet-container.mat-bottom-sheet-container {
+ --mat-bottom-sheet-container-background-color: var(--app-card-color);
+ --mat-bottom-sheet-container-text-color: var(--default-text-color);
+
+ position: relative;
+ overflow: hidden;
+}
\ No newline at end of file
diff --git a/frontend/Exence/src/styles/components/error.scss b/frontend/Exence/src/styles/components/error.scss
new file mode 100644
index 0000000..98b9c38
--- /dev/null
+++ b/frontend/Exence/src/styles/components/error.scss
@@ -0,0 +1,4 @@
+mat-error.mat-mdc-form-field-error {
+ --mat-form-field-error-text-color: var(--error-color);
+ font-weight: 600;
+}
\ No newline at end of file
diff --git a/frontend/Exence/src/styles/components/form-field.scss b/frontend/Exence/src/styles/components/form-field.scss
index c59a573..da120ce 100644
--- a/frontend/Exence/src/styles/components/form-field.scss
+++ b/frontend/Exence/src/styles/components/form-field.scss
@@ -64,4 +64,23 @@ mat-error {
.mat-mdc-form-field-icon-suffix {
margin-right: 0.5rem;
+}
+
+mat-form-field.mat-mdc-form-field.search {
+ --mat-form-field-container-height: 32px;
+ --mat-form-field-outlined-outline-width: 1px;
+ --mat-form-field-container-vertical-padding: 1px;
+ --mat-form-field-outlined-container-shape: 20px;
+
+ .mat-mdc-form-field-infix {
+ min-height: unset;
+ padding: unset;
+ }
+
+ .mat-mdc-form-field-flex {
+ height: 100%;
+ display: flex;
+ align-items: center;
+ padding-left: 0.5rem;
+ }
}
\ No newline at end of file
diff --git a/frontend/Exence/src/styles/styles.scss b/frontend/Exence/src/styles/styles.scss
index 5aedb74..9259d5a 100644
--- a/frontend/Exence/src/styles/styles.scss
+++ b/frontend/Exence/src/styles/styles.scss
@@ -15,6 +15,9 @@
@import './components/dialog.scss';
@import './components/divider.scss';
@import './components/snackbar.scss';
+@import './components/badge.scss';
+@import './components/error.scss';
+@import './components/bottom-sheet.scss';
@import './fonts/material-icons.scss';
* {