diff --git a/frontend/Exence/package-lock.json b/frontend/Exence/package-lock.json index 89c9e2f..88e79bc 100644 --- a/frontend/Exence/package-lock.json +++ b/frontend/Exence/package-lock.json @@ -26,6 +26,7 @@ "jwt-decode": "^4.0.0", "ng2-charts": "^8.0.0", "ngx-cookie-service": "^21.1.0", + "ngx-infinite-scroll": "^21.0.0", "or": "^0.2.0", "rxjs": "~7.8.0", "tslib": "^2.3.0", @@ -9083,6 +9084,19 @@ "@angular/core": "^21.0.0" } }, + "node_modules/ngx-infinite-scroll": { + "version": "21.0.0", + "resolved": "https://registry.npmjs.org/ngx-infinite-scroll/-/ngx-infinite-scroll-21.0.0.tgz", + "integrity": "sha512-bm1hCB7aoO9zyiNZBBOLHx9t+cwk/tLiFG5eQB8pWiNup6I2eQiHoT5B2gqlJjj4GfuER5phy5AIWl/B7YiwSQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/common": ">=21.0.0 <22.0.0", + "@angular/core": ">=21.0.0 <22.0.0" + } + }, "node_modules/node-addon-api": { "version": "7.1.1", "dev": true, diff --git a/frontend/Exence/package.json b/frontend/Exence/package.json index ca685d3..fa8ef7d 100644 --- a/frontend/Exence/package.json +++ b/frontend/Exence/package.json @@ -32,6 +32,7 @@ "jwt-decode": "^4.0.0", "ng2-charts": "^8.0.0", "ngx-cookie-service": "^21.1.0", + "ngx-infinite-scroll": "^21.0.0", "or": "^0.2.0", "rxjs": "~7.8.0", "tslib": "^2.3.0", diff --git a/frontend/Exence/src/app/private/dashboard/dashboard.component.html b/frontend/Exence/src/app/private/dashboard/dashboard.component.html index 1768232..421eb8e 100644 --- a/frontend/Exence/src/app/private/dashboard/dashboard.component.html +++ b/frontend/Exence/src/app/private/dashboard/dashboard.component.html @@ -88,6 +88,7 @@ title="Expenses" [type]="transactionTypes.EXPENSE" (dataChangedEvent)="onDataChanged()" + (onScroll)="onScroll($event, transactionTypes.EXPENSE)" /> } @else { @@ -106,6 +108,7 @@ [data]="{ transactions, categories }" title="Transactions" (dataChangedEvent)="onDataChanged()" + (onScroll)="onScroll($event)" /> diff --git a/frontend/Exence/src/app/private/dashboard/dashboard.component.ts b/frontend/Exence/src/app/private/dashboard/dashboard.component.ts index 43cde93..ea98468 100644 --- a/frontend/Exence/src/app/private/dashboard/dashboard.component.ts +++ b/frontend/Exence/src/app/private/dashboard/dashboard.component.ts @@ -48,7 +48,6 @@ export class DashboardComponent implements OnInit { readonly display = inject(DisplaySizeService); readonly navigation = inject(NavigationService); - transactionTypes = TransactionType; dateIntervals = DateInterval; @@ -65,23 +64,25 @@ export class DashboardComponent implements OnInit { topCategories?: CategorySummaryResponse[]; topCategory?: CategorySummaryResponse; + loading = false; + async ngOnInit(): Promise { await this.initialize(); } async initialize(): Promise { return Promise.all([ - this.transactionService.list(), + this.getTransactions(), + this.getTransactions(0, TransactionType.INCOME), + this.getTransactions(0, TransactionType.EXPENSE), this.categoryService.list(), this.categoryService.listTop4(), - this.transactionService.incomes(), - this.transactionService.expenses(), this.transactionService.totals(), - ]).then(([transactions, categories, top4, incomes, expenses, totals]) => { + ]).then(([transactions, incomes, expenses, categories, top4, totals]) => { this.transactions = transactions; - this.categories = categories; this.incomes = incomes; this.expenses = expenses; + this.categories = categories; this.totals = totals; this.topCategories = top4; @@ -90,7 +91,7 @@ export class DashboardComponent implements OnInit { }); } - public async openCreateTransactionDialog(transactionType: TransactionType): Promise { + async openCreateTransactionDialog(transactionType: TransactionType): Promise { const result = await this.dialog.openNonModal( CreateTransactionDialogComponent, { type: transactionType } ); @@ -102,4 +103,26 @@ export class DashboardComponent implements OnInit { async onDataChanged(): Promise { await this.initialize(); } + + async getTransactions(pageIndex = 0, type?: TransactionType): Promise> { + switch (type) { + case TransactionType.INCOME: + return this.transactionService.listIncomes(pageIndex); + case TransactionType.EXPENSE: + return this.transactionService.listExpenses(pageIndex); + default: + return this.transactionService.list(pageIndex); + } + } + + async onScroll(pageIndex: number, type?: TransactionType): Promise { + if (!this.loading && !this.transactions.last) { + this.loading = true; + try { + this.transactions = await this.getTransactions(pageIndex, type); + } finally { + this.loading = false; + } + } + } } diff --git a/frontend/Exence/src/app/private/transactions-and-categories/transaction.service.ts b/frontend/Exence/src/app/private/transactions-and-categories/transaction.service.ts index 6a60eeb..0ca93cb 100644 --- a/frontend/Exence/src/app/private/transactions-and-categories/transaction.service.ts +++ b/frontend/Exence/src/app/private/transactions-and-categories/transaction.service.ts @@ -1,10 +1,12 @@ -import { inject, Injectable } from '@angular/core'; +import { effect, inject, Injectable } from '@angular/core'; import { lastValueFrom } from 'rxjs'; 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 { TransactionTotalsResponse } from '../../data-model/modules/transaction/TransactionTotalsResponse'; +import { TransactionType } from '../../data-model/modules/transaction/TransactionType'; import { HttpService } from '../../shared/http/http.service'; +import { CurrentUserService } from '../../shared/user/current-user.service'; @Injectable({ providedIn: 'root', @@ -13,25 +15,48 @@ export class TransactionService { private readonly http = inject(HttpService); private baseUrl = '/api/transactions'; + + private transactions?: PagedResponse | null; + private incomes?: PagedResponse | null; + private expenses?: PagedResponse | null; + constructor(currentUserService: CurrentUserService) { + effect(() => { + currentUserService.user(); // to trigger user change + this.invalidateCaches(); + }); + } + public get(id: number): Promise { return lastValueFrom(this.http.get(`${this.baseUrl}/${id}`)); } - public list(): Promise> { - return lastValueFrom(this.http.get>(this.baseUrl)); + public async list(pageIndex = 0): Promise> { + if (!this.transactions || this.transactions.page !== pageIndex) { + const newPage = await lastValueFrom(this.http.get>(this.baseUrl, { page: pageIndex.toString() })); + this.transactions = this.getDataToCache(this.transactions, newPage, pageIndex); + } + return this.transactions; } public listRecurrings(): Promise { return lastValueFrom(this.http.get(`${this.baseUrl}/recurring`)); } - public incomes(): Promise> { - return lastValueFrom(this.http.get>(`${this.baseUrl}/income`)); + public async listIncomes(pageIndex = 0): Promise> { + if (!this.incomes || this.incomes.page !== pageIndex) { + const newPage = await lastValueFrom(this.http.get>(`${this.baseUrl}/income`, { page: pageIndex.toString() })); + this.incomes = this.getDataToCache(this.incomes, newPage, pageIndex); + } + return this.incomes; } - public expenses(): Promise> { - return lastValueFrom(this.http.get>(`${this.baseUrl}/expense`)); + public async listExpenses(pageIndex = 0): Promise> { + if (!this.expenses || this.expenses.page !== pageIndex) { + const newPage = await lastValueFrom(this.http.get>(`${this.baseUrl}/expense`, { page: (pageIndex ?? 0).toString() })); + this.expenses = this.getDataToCache(this.expenses, newPage, pageIndex); + } + return this.expenses; } public totals(): Promise { @@ -39,14 +64,57 @@ export class TransactionService { } public create(request: Transaction): Promise { + this.invalidateCachesByType(request.type); return lastValueFrom(this.http.post(this.baseUrl, request)); } public update(request: Transaction): Promise { + this.invalidateCachesByType(request.type); return lastValueFrom(this.http.put(`${this.baseUrl}/${request.id}`, request)); } - public delete(id: number): Promise { + public delete(id: number, type?: TransactionType): Promise { + this.invalidateCachesByType(type); return lastValueFrom(this.http.delete(`${this.baseUrl}/${id}`)); } + + public invalidateCaches(): void { + this.invalidateTransactionCache(); + this.invalidateIncomeCache(); + this.invalidateExpenseCache(); + } + + private invalidateCachesByType(type?: TransactionType): void { + switch (type) { + case TransactionType.INCOME: + return this.invalidateIncomeCache(); + case TransactionType.EXPENSE: + return this.invalidateExpenseCache(); + default: + return this.invalidateCaches(); + } + } + + public invalidateTransactionCache(): void { this.transactions = null; } + public invalidateIncomeCache(): void { this.incomes = null; this.transactions = null; } + public invalidateExpenseCache(): void { this.expenses = null; this.transactions = null; } + + + private getDataToCache(oldCached: PagedResponse | null | undefined, dataToAddToCache: PagedResponse, pageIndex?: number): PagedResponse { + // either content, or pageIndex changed + if (oldCached && pageIndex !== oldCached.page) { + const clone: PagedResponse = { ...oldCached }; + const content = [...clone.content.sort((a, b) => b.date.localeCompare(a.date)), ...dataToAddToCache.content]; + const numberOfElements = clone.numberOfElements + dataToAddToCache.numberOfElements; + + const result = { + ...dataToAddToCache, + content, + numberOfElements + }; + return result; + } else { + return dataToAddToCache; + } + } } diff --git a/frontend/Exence/src/app/private/transactions-and-categories/transactions-and-categories.component.html b/frontend/Exence/src/app/private/transactions-and-categories/transactions-and-categories.component.html index ef72480..54b46bf 100644 --- a/frontend/Exence/src/app/private/transactions-and-categories/transactions-and-categories.component.html +++ b/frontend/Exence/src/app/private/transactions-and-categories/transactions-and-categories.component.html @@ -70,7 +70,6 @@ matIcon="money_off" class="flex-grow-1" nonExpandable - paginationDisabled title="Recurring Incomes" [type]="transactionTypes.INCOME" [isRecurring]="true" @@ -84,7 +83,6 @@ matIcon="money_off" class="flex-grow-1" nonExpandable - paginationDisabled title="Recurring Expenses" [type]="transactionTypes.EXPENSE" [isRecurring]="true" @@ -99,7 +97,6 @@ matIcon="money_off" class="flex-grow-1" nonExpandable - paginationDisabled title="Recurrings" [isRecurring]="true" (dataChangedEvent)="onDataChanged()" @@ -135,6 +132,7 @@ [class.overflow-hidden]="display.isMd()" title="Transactions" (dataChangedEvent)="onDataChanged()" + (onScroll)="onScroll($event)" /> @@ -164,7 +162,6 @@ title="Categories" type="category" (dataChangedEvent)="onDataChanged()" - paginationDisabled /> 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 8caddd5..50a99fa 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 @@ -43,6 +43,7 @@ export class TransactionsAndCategoriesComponent implements OnInit { categories: Category[] = []; selectedIndex = 0; + loading = false; transactionTypes = TransactionType; @@ -66,19 +67,34 @@ export class TransactionsAndCategoriesComponent implements OnInit { }); } - public async openCreateTransactionDialog(): Promise { + async openCreateTransactionDialog(): Promise { const result = await this.dialog.openNonModal(CreateTransactionDialogComponent, undefined); if (!result) return; await this.initialize(); // to trigger data refresh in all tables (e.g. if a recurring transaction was created) } - public async openCreateCategoryDialog(): Promise { + async openCreateCategoryDialog(): Promise { const result = await this.dialog.openNonModal(CreateCategoryDialogComponent, undefined); if (!result) return; this.categories = (await this.categoryService.list()); } + async getTransactions(pageIndex: number): Promise> { + return this.transactionService.list(pageIndex); + } + async onDataChanged(): Promise { await this.initialize(); } + + async onScroll(pageIndex: number): Promise { + if (!this.loading && !this.transactions.last) { + this.loading = true; + try { + this.transactions = await this.getTransactions(pageIndex); + } finally { + this.loading = false; + } + } + } } diff --git a/frontend/Exence/src/app/shared/data-table/data-table.component.html b/frontend/Exence/src/app/shared/data-table/data-table.component.html index 045c01e..36a3832 100644 --- a/frontend/Exence/src/app/shared/data-table/data-table.component.html +++ b/frontend/Exence/src/app/shared/data-table/data-table.component.html @@ -19,7 +19,13 @@

@if (!emptyTableData) { -
+
@if (!emptyTransactionTable) {