diff --git a/.github/workflows/build-application.yml b/.github/workflows/build-application.yml index 9bc96270..816a2df3 100644 --- a/.github/workflows/build-application.yml +++ b/.github/workflows/build-application.yml @@ -1,7 +1,9 @@ name: Build Application on: push: + branches: [main] pull_request: + branches: [main, dev] workflow_dispatch: jobs: build: @@ -10,9 +12,6 @@ jobs: matrix: os: [ubuntu-latest] node-version: [16.x, 18.x, 20.x] - env: - REACT_APP_SUPABASE_URL: ${{ secrets.SUPABASE_URL }} - REACT_APP_SUPABASE_ANON: ${{ secrets.SUPABASE_ANON_KEY }} steps: - name: Checkout code diff --git a/index.html b/index.html index fcd34e9a..e4de8551 100644 --- a/index.html +++ b/index.html @@ -9,12 +9,14 @@ Budget-Buddy + <% if (isProd) { %> + <% } %>
diff --git a/package-lock.json b/package-lock.json index 86a3f546..bcd2a345 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.17.0", + "vite-plugin-ejs": "^1.7.0", "zod": "^3.22.4", "zustand": "^4.4.4" }, @@ -1042,7 +1043,6 @@ "cpu": [ "arm" ], - "dev": true, "optional": true, "os": [ "android" @@ -1058,7 +1058,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "android" @@ -1074,7 +1073,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "android" @@ -1090,7 +1088,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "darwin" @@ -1106,7 +1103,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "darwin" @@ -1122,7 +1118,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "freebsd" @@ -1138,7 +1133,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "freebsd" @@ -1154,7 +1148,6 @@ "cpu": [ "arm" ], - "dev": true, "optional": true, "os": [ "linux" @@ -1170,7 +1163,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -1186,7 +1178,6 @@ "cpu": [ "ia32" ], - "dev": true, "optional": true, "os": [ "linux" @@ -1202,7 +1193,6 @@ "cpu": [ "loong64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -1218,7 +1208,6 @@ "cpu": [ "mips64el" ], - "dev": true, "optional": true, "os": [ "linux" @@ -1234,7 +1223,6 @@ "cpu": [ "ppc64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -1250,7 +1238,6 @@ "cpu": [ "riscv64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -1266,7 +1253,6 @@ "cpu": [ "s390x" ], - "dev": true, "optional": true, "os": [ "linux" @@ -1282,7 +1268,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -1298,7 +1283,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "netbsd" @@ -1314,7 +1298,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "openbsd" @@ -1330,7 +1313,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "sunos" @@ -1346,7 +1328,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "win32" @@ -1362,7 +1343,6 @@ "cpu": [ "ia32" ], - "dev": true, "optional": true, "os": [ "win32" @@ -1378,7 +1358,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "win32" @@ -2460,7 +2439,6 @@ "cpu": [ "arm" ], - "dev": true, "optional": true, "os": [ "android" @@ -2473,7 +2451,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "android" @@ -2486,7 +2463,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "darwin" @@ -2499,7 +2475,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "darwin" @@ -2512,7 +2487,6 @@ "cpu": [ "arm" ], - "dev": true, "optional": true, "os": [ "linux" @@ -2525,7 +2499,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -2538,7 +2511,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -2551,7 +2523,6 @@ "cpu": [ "riscv64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -2564,7 +2535,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -2577,7 +2547,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -2590,7 +2559,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "win32" @@ -2603,7 +2571,6 @@ "cpu": [ "ia32" ], - "dev": true, "optional": true, "os": [ "win32" @@ -2616,7 +2583,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "win32" @@ -3125,7 +3091,7 @@ "version": "20.10.4", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.4.tgz", "integrity": "sha512-D08YG6rr8X90YB56tSIuBaddy/UXAA9RKJoFvrsnogAum/0pmjkgi4+2nx96A330FmioegBWmEYQ+syqCFaveg==", - "dev": true, + "devOptional": true, "dependencies": { "undici-types": "~5.26.4" } @@ -3743,6 +3709,11 @@ "node": ">=8" } }, + "node_modules/async": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.5.tgz", + "integrity": "sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -3899,7 +3870,6 @@ "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -4145,8 +4115,7 @@ "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" }, "node_modules/convert-source-map": { "version": "1.9.0", @@ -4604,6 +4573,20 @@ "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" }, + "node_modules/ejs": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.9.tgz", + "integrity": "sha512-rC+QVNMJWv+MtPgkt0y+0rVEIdbtxVADApW9JXrUVlzHetgcyczP/E7DJmWJ4fJCZF2cPcBk0laWO9ZHMG3DmQ==", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/electron-to-chromium": { "version": "1.4.609", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.609.tgz", @@ -4670,7 +4653,6 @@ "version": "0.19.8", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.8.tgz", "integrity": "sha512-l7iffQpT2OrZfH2rXIp7/FkmaeZM0vxbxN9KfiCwGYuZqzMg/JdvX26R31Zxn/Pxvsrg3Y9N6XTcnknqDyyv4w==", - "dev": true, "hasInstallScript": true, "bin": { "esbuild": "bin/esbuild" @@ -5054,6 +5036,33 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/filelist": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", + "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -5203,7 +5212,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "optional": true, "os": [ @@ -5991,6 +5999,23 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/jake": { + "version": "10.8.7", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.8.7.tgz", + "integrity": "sha512-ZDi3aP+fG/LchyBzUM804VjddnwfSfsdeYkwt8NcbKRvo4rFkjhs456iLFn3k2ZUWvNe4i48WACDbza8fhq2+w==", + "dependencies": { + "async": "^3.2.3", + "chalk": "^4.0.2", + "filelist": "^1.0.4", + "minimatch": "^3.1.2" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", @@ -7118,7 +7143,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -7144,7 +7168,6 @@ "version": "3.3.7", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", - "dev": true, "funding": [ { "type": "github", @@ -7444,8 +7467,7 @@ "node_modules/picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", - "dev": true + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" }, "node_modules/picomatch": { "version": "2.3.1", @@ -7536,7 +7558,6 @@ "version": "8.4.32", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.32.tgz", "integrity": "sha512-D/kj5JNu6oo2EIy+XL/26JEDTlIbB8hw85G8StOE6L74RQAVVP5rej6wxCNqyMbR4RkPfqvezVbPw81Ngd6Kcw==", - "dev": true, "funding": [ { "type": "opencollective", @@ -7911,7 +7932,6 @@ "version": "4.7.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.7.0.tgz", "integrity": "sha512-7Kw0dUP4BWH78zaZCqF1rPyQ8D5DSU6URG45v1dqS/faNsx9WXyess00uTOZxKr7oR/4TOjO1CPudT8L1UsEgw==", - "dev": true, "bin": { "rollup": "dist/bin/rollup" }, @@ -8115,7 +8135,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -8529,7 +8548,7 @@ "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true + "devOptional": true }, "node_modules/universalify": { "version": "0.2.0", @@ -8627,7 +8646,6 @@ "version": "5.0.7", "resolved": "https://registry.npmjs.org/vite/-/vite-5.0.7.tgz", "integrity": "sha512-B4T4rJCDPihrQo2B+h1MbeGL/k/GMAHzhQ8S0LjQ142s6/+l3hHTT095ORvsshj4QCkoWu3Xtmob5mazvakaOw==", - "dev": true, "dependencies": { "esbuild": "^0.19.3", "postcss": "^8.4.32", @@ -8678,6 +8696,17 @@ } } }, + "node_modules/vite-plugin-ejs": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/vite-plugin-ejs/-/vite-plugin-ejs-1.7.0.tgz", + "integrity": "sha512-JNP3zQDC4mSbfoJ3G73s5mmZITD8NGjUmLkq4swxyahy/W0xuokK9U9IJGXw7KCggq6UucT6hJ0p+tQrNtqTZw==", + "dependencies": { + "ejs": "^3.1.9" + }, + "peerDependencies": { + "vite": ">=5.0.0" + } + }, "node_modules/w3c-xmlserializer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", diff --git a/package.json b/package.json index 5e66b7eb..29190de0 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.17.0", + "vite-plugin-ejs": "^1.7.0", "zod": "^3.22.4", "zustand": "^4.4.4" }, diff --git a/src/components/Base/Table/Table.component.tsx b/src/components/Base/Table/Table.component.tsx index 9a451f9b..49550b9c 100644 --- a/src/components/Base/Table/Table.component.tsx +++ b/src/components/Base/Table/Table.component.tsx @@ -165,7 +165,7 @@ export const Table = ({ diff --git a/src/core/Auth/Layout/withAuthLayout.tsx b/src/core/Auth/Layout/withAuthLayout.tsx index 1cbcd0cb..7c1886be 100644 --- a/src/core/Auth/Layout/withAuthLayout.tsx +++ b/src/core/Auth/Layout/withAuthLayout.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Navigate } from 'react-router-dom'; +import { Navigate, useLocation } from 'react-router-dom'; import { FullPageLoader } from '@/components/Loading'; import { useAuthContext } from '../Auth.context'; import { AuthLayout } from './Auth.layout'; @@ -12,10 +12,19 @@ import { NotVerified } from './NotVerified.component'; */ export function withAuthLayout

(Component: React.ComponentType

) { return function WrappedComponent(props: P & { isAuthenticated?: boolean }) { + const { pathname } = useLocation(); const { loading, session } = useAuthContext(); + const loginRedirectUrl = React.useMemo(() => { + const query = new URLSearchParams({ + callbackUrl: pathname, + }); + return '/sign-in?' + query; + }, [pathname]); + if (loading) return ; - if (!session) return ; + + if (!session) return ; return ( {session.isVerified ? : } ); diff --git a/src/core/Budget/BalanceWidget.component.tsx b/src/core/Budget/BalanceWidget.component.tsx new file mode 100644 index 00000000..86a9011d --- /dev/null +++ b/src/core/Budget/BalanceWidget.component.tsx @@ -0,0 +1,116 @@ +import { Card, type TCardProps } from '@/components/Base'; +import { formatBalance } from '@/utils'; +import { type TDescription } from '@budgetbuddyde/types'; +import { Box, Divider, List, ListItem, ListItemText, Typography } from '@mui/material'; +import React from 'react'; + +export type TBalanceWidgetData = { + type: 'INCOME' | 'SPENDINGS'; + label: string; + description: TDescription; + amount: number; +}; + +export type TBalanceWidgetProps = { + cardProps?: TCardProps; + income?: TBalanceWidgetData[]; + spendings?: TBalanceWidgetData[]; +}; + +export const BalanceWidget: React.FC = ({ cardProps, income, spendings }) => { + const totalIncome: number = React.useMemo(() => { + return income?.reduce((acc, { amount }) => acc + amount, 0) ?? 0; + }, [income]); + + const totalSpendings: number = React.useMemo(() => { + return spendings?.reduce((acc, { amount }) => acc + amount, 0) ?? 0; + }, [spendings]); + + const totalBalance: number = React.useMemo(() => { + return totalIncome + totalSpendings; + }, [totalIncome, totalSpendings]); + + const groupData = React.useCallback((data: TBalanceWidgetData[]): TBalanceWidgetData[] => { + if (data.length === 0) return []; + return Object.values( + data.reduce((acc: { [key: string]: TBalanceWidgetData }, item: TBalanceWidgetData) => { + const { label, amount, type } = item; + if (acc[label]) { + acc[label].amount += amount; + } else { + acc[label] = { label, description: 'No information', type, amount }; + } + return acc; + }, {}) + ); + }, []); + + const groupedIncome = React.useMemo(() => { + return groupData(income ?? []); + }, [income]); + + const groupedSpendings = React.useMemo(() => { + return groupData(spendings ?? []); + }, [spendings]); + + return ( + + + + Balance + + + + Income} dense> + {groupedIncome.length > 0 && + groupedIncome.map(({ amount, label }) => ( + {formatBalance(amount)}} + disablePadding + > + + + ))} + + + + Spendings} dense> + {groupedSpendings.length > 0 && + groupedSpendings.map(({ amount, label }) => ( + {formatBalance(amount)}} + disablePadding + > + + + ))} + + + + + {[ + { label: 'Income', amount: totalIncome }, + { label: 'Spendings', amount: totalSpendings }, + { label: 'Balance', amount: totalBalance }, + ].map(({ label, amount }, idx, list) => ( + + {formatBalance(amount)} + + } + disablePadding + > + + + ))} + + + + ); +}; diff --git a/src/core/Budget/index.ts b/src/core/Budget/index.ts index eff0d7aa..17cbf9f9 100644 --- a/src/core/Budget/index.ts +++ b/src/core/Budget/index.ts @@ -9,3 +9,4 @@ export * from './BudgetList.component'; export * from './CategoryBudget.component'; export * from './EditBudgetDrawer.component'; export * from './StatsWrapper.component'; +export * from './BalanceWidget.component'; diff --git a/src/core/Category/Autocomplete/CategoryAutocomplete.component.tsx b/src/core/Category/Autocomplete/CategoryAutocomplete.component.tsx index 5587b23c..3ed05f8c 100644 --- a/src/core/Category/Autocomplete/CategoryAutocomplete.component.tsx +++ b/src/core/Category/Autocomplete/CategoryAutocomplete.component.tsx @@ -12,8 +12,9 @@ import { Typography, createFilterOptions, } from '@mui/material'; -import { CreateCategoryAlert, useFetchCategories } from '../'; +import { CategoryService, CreateCategoryAlert, useFetchCategories } from '../'; import { StyledAutocompleteOption } from '@/components/Base'; +import { useFetchTransactions } from '@/core/Transaction'; export type TCategoryInputOption = { label: string; @@ -74,22 +75,29 @@ export const CategoryAutocomplete: React.FC = ({ }) => { const id = React.useId(); const navigate = useNavigate(); - const { loading: loadingCategories, categories, error } = useFetchCategories(); + const { loading: loadingTransactions, transactions } = useFetchTransactions(); + const { loading: loadingCategories, categories, error: categoryError } = useFetchCategories(); - if (!loadingCategories && categories.length === 0 && !error) { - if (error) { - return ( - - Error - {String(error)} - - ); - } else return ; - } else if (!loadingCategories && error) console.error('CategoryAutocomplete: ' + error); + const options: TCategoryInputOption[] = React.useMemo(() => { + return CategoryService.sortAutocompleteOptionsByTransactionUsage(categories, transactions); + }, [categories, transactions]); + + if (categoryError) { + console.log('CategoryAutocomplete.component.tsx: categoryError: ', categoryError); + return ( + + Error + {String(!categoryError)} + + ); + } + if (!loadingCategories && categories.length === 0) { + return ; + } return ( ({ label: item.name, value: item.id }))} + options={options} onChange={(event, value, _details) => { if (!value) return; const categoryNameExists = categories.some((category) => category.name === value.label); @@ -123,9 +131,9 @@ export const CategoryAutocomplete: React.FC = ({ required={required} /> )} - disabled={loadingCategories} + disabled={loadingCategories || loadingTransactions} isOptionEqualToValue={(option, value) => option.value === value.value} - loading={loadingCategories} + loading={loadingCategories || loadingTransactions} sx={sx} /> ); diff --git a/src/core/Category/Category.service.ts b/src/core/Category/Category.service.ts index b7dd51d1..843c613a 100644 --- a/src/core/Category/Category.service.ts +++ b/src/core/Category/Category.service.ts @@ -6,12 +6,15 @@ import { type TCreateCategoryPayload, type TDeleteCategoryPayload, type TUpdateCategoryPayload, - TDeleteCategoryResponsePayload, + type TDeleteCategoryResponsePayload, ZDeleteCategoryResponsePayload, + TTransaction, } from '@budgetbuddyde/types'; import { prepareRequestOptions } from '@/utils'; import { isRunningInProdEnv } from '@/utils/isRunningInProdEnv.util'; import { IAuthContext } from '../Auth'; +import { TCategoryInputOption } from '.'; +import { subDays } from 'date-fns'; export class CategoryService { private static host = @@ -104,4 +107,51 @@ export class CategoryService { return [null, error as Error]; } } + + /** + * Sorts the autocomplete options for categories based on transaction usage. + * + * @param transactions - The list of transactions. + * @param days - The number of days to consider for transaction usage. Default is 30 days. + * @returns The sorted autocomplete options for categories. + */ + static sortAutocompleteOptionsByTransactionUsage( + categories: TCategory[], + transactions: TTransaction[], + days: number = 30 + ): TCategoryInputOption[] { + const uniqueCatgegories = categories; + const now = new Date(); + const startDate = subDays(now, days); + const categoryFrequencyMap: { [categoryId: string]: number } = {}; + + let pastNTransactions = transactions.filter(({ processedAt }) => processedAt >= startDate); + if (pastNTransactions.length < 1) pastNTransactions = transactions.slice(0, 50); + pastNTransactions.forEach(({ category: { id }, processedAt }) => { + if (processedAt >= startDate && processedAt <= now) { + categoryFrequencyMap[id] = (categoryFrequencyMap[id] || 0) + 1; + } + }); + + return this.getAutocompleteOptions( + uniqueCatgegories + .map((category) => ({ + ...category, + frequency: categoryFrequencyMap[category.id] || -1, + })) + .sort((a, b) => b.frequency - a.frequency) + ); + } + + /** + * Returns an array of autocomplete options for the given categories. + * @param categories - The array of categories. + * @returns An array of autocomplete options. + */ + static getAutocompleteOptions(categories: TCategory[]): TCategoryInputOption[] { + return categories.map(({ id, name }) => ({ + label: name, + value: id, + })); + } } diff --git a/src/core/Category/__tests__/Category.service.test.ts b/src/core/Category/__tests__/Category.service.test.ts new file mode 100644 index 00000000..f9d5891d --- /dev/null +++ b/src/core/Category/__tests__/Category.service.test.ts @@ -0,0 +1,94 @@ +import { subDays } from 'date-fns'; +import { CategoryService } from '../Category.service'; +import { type TCategory, type TTransaction } from '@budgetbuddyde/types'; +import { type TCategoryInputOption } from '../Autocomplete'; + +describe('sortAutocompleteOptionsByTransactionUsage', () => { + it('should return an empty array if categories is empty', () => { + const categories: TCategory[] = []; + const transactions: TTransaction[] = []; + const result = CategoryService.sortAutocompleteOptionsByTransactionUsage( + categories, + transactions + ); + expect(result).toEqual([]); + }); + + it('should return autocomplete options sorted by transaction usage', () => { + const categories = [ + { id: 1, name: 'Category 1' }, + { id: 2, name: 'Category 2' }, + { id: 3, name: 'Category 3' }, + ] as TCategory[]; + const transactions = [ + { category: categories[0], processedAt: new Date() }, + { category: categories[1], processedAt: new Date() }, + { category: categories[0], processedAt: new Date() }, + { category: categories[2], processedAt: new Date() }, + ] as TTransaction[]; + const result = CategoryService.sortAutocompleteOptionsByTransactionUsage( + categories, + transactions + ); + + const expected: TCategoryInputOption[] = [ + { value: 1, label: 'Category 1' }, + { value: 2, label: 'Category 2' }, + { value: 3, label: 'Category 3' }, + ]; + expect(result).toEqual(expected); + }); + + it('should return autocomplete options sorted by transaction usage within the specified days', () => { + const categories = [ + { id: 1, name: 'Category 1' }, + { id: 2, name: 'Category 2' }, + { id: 3, name: 'Category 3' }, + ] as TCategory[]; + const transactions = [ + { category: categories[0], processedAt: subDays(new Date(), 10) }, + { category: categories[1], processedAt: subDays(new Date(), 20) }, + { category: categories[0], processedAt: subDays(new Date(), 5) }, + { category: categories[2], processedAt: subDays(new Date(), 15) }, + ] as TTransaction[]; + const result = CategoryService.sortAutocompleteOptionsByTransactionUsage( + categories, + transactions, + 15 + ); + + const expected: TCategoryInputOption[] = [ + { value: 1, label: 'Category 1' }, + { value: 3, label: 'Category 3' }, + { value: 2, label: 'Category 2' }, + ]; + expect(result).toEqual(expected); + }); + + it('should return autocomplete options sorted by transaction usage with default days', () => { + const categories = [ + { id: 1, name: 'Category 1' }, + { id: 2, name: 'Category 2' }, + { id: 3, name: 'Category 3' }, + ] as TCategory[]; + const transactions = [ + { category: categories[0], processedAt: subDays(new Date(), 10) }, + { category: categories[2], processedAt: subDays(new Date(), 20) }, + { category: categories[0], processedAt: subDays(new Date(), 5) }, + { category: categories[1], processedAt: subDays(new Date(), 15) }, + { category: categories[2], processedAt: subDays(new Date(), 31) }, + { category: categories[2], processedAt: subDays(new Date(), 31) }, + ] as TTransaction[]; + const result = CategoryService.sortAutocompleteOptionsByTransactionUsage( + categories, + transactions + ); + + const expected: TCategoryInputOption[] = [ + { value: 1, label: 'Category 1' }, + { value: 2, label: 'Category 2' }, + { value: 3, label: 'Category 3' }, + ]; + expect(result).toEqual(expected); + }); +}); diff --git a/src/core/Filter/Filter.store.ts b/src/core/Filter/Filter.store.ts index e69e2230..3d22f2f6 100644 --- a/src/core/Filter/Filter.store.ts +++ b/src/core/Filter/Filter.store.ts @@ -9,8 +9,8 @@ export type TFilters = { paymentMethods: TPaymentMethod['id'][] | null; startDate: Date; endDate: Date; - priceFrom: number; - priceTo: number; + priceFrom: number | null; + priceTo: number | null; }; export const DEFAULT_FILTERS: TFilters = { @@ -19,8 +19,8 @@ export const DEFAULT_FILTERS: TFilters = { paymentMethods: null, startDate: getFirstDayOfMonth(subMonths(new Date(), 12)), endDate: getLastDayOfMonth(), - priceFrom: -99999, - priceTo: 99999, + priceFrom: null, + priceTo: null, }; export interface IFilterStore { diff --git a/src/core/PaymentMethod/Autocomplete/PaymentMethodAutocomplete.component.tsx b/src/core/PaymentMethod/Autocomplete/PaymentMethodAutocomplete.component.tsx index 36ea8200..23c43bb5 100644 --- a/src/core/PaymentMethod/Autocomplete/PaymentMethodAutocomplete.component.tsx +++ b/src/core/PaymentMethod/Autocomplete/PaymentMethodAutocomplete.component.tsx @@ -16,6 +16,8 @@ import { useFetchPaymentMethods } from '../useFetchPaymentMethods.hook'; import { CreatePaymentMethodAlert } from '../CreatePaymentMethodAlert.component'; import { StyledAutocompleteOption } from '@/components/Base'; import { getNameFromLabel } from '@/core/Category'; +import { PaymentMethodService } from '../PaymentMethod.service'; +import { useFetchTransactions } from '@/core/Transaction'; export type TPaymentMethodInputOption = { label: string; @@ -61,25 +63,39 @@ export const PaymentMethodAutocomplete: React.FC }) => { const id = React.useId(); const navigate = useNavigate(); - const { loading: loadingPaymentMethods, paymentMethods, error } = useFetchPaymentMethods(); + const { loading: loadingTransactions, transactions } = useFetchTransactions(); + const { + loading: loadingPaymentMethods, + paymentMethods, + error: paymentMethodError, + } = useFetchPaymentMethods(); - if (!loadingPaymentMethods && paymentMethods.length === 0 && !error) { - if (error) { - return ( - - Error - {String(error)} - - ); - } else return ; - } else if (!loadingPaymentMethods && error) console.error('PaymentMethodAutocomplete: ' + error); + const options: TPaymentMethodInputOption[] = React.useMemo(() => { + return PaymentMethodService.sortAutocompleteOptionsByTransactionUsage( + paymentMethods, + transactions + ); + }, [paymentMethods, transactions]); + + if (paymentMethodError) { + console.log( + 'PaymentMethodAutocomplete.component.tsx: paymentMethodError: ', + paymentMethodError + ); + return ( + + Error + {String(!paymentMethodError)} + + ); + } + if (!loadingPaymentMethods && paymentMethods.length === 0) { + return ; + } return ( ({ - label: `${item.name} ${PaymentMethodLabelSeperator} ${item.provider}`, - value: item.id, - }))} + options={options} onChange={(event, value) => { if (!value) return; const paymentMethodExists = paymentMethods.some( @@ -115,9 +131,9 @@ export const PaymentMethodAutocomplete: React.FC required={required} /> )} - disabled={loadingPaymentMethods} + disabled={loadingPaymentMethods || loadingTransactions} isOptionEqualToValue={(option, value) => option.value === value.value} - loading={loadingPaymentMethods} + loading={loadingPaymentMethods || loadingTransactions} sx={sx} /> ); diff --git a/src/core/PaymentMethod/PaymentMethod.service.ts b/src/core/PaymentMethod/PaymentMethod.service.ts index 503051c5..893bc57b 100644 --- a/src/core/PaymentMethod/PaymentMethod.service.ts +++ b/src/core/PaymentMethod/PaymentMethod.service.ts @@ -9,10 +9,13 @@ import { type TUser, type TDeletePaymentMethodResponsePayload, ZDeletePaymentMethodResponsePayload, + type TTransaction, } from '@budgetbuddyde/types'; import { prepareRequestOptions } from '@/utils'; import { isRunningInProdEnv } from '@/utils/isRunningInProdEnv.util'; import { IAuthContext } from '../Auth'; +import { PaymentMethodLabelSeperator, type TPaymentMethodInputOption } from './Autocomplete'; +import { subDays } from 'date-fns'; export class PaymentMethodService { private static host = @@ -105,4 +108,61 @@ export class PaymentMethodService { return [null, error as Error]; } } + + /** + * Sorts the autocomplete options for payment-methods based on transaction usage. + * + * @param transactions - The list of transactions. + * @param days - The number of days to consider for transaction usage. Default is 30 days. + * @returns The sorted autocomplete options for payment-methods. + */ + static sortAutocompleteOptionsByTransactionUsage( + paymentMethods: TPaymentMethod[], + transactions: TTransaction[], + days: number = 30 + ): TPaymentMethodInputOption[] { + const uniquePaymentMethods = paymentMethods; + const now = new Date(); + const startDate = subDays(now, days); + const paymentMethodFrequencyMap: { [paymentMethodId: string]: number } = {}; + + let pastNTransactions = transactions.filter(({ processedAt }) => processedAt >= startDate); + if (pastNTransactions.length < 1) pastNTransactions = transactions.slice(0, 50); + pastNTransactions.forEach(({ paymentMethod: { id }, processedAt }) => { + if (processedAt >= startDate && processedAt <= now) { + paymentMethodFrequencyMap[id] = (paymentMethodFrequencyMap[id] || 0) + 1; + } + }); + + return this.getAutocompleteOptions( + uniquePaymentMethods + .map((paymentMethod) => ({ + ...paymentMethod, + frequency: paymentMethodFrequencyMap[paymentMethod.id] || -1, + })) + .sort((a, b) => b.frequency - a.frequency) + ); + } + + /** + * Returns an array of autocomplete options for the given payment-methods. + * @param paymentMethods - The array of payment-methods. + * @returns An array of autocomplete options. + */ + static getAutocompleteOptions(paymentMethods: TPaymentMethod[]): TPaymentMethodInputOption[] { + return paymentMethods.map(({ id, name, provider }) => ({ + label: this.getAutocompleteLabel({ name, provider }), + value: id, + })); + } + + /** + * Returns the autocomplete label for a payment method. + * The autocomplete label is a combination of the payment method's name and provider. + * @param paymentMethod - The payment method object. + * @returns The autocomplete label. + */ + static getAutocompleteLabel(paymentMethod: Pick): string { + return `${paymentMethod.name} ${PaymentMethodLabelSeperator} ${paymentMethod.provider}`; + } } diff --git a/src/core/PaymentMethod/__tests__/PaymentMethod.service.test.ts b/src/core/PaymentMethod/__tests__/PaymentMethod.service.test.ts new file mode 100644 index 00000000..a2046784 --- /dev/null +++ b/src/core/PaymentMethod/__tests__/PaymentMethod.service.test.ts @@ -0,0 +1,94 @@ +import { subDays } from 'date-fns'; +import { PaymentMethodService } from '../PaymentMethod.service'; +import { type TPaymentMethod, type TTransaction } from '@budgetbuddyde/types'; +import { type TPaymentMethodInputOption } from '../Autocomplete'; + +describe('sortAutocompleteOptionsByTransactionUsage', () => { + it('should return an empty array if payment-methods is empty', () => { + const paymentMethods: TPaymentMethod[] = []; + const transactions: TTransaction[] = []; + const result = PaymentMethodService.sortAutocompleteOptionsByTransactionUsage( + paymentMethods, + transactions + ); + expect(result).toEqual([]); + }); + + it('should return autocomplete options sorted by transaction usage', () => { + const paymentMethods = [ + { id: 1, name: 'Payment Method 1', provider: 'Provider 1' }, + { id: 2, name: 'Payment Method 2', provider: 'Provider 2' }, + { id: 3, name: 'Payment Method 3', provider: 'Provider 3' }, + ] as TPaymentMethod[]; + const transactions = [ + { paymentMethod: paymentMethods[0], processedAt: new Date() }, + { paymentMethod: paymentMethods[1], processedAt: new Date() }, + { paymentMethod: paymentMethods[0], processedAt: new Date() }, + { paymentMethod: paymentMethods[2], processedAt: new Date() }, + ] as TTransaction[]; + const result = PaymentMethodService.sortAutocompleteOptionsByTransactionUsage( + paymentMethods, + transactions + ); + + const expected: TPaymentMethodInputOption[] = [ + { value: 1, label: PaymentMethodService.getAutocompleteLabel(paymentMethods[0]) }, + { value: 2, label: PaymentMethodService.getAutocompleteLabel(paymentMethods[1]) }, + { value: 3, label: PaymentMethodService.getAutocompleteLabel(paymentMethods[2]) }, + ]; + expect(result).toEqual(expected); + }); + + it('should return autocomplete options sorted by transaction usage within the specified days', () => { + const paymentMethods = [ + { id: 1, name: 'Payment Method 1', provider: 'Provider 1' }, + { id: 2, name: 'Payment Method 2', provider: 'Provider 2' }, + { id: 3, name: 'Payment Method 3', provider: 'Provider 3' }, + ] as TPaymentMethod[]; + const transactions = [ + { paymentMethod: paymentMethods[1], processedAt: subDays(new Date(), 20) }, + { paymentMethod: paymentMethods[0], processedAt: subDays(new Date(), 10) }, + { paymentMethod: paymentMethods[0], processedAt: subDays(new Date(), 5) }, + { paymentMethod: paymentMethods[2], processedAt: subDays(new Date(), 15) }, + ] as TTransaction[]; + const result = PaymentMethodService.sortAutocompleteOptionsByTransactionUsage( + paymentMethods, + transactions, + 15 + ); + + const expected: TPaymentMethodInputOption[] = [ + { value: 1, label: PaymentMethodService.getAutocompleteLabel(paymentMethods[0]) }, + { value: 3, label: PaymentMethodService.getAutocompleteLabel(paymentMethods[2]) }, + { value: 2, label: PaymentMethodService.getAutocompleteLabel(paymentMethods[1]) }, + ]; + expect(result).toEqual(expected); + }); + + it('should return autocomplete options sorted by transaction usage with default days', () => { + const paymentMethods = [ + { id: 1, name: 'Payment Method 1', provider: 'Provider 1' }, + { id: 2, name: 'Payment Method 2', provider: 'Provider 2' }, + { id: 3, name: 'Payment Method 3', provider: 'Provider 3' }, + ] as TPaymentMethod[]; + const transactions = [ + { paymentMethod: paymentMethods[0], processedAt: subDays(new Date(), 10) }, + { paymentMethod: paymentMethods[2], processedAt: subDays(new Date(), 20) }, + { paymentMethod: paymentMethods[0], processedAt: subDays(new Date(), 5) }, + { paymentMethod: paymentMethods[1], processedAt: subDays(new Date(), 15) }, + { paymentMethod: paymentMethods[2], processedAt: subDays(new Date(), 31) }, + { paymentMethod: paymentMethods[2], processedAt: subDays(new Date(), 31) }, + ] as TTransaction[]; + const result = PaymentMethodService.sortAutocompleteOptionsByTransactionUsage( + paymentMethods, + transactions + ); + + const expected: TPaymentMethodInputOption[] = [ + { value: 1, label: PaymentMethodService.getAutocompleteLabel(paymentMethods[0]) }, + { value: 2, label: PaymentMethodService.getAutocompleteLabel(paymentMethods[1]) }, + { value: 3, label: PaymentMethodService.getAutocompleteLabel(paymentMethods[2]) }, + ]; + expect(result).toEqual(expected); + }); +}); diff --git a/src/core/Transaction/CreateTransactionDrawer.component.tsx b/src/core/Transaction/CreateTransactionDrawer.component.tsx index c2af6414..92128523 100644 --- a/src/core/Transaction/CreateTransactionDrawer.component.tsx +++ b/src/core/Transaction/CreateTransactionDrawer.component.tsx @@ -16,14 +16,14 @@ import { useAuthContext } from '../Auth'; import { useSnackbarContext } from '../Snackbar'; import { TransactionService, useFetchTransactions } from '.'; import { CategoryAutocomplete, getCategoryFromList, useFetchCategories } from '../Category'; -import { ReceiverAutocomplete } from '@/components/Base'; +import { ReceiverAutocomplete, type TAutocompleteOption } from '@/components/Base'; import { PaymentMethodAutocomplete, getPaymentMethodFromList, useFetchPaymentMethods, } from '../PaymentMethod'; import { - TCreateTransactionPayload, + type TCreateTransactionPayload, type TTransaction, ZCreateTransactionPayload, } from '@budgetbuddyde/types'; @@ -67,6 +67,13 @@ export const CreateTransactionDrawer: React.FC = date: new Date(), }); + const receiverOptions: TAutocompleteOption[] = React.useMemo(() => { + return TransactionService.getUniqueReceivers(transactions).map((receiver) => ({ + label: receiver, + value: receiver, + })); + }, [transactions]); + const handler: ICreateTransactionDrawerHandler = { onClose() { onChangeOpen(false); @@ -202,10 +209,7 @@ export const CreateTransactionDrawer: React.FC = sx={FormStyle} id="receiver" label="Receiver" - options={TransactionService.getUniqueReceivers(transactions).map((receiver) => ({ - label: receiver, - value: receiver, - }))} + options={receiverOptions} defaultValue={transaction?.receiver} onValueChange={(value) => handler.onReceiverChange(String(value))} required diff --git a/src/core/Transaction/Transaction.service.ts b/src/core/Transaction/Transaction.service.ts index 14a2f09e..be0340ad 100644 --- a/src/core/Transaction/Transaction.service.ts +++ b/src/core/Transaction/Transaction.service.ts @@ -12,7 +12,7 @@ import { type TDeleteTransactionResponsePayload, ZDeleteTransactionResponsePayload, } from '@budgetbuddyde/types'; -import { format, isSameMonth } from 'date-fns'; +import { format, isSameMonth, subDays } from 'date-fns'; import { isRunningInProdEnv } from '@/utils/isRunningInProdEnv.util'; import { prepareRequestOptions } from '@/utils'; import { type IAuthContext } from '../Auth'; @@ -112,12 +112,34 @@ export class TransactionService { } /** - * Retrieves the unique receivers of a set of transactions. - * @param transactions - The transactions to retrieve the unique receivers from. - * @returns An array containing the unique receivers. + * Returns an array of unique receivers from the given transactions within a specified number of days. + * The receivers are sorted based on their frequency of occurrence in the transactions. + * + * @param transactions - The array of transactions. + * @param days - The number of days to consider for filtering the transactions. Default is 30 days. + * @returns An array of unique receivers sorted by frequency of occurrence. */ - static getUniqueReceivers(transactions: TTransaction[]): string[] { - return [...new Set(transactions.map(({ receiver }) => receiver))]; + static getUniqueReceivers(transactions: TTransaction[], days: number = 30): string[] { + const uniqueReceivers = Array.from(new Set(transactions.map(({ receiver }) => receiver))); + const now = new Date(); + const startDate = subDays(now, days); + const receiverFrequencyMap: { [receiver: string]: number } = {}; + + let pastNTransactions = transactions.filter(({ processedAt }) => processedAt >= startDate); + if (pastNTransactions.length < 1) pastNTransactions = transactions.slice(0, 50); + pastNTransactions.forEach(({ receiver, processedAt }) => { + if (processedAt >= startDate && processedAt <= now) { + receiverFrequencyMap[receiver] = (receiverFrequencyMap[receiver] || 0) + 1; + } + }); + + return uniqueReceivers + .map((receiver) => ({ + receiver, + frequency: receiverFrequencyMap[receiver] || -1, + })) + .sort((a, b) => b.frequency - a.frequency) + .map(({ receiver }) => receiver); } /** diff --git a/src/core/Transaction/__tests___/Transaction.service.test.ts b/src/core/Transaction/__tests___/Transaction.service.test.ts new file mode 100644 index 00000000..4e891817 --- /dev/null +++ b/src/core/Transaction/__tests___/Transaction.service.test.ts @@ -0,0 +1,46 @@ +import { subDays } from 'date-fns'; +import { TransactionService } from '../Transaction.service'; +import { TTransaction } from '@budgetbuddyde/types'; + +describe('getUniqueReceivers', () => { + it('should return an empty array if transactions is empty', () => { + const transactions: TTransaction[] = []; + const result = TransactionService.getUniqueReceivers(transactions); + expect(result).toEqual([]); + }); + + it('should return an array of unique receivers', () => { + const transactions = [ + { receiver: 'John', processedAt: new Date() }, + { receiver: 'Jane', processedAt: new Date() }, + { receiver: 'John', processedAt: new Date() }, + { receiver: 'Alice', processedAt: new Date() }, + ] as TTransaction[]; + const result = TransactionService.getUniqueReceivers(transactions); + expect(result).toEqual(['John', 'Jane', 'Alice']); + }); + + it('should return an array of unique receivers within the specified days', () => { + const transactions = [ + { receiver: 'John', processedAt: subDays(new Date(), 10) }, + { receiver: 'Jane', processedAt: subDays(new Date(), 20) }, + { receiver: 'John', processedAt: subDays(new Date(), 5) }, + { receiver: 'Alice', processedAt: subDays(new Date(), 15) }, + ] as TTransaction[]; + const result = TransactionService.getUniqueReceivers(transactions, 15); + expect(result).toEqual(['John', 'Alice', 'Jane']); + }); + + it('should return an array of unique receivers sorted by frequency', () => { + const transactions = [ + { receiver: 'John', processedAt: new Date() }, + { receiver: 'Jane', processedAt: new Date() }, + { receiver: 'John', processedAt: new Date() }, + { receiver: 'Alice', processedAt: new Date() }, + { receiver: 'Alice', processedAt: new Date() }, + { receiver: 'Alice', processedAt: new Date() }, + ] as TTransaction[]; + const result = TransactionService.getUniqueReceivers(transactions); + expect(result).toEqual(['Alice', 'John', 'Jane']); + }); +}); diff --git a/src/routes/Budget.route.tsx b/src/routes/Budget.route.tsx index a24bc505..1c590975 100644 --- a/src/routes/Budget.route.tsx +++ b/src/routes/Budget.route.tsx @@ -1,6 +1,8 @@ +import React from 'react'; import { ContentGrid } from '@/components/Layout'; import { withAuthLayout } from '@/core/Auth/Layout'; import { + BalanceWidget, BudgetList, BudgetProgressWrapper, StatsWrapper, @@ -11,6 +13,7 @@ import { CategoryIncomeChart } from '@/core/Category/Chart/IncomeChart.component import { Grid } from '@mui/material'; import { DailyTransactionChart } from '@/core/Transaction'; import { CircularProgress } from '@/components/Loading'; +import { SubscriptionService, useFetchSubscriptions } from '@/core/Subscription'; export const DATE_RANGE_INPUT_FORMAT = 'dd.MM'; export type TChartContentType = 'INCOME' | 'SPENDINGS'; @@ -21,11 +24,36 @@ export const ChartContentTypes = [ export const Budgets = () => { const { budgetProgress, loading: loadingBudgetProgress } = useFetchBudgetProgress(); + const { loading: loadingSubscriptions, subscriptions } = useFetchSubscriptions(); + + const getSubscriptionDataByType = React.useCallback( + (type: 'INCOME' | 'SPENDINGS') => { + return SubscriptionService.getPlannedBalanceByType(subscriptions, type) + .filter(({ paused }) => !paused) + .map((item) => ({ + type: type, + label: item.category.name, + description: item.description, + amount: item.transferAmount, + })); + }, + [subscriptions] + ); return ( + + {!loadingSubscriptions && ( + + )} diff --git a/src/routes/PaymentMethods.route.tsx b/src/routes/PaymentMethods.route.tsx index efe04429..dfa56373 100644 --- a/src/routes/PaymentMethods.route.tsx +++ b/src/routes/PaymentMethods.route.tsx @@ -132,7 +132,7 @@ export const PaymentMethods = () => { return ( - + isLoading={loadingPaymentMethods} title="Payment Methods" diff --git a/src/routes/SignIn.route.tsx b/src/routes/SignIn.route.tsx index 0d978dfb..baeab400 100644 --- a/src/routes/SignIn.route.tsx +++ b/src/routes/SignIn.route.tsx @@ -7,10 +7,11 @@ import { Card, PasswordInput } from '@/components/Base'; import { StackedIconButton } from '@/components/StackedIconButton.component'; import { AppLogo } from '@/components/AppLogo.component'; import { withUnauthentificatedLayout } from '@/core/Auth/Layout'; -import { useNavigate } from 'react-router-dom'; +import { useLocation, useNavigate } from 'react-router-dom'; import { type TSignInPayload, ZSignInPayload } from '@budgetbuddyde/types'; const SignIn = () => { + const location = useLocation(); const navigate = useNavigate(); const { session, setSession } = useAuthContext(); const { showSnackbar } = useSnackbarContext(); @@ -20,27 +21,35 @@ const SignIn = () => { inputChange: (event: React.ChangeEvent) => { setForm((prev) => ({ ...prev, [event.target.name]: event.target.value })); }, - formSubmit: async (event: React.FormEvent) => { - event.preventDefault(); + formSubmit: React.useCallback( + async (event: React.FormEvent) => { + event.preventDefault(); - try { - const parsedForm = ZSignInPayload.safeParse(form); - if (!parsedForm.success) throw new Error(parsedForm.error.message); - const payload: TSignInPayload = parsedForm.data; + try { + const parsedForm = ZSignInPayload.safeParse(form); + if (!parsedForm.success) throw new Error(parsedForm.error.message); + const payload: TSignInPayload = parsedForm.data; - const [session, error] = await AuthService.signIn(payload); - if (error) throw error; - if (!session) throw new Error('No session returned'); - setSession(session); - showSnackbar({ message: 'Authentification successfull' }); - navigate('/'); - } catch (error) { - console.error(error); - showSnackbar({ - message: error instanceof Error ? error.message : 'Authentification failed', - }); - } - }, + const [session, error] = await AuthService.signIn(payload); + if (error) throw error; + if (!session) throw new Error('No session returned'); + setSession(session); + showSnackbar({ message: 'Authentification successfull' }); + if (location.search) { + const query = new URLSearchParams(location.search.substring(1)); + if (query.get('callbackUrl')) navigate(query.get('callbackUrl')!); + return; + } + navigate('/'); + } catch (error) { + console.error(error); + showSnackbar({ + message: error instanceof Error ? error.message : 'Authentification failed', + }); + } + }, + [form, setSession, showSnackbar, navigate, location] + ), }; React.useEffect(() => { diff --git a/src/utils/filter.util.ts b/src/utils/filter.util.ts index 0f5bb50a..6670c0dd 100644 --- a/src/utils/filter.util.ts +++ b/src/utils/filter.util.ts @@ -46,11 +46,11 @@ export function filterTransactions( } if (filter.priceFrom != null) { - transactions = transactions.filter(({ transferAmount }) => transferAmount >= filter.priceFrom); + transactions = transactions.filter(({ transferAmount }) => transferAmount >= filter.priceFrom!); } if (filter.priceTo != null) { - transactions = transactions.filter(({ transferAmount }) => transferAmount <= filter.priceTo); + transactions = transactions.filter(({ transferAmount }) => transferAmount <= filter.priceTo!); } return transactions; diff --git a/vite.config.ts b/vite.config.ts index f65162ee..4ce36843 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,4 +1,5 @@ import { defineConfig, type CommonServerOptions } from 'vite'; +import { ViteEjsPlugin } from 'vite-plugin-ejs'; import path from 'path'; import react from '@vitejs/plugin-react-swc'; // import dns from 'dns'; @@ -39,5 +40,13 @@ export default defineConfig({ build: { outDir: 'build', }, - plugins: [react()], + plugins: [ + react(), + ViteEjsPlugin((config) => { + return { + ...config, + isProd: config.mode === 'production', + }; + }), + ], });