From 48e239482d3bb97ab7ee3c114040ef7a0939db47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Loipf=C3=BChrer?= Date: Mon, 27 Nov 2023 22:55:53 +0100 Subject: [PATCH] feat(frontend): misc improvements and refactorings --- .github/workflows/frontend.yaml | 30 +-- .../apps/web/src/components/AccountSelect.tsx | 2 +- .../apps/web/src/components/RequireAuth.tsx | 2 +- .../apps/web/src/components/ShareSelect.tsx | 91 ++++---- .../apps/web/src/components/TagSelector.tsx | 2 +- .../accounts/AccountClearingListEntry.tsx | 6 +- .../accounts/AccountTransactionList.tsx | 2 +- .../accounts/AccountTransactionListEntry.tsx | 4 +- .../src/components/accounts/BalanceTable.tsx | 19 +- .../accounts/ClearingAccountDetail.tsx | 2 +- .../accounts/DeleteAccountModal.tsx | 4 +- .../components/groups/GroupCreateModal.tsx | 28 ++- .../components/groups/GroupDeleteModal.tsx | 2 +- .../components/groups/GroupMemberSelect.tsx | 2 +- .../components/groups/InviteLinkCreate.tsx | 2 +- .../apps/web/src/components/style/Banner.tsx | 2 +- .../style/datagrid/renderCurrency.tsx | 12 +- .../ClearingAccountList.tsx | 200 +++++++++------- .../PersonalAccountList.tsx | 167 ++++++++------ .../PersonalAccountListItem.tsx | 6 +- .../web/src/pages/auth/ConfirmEmailChange.tsx | 6 +- .../pages/auth/ConfirmPasswordRecovery.tsx | 2 +- .../src/pages/auth/ConfirmRegistration.tsx | 8 +- frontend/apps/web/src/pages/auth/Login.tsx | 23 +- frontend/apps/web/src/pages/auth/Logout.tsx | 6 +- frontend/apps/web/src/pages/auth/Register.tsx | 28 ++- .../pages/auth/RequestPasswordRecovery.tsx | 4 +- .../web/src/pages/groups/GroupSettings.tsx | 37 +-- .../TransactionDetail/ImageUploadDialog.tsx | 4 +- .../TransactionDetail/TransactionActions.tsx | 2 +- .../TransactionList/TransactionList.tsx | 213 ++++++++++-------- .../TransactionList/TransactionListItem.tsx | 6 +- frontend/apps/web/src/state/config.ts | 35 ++- frontend/libs/redux/src/lib/selectors.ts | 6 +- .../src/lib/transactions/transactionSlice.ts | 76 ++++--- frontend/libs/redux/src/lib/types.ts | 8 +- frontend/package-lock.json | 38 ---- frontend/package.json | 1 - tools/generate_dummy_data.py | 19 +- 39 files changed, 592 insertions(+), 515 deletions(-) diff --git a/.github/workflows/frontend.yaml b/.github/workflows/frontend.yaml index 19ba6b1e..d9cf884e 100644 --- a/.github/workflows/frontend.yaml +++ b/.github/workflows/frontend.yaml @@ -108,38 +108,20 @@ jobs: - name: Setup Gradle uses: gradle/gradle-build-action@v2 -# - name: Build App Bundle -# run: npx nx build-android mobile --mode release - - name: Build App APK run: npx nx build-android mobile --tasks assembleRelease - - name: Sign App APK - id: sign_app_apk - uses: r0adkll/sign-android-release@v1 - with: - releaseDirectory: frontend/apps/mobile/android/app/build/outputs/apk/release - signingKeyBase64: ${{ secrets.ANDROID_SIGNING_KEY }} - alias: ${{ secrets.ANDROID_KEY_STORE_ALIAS }} - keyStorePassword: ${{ secrets.ANDROID_KEY_STORE_PASSWORD }} - -# - name: Sign App Bundle -# id: sign_app_aab +# - name: Sign App APK +# id: sign_app_apk # uses: r0adkll/sign-android-release@v1 # with: -# releaseDirectory: frontend/apps/mobile/android/app/build/outputs/bundle/release +# releaseDirectory: frontend/apps/mobile/android/app/build/outputs/apk/release # signingKeyBase64: ${{ secrets.ANDROID_SIGNING_KEY }} # alias: ${{ secrets.ANDROID_KEY_STORE_ALIAS }} # keyStorePassword: ${{ secrets.ANDROID_KEY_STORE_PASSWORD }} -# - name: Upload App Bundle +# - name: Upload APK # uses: actions/upload-artifact@v3 # with: -# name: app-release-aab -# path: frontend/apps/mobile/android/app/build/outputs/bundle/release/app-release.aab - - - name: Upload APK - uses: actions/upload-artifact@v3 - with: - name: app-release-apk - path: ${{steps.sign_app_apk.outputs.signedReleaseFile}} +# name: app-release-apk +# path: ${{steps.sign_app_apk.outputs.signedReleaseFile}} diff --git a/frontend/apps/web/src/components/AccountSelect.tsx b/frontend/apps/web/src/components/AccountSelect.tsx index 6e5b94f2..1df267fe 100644 --- a/frontend/apps/web/src/components/AccountSelect.tsx +++ b/frontend/apps/web/src/components/AccountSelect.tsx @@ -3,7 +3,7 @@ import { Account } from "@abrechnung/types"; import { Autocomplete, Box, Popper, TextField, TextFieldProps, Typography } from "@mui/material"; import { styled } from "@mui/material/styles"; import React from "react"; -import { selectAccountSlice, useAppSelector } from "../store"; +import { selectAccountSlice, useAppSelector } from "@/store"; import { getAccountIcon } from "./style/AbrechnungIcons"; import { DisabledTextField } from "./style/DisabledTextField"; diff --git a/frontend/apps/web/src/components/RequireAuth.tsx b/frontend/apps/web/src/components/RequireAuth.tsx index 5ec395d2..af028ad3 100644 --- a/frontend/apps/web/src/components/RequireAuth.tsx +++ b/frontend/apps/web/src/components/RequireAuth.tsx @@ -1,7 +1,7 @@ import { selectIsAuthenticated } from "@abrechnung/redux"; import React from "react"; import { Navigate, useLocation } from "react-router-dom"; -import { selectAuthSlice, useAppSelector } from "../store"; +import { selectAuthSlice, useAppSelector } from "@/store"; interface Props { authFallback?: string; diff --git a/frontend/apps/web/src/components/ShareSelect.tsx b/frontend/apps/web/src/components/ShareSelect.tsx index 246f7416..7526dd9b 100644 --- a/frontend/apps/web/src/components/ShareSelect.tsx +++ b/frontend/apps/web/src/components/ShareSelect.tsx @@ -130,51 +130,44 @@ export const ShareSelect: React.FC = ({ const theme = useTheme(); const isSmallScreen = useMediaQuery((theme: Theme) => theme.breakpoints.down("sm")); + const [showEvents, setShowEvents] = React.useState(false); + const [showAdvanced, setShowAdvanced] = React.useState(false); const [searchValue, setSearchValue] = React.useState(""); - const selector = React.useCallback( - memoize((state: RootState) => { + const unfilteredAccounts = useAppSelector((state) => + selectGroupAccounts({ state: selectAccountSlice(state), groupId }) + ); + const accounts = React.useMemo(() => { + return unfilteredAccounts.filter((a) => { const isAccountShown = (accountId: number) => { - if (editable) { + if (value[accountId] !== undefined) { return true; } + + if (editable) { + return !(!showEvents && a.type === "clearing"); + } if (shouldDisplayAccount) { return shouldDisplayAccount(accountId); } - - return value[accountId] !== undefined; + return false; }; - - const accounts = selectGroupAccounts({ - state: selectAccountSlice(state), - groupId, - }); - return accounts.filter((a) => { - if (excludeAccounts && excludeAccounts.includes(a.id)) { - return false; - } - if (!isAccountShown(a.id)) { - return false; - } - if (searchValue && searchValue !== "") { - if ( - a.name.toLowerCase().includes(searchValue.toLowerCase()) || - a.description.toLowerCase().includes(searchValue.toLowerCase()) || - (a.type === "clearing" && a.date_info && a.date_info.includes(searchValue.toLowerCase())) - ) { - return true; - } - return false; - } - return true; - }); - }), - [groupId, editable, shouldDisplayAccount] - ); - - const accounts = useAppSelector(selector); - - const [showAdvanced, setShowAdvanced] = React.useState(false); + if (excludeAccounts && excludeAccounts.includes(a.id)) { + return false; + } + if (!isAccountShown(a.id)) { + return false; + } + if (searchValue && searchValue !== "") { + return ( + a.name.toLowerCase().includes(searchValue.toLowerCase()) || + a.description.toLowerCase().includes(searchValue.toLowerCase()) || + (a.type === "clearing" && a.date_info && a.date_info.includes(searchValue.toLowerCase())) + ); + } + return true; + }); + }, [value, showEvents, editable, searchValue, unfilteredAccounts, excludeAccounts, shouldDisplayAccount]); React.useEffect(() => { if (Object.values(value).reduce((showAdvanced, value) => showAdvanced || value !== 1, false)) { @@ -200,7 +193,7 @@ export const ShareSelect: React.FC = ({ } return nAccs; }, 0); - const showSearch = !isSmallScreen && accounts.length > 5; + const showSearch = !isSmallScreen && unfilteredAccounts.length > 5; const handleAccountShareChange = (accountId: number, shareValue: number) => { const newValue = { ...value }; @@ -222,12 +215,24 @@ export const ShareSelect: React.FC = ({ {nSelectedEvents > 0 && } {editable && ( - } - checked={showAdvanced} - onChange={(event: React.ChangeEvent) => setShowAdvanced(event.target.checked)} - label="Advanced" - /> + + } + checked={showEvents} + onChange={(event: React.ChangeEvent) => + setShowEvents(event.target.checked) + } + label="Show Events" + /> + } + checked={showAdvanced} + onChange={(event: React.ChangeEvent) => + setShowAdvanced(event.target.checked) + } + label="Advanced" + /> + )} diff --git a/frontend/apps/web/src/components/TagSelector.tsx b/frontend/apps/web/src/components/TagSelector.tsx index 34353d33..e9f30be8 100644 --- a/frontend/apps/web/src/components/TagSelector.tsx +++ b/frontend/apps/web/src/components/TagSelector.tsx @@ -2,7 +2,7 @@ import { selectTagsInGroup } from "@abrechnung/redux"; import { Add as AddIcon } from "@mui/icons-material"; import { Box, Checkbox, Chip, ChipProps, ListItemIcon, ListItemText, MenuItem, TextFieldProps } from "@mui/material"; import * as React from "react"; -import { useAppSelector } from "../store"; +import { useAppSelector } from "@/store"; import { AddNewTagDialog } from "./AddNewTagDialog"; import { DisabledTextField } from "./style/DisabledTextField"; diff --git a/frontend/apps/web/src/components/accounts/AccountClearingListEntry.tsx b/frontend/apps/web/src/components/accounts/AccountClearingListEntry.tsx index 68def908..29074856 100644 --- a/frontend/apps/web/src/components/accounts/AccountClearingListEntry.tsx +++ b/frontend/apps/web/src/components/accounts/AccountClearingListEntry.tsx @@ -2,9 +2,9 @@ import { selectAccountBalances, selectAccountById, selectGroupCurrencySymbol } f import { Box, ListItemAvatar, ListItemText, Tooltip, Typography } from "@mui/material"; import { DateTime } from "luxon"; import React from "react"; -import { balanceColor } from "../../core/utils"; -import { selectAccountSlice, selectGroupSlice, useAppSelector } from "../../store"; -import { getAccountLink } from "../../utils"; +import { balanceColor } from "@/core/utils"; +import { selectAccountSlice, selectGroupSlice, useAppSelector } from "@/store"; +import { getAccountLink } from "@/utils"; import { ClearingAccountIcon } from "../style/AbrechnungIcons"; import ListItemLink from "../style/ListItemLink"; diff --git a/frontend/apps/web/src/components/accounts/AccountTransactionList.tsx b/frontend/apps/web/src/components/accounts/AccountTransactionList.tsx index 06e74114..da910d78 100644 --- a/frontend/apps/web/src/components/accounts/AccountTransactionList.tsx +++ b/frontend/apps/web/src/components/accounts/AccountTransactionList.tsx @@ -3,7 +3,7 @@ import { Account, Transaction } from "@abrechnung/types"; import { Alert, List } from "@mui/material"; import { DateTime } from "luxon"; import React from "react"; -import { selectAccountSlice, selectTransactionSlice, useAppSelector } from "../../store"; +import { selectAccountSlice, selectTransactionSlice, useAppSelector } from "@/store"; import AccountClearingListEntry from "./AccountClearingListEntry"; import AccountTransactionListEntry from "./AccountTransactionListEntry"; diff --git a/frontend/apps/web/src/components/accounts/AccountTransactionListEntry.tsx b/frontend/apps/web/src/components/accounts/AccountTransactionListEntry.tsx index 29a73e58..66b43507 100644 --- a/frontend/apps/web/src/components/accounts/AccountTransactionListEntry.tsx +++ b/frontend/apps/web/src/components/accounts/AccountTransactionListEntry.tsx @@ -3,8 +3,8 @@ import { HelpOutline } from "@mui/icons-material"; import { Chip, ListItemAvatar, ListItemText, Tooltip, Typography } from "@mui/material"; import { DateTime } from "luxon"; import React from "react"; -import { balanceColor } from "../../core/utils"; -import { selectGroupSlice, selectTransactionSlice, useAppSelector } from "../../store"; +import { balanceColor } from "@/core/utils"; +import { selectGroupSlice, selectTransactionSlice, useAppSelector } from "@/store"; import { PurchaseIcon, TransferIcon } from "../style/AbrechnungIcons"; import ListItemLink from "../style/ListItemLink"; diff --git a/frontend/apps/web/src/components/accounts/BalanceTable.tsx b/frontend/apps/web/src/components/accounts/BalanceTable.tsx index 54200241..a961372f 100644 --- a/frontend/apps/web/src/components/accounts/BalanceTable.tsx +++ b/frontend/apps/web/src/components/accounts/BalanceTable.tsx @@ -1,6 +1,6 @@ import { selectAccountSlice, selectGroupSlice, useAppSelector } from "@/store"; import { selectAccountBalances, selectGroupById, selectPersonalAccounts } from "@abrechnung/redux"; -import { DataGrid, GridToolbar } from "@mui/x-data-grid"; +import { DataGrid, GridColDef, GridToolbar } from "@mui/x-data-grid"; import React from "react"; import { renderCurrency } from "../style/datagrid/renderCurrency"; @@ -17,15 +17,17 @@ export const BalanceTable: React.FC = ({ groupId }) => { const tableData = personalAccounts.map((acc) => { return { - ...acc, + id: acc.id, + name: acc.name, + description: acc.description, balance: balances[acc.id]?.balance ?? 0, totalPaid: balances[acc.id]?.totalPaid ?? 0, totalConsumed: balances[acc.id]?.totalConsumed ?? 0, }; }); - const columns = [ - { field: "id", headerName: "ID", hide: true }, + const columns: GridColDef[] = [ + { field: "id", headerName: "ID" }, { field: "name", headerName: "Name", width: 150 }, { field: "description", headerName: "Description", width: 200 }, { @@ -50,7 +52,14 @@ export const BalanceTable: React.FC = ({ groupId }) => { ; + interface Props { show: boolean; onClose: ( @@ -39,7 +37,7 @@ interface Props { export const GroupCreateModal: React.FC = ({ show, onClose }) => { const dispatch = useAppDispatch(); - const handleSubmit = (values, { setSubmitting }) => { + const handleSubmit = (values: FormValues, { setSubmitting }: FormikHelpers) => { dispatch( createGroup({ api, @@ -74,7 +72,7 @@ export const GroupCreateModal: React.FC = ({ show, onClose }) => { addUserAccountOnJoin: false, }} onSubmit={handleSubmit} - validationSchema={validationSchema} + validationSchema={toFormikValidationSchema(validationSchema)} > {({ values, diff --git a/frontend/apps/web/src/components/groups/GroupDeleteModal.tsx b/frontend/apps/web/src/components/groups/GroupDeleteModal.tsx index 7c6f0aa5..65b58b21 100644 --- a/frontend/apps/web/src/components/groups/GroupDeleteModal.tsx +++ b/frontend/apps/web/src/components/groups/GroupDeleteModal.tsx @@ -2,7 +2,7 @@ import { Group } from "@abrechnung/types"; import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle } from "@mui/material"; import React from "react"; import { toast } from "react-toastify"; -import { api } from "../../core/api"; +import { api } from "@/core/api"; interface Props { show: boolean; diff --git a/frontend/apps/web/src/components/groups/GroupMemberSelect.tsx b/frontend/apps/web/src/components/groups/GroupMemberSelect.tsx index 1cc75032..83aba919 100644 --- a/frontend/apps/web/src/components/groups/GroupMemberSelect.tsx +++ b/frontend/apps/web/src/components/groups/GroupMemberSelect.tsx @@ -2,7 +2,7 @@ import React from "react"; import { Autocomplete, Box, Popper, TextField, TextFieldProps, Typography } from "@mui/material"; import { DisabledTextField } from "../style/DisabledTextField"; import { styled } from "@mui/material/styles"; -import { selectGroupSlice, useAppSelector } from "../../store"; +import { selectGroupSlice, useAppSelector } from "@/store"; import { selectGroupMemberIds, selectGroupMemberIdToUsername } from "@abrechnung/redux"; const StyledAutocompletePopper = styled(Popper)(({ theme }) => ({ diff --git a/frontend/apps/web/src/components/groups/InviteLinkCreate.tsx b/frontend/apps/web/src/components/groups/InviteLinkCreate.tsx index 24eb2e24..20967e3f 100644 --- a/frontend/apps/web/src/components/groups/InviteLinkCreate.tsx +++ b/frontend/apps/web/src/components/groups/InviteLinkCreate.tsx @@ -15,7 +15,7 @@ import { Form, Formik } from "formik"; import { DateTime } from "luxon"; import React from "react"; import { toast } from "react-toastify"; -import { api } from "../../core/api"; +import { api } from "@/core/api"; interface Props { group: Group; diff --git a/frontend/apps/web/src/components/style/Banner.tsx b/frontend/apps/web/src/components/style/Banner.tsx index 65398536..46e7feac 100644 --- a/frontend/apps/web/src/components/style/Banner.tsx +++ b/frontend/apps/web/src/components/style/Banner.tsx @@ -1,7 +1,7 @@ import React from "react"; import { Alert, AlertTitle } from "@mui/material"; import { useRecoilValue } from "recoil"; -import { config } from "../../state/config"; +import { config } from "@/state/config"; export const Banner: React.FC = () => { const cfg = useRecoilValue(config); diff --git a/frontend/apps/web/src/components/style/datagrid/renderCurrency.tsx b/frontend/apps/web/src/components/style/datagrid/renderCurrency.tsx index 434c92ae..74f580a8 100644 --- a/frontend/apps/web/src/components/style/datagrid/renderCurrency.tsx +++ b/frontend/apps/web/src/components/style/datagrid/renderCurrency.tsx @@ -1,17 +1,17 @@ import { useTheme } from "@mui/material/styles"; import * as React from "react"; -function pnlFormatter(value, currency_symbol) { +function pnlFormatter(value: number, currency_symbol: string) { return `${value.toFixed(2)} ${currency_symbol}`; } interface CurrencyValueProps { currency_symbol: string; - value: number; + value?: number; forceColor: string; } -const CurrencyValue = React.memo(({ currency_symbol, value, forceColor }: CurrencyValueProps) => { +const CurrencyValue = React.memo(({ currency_symbol, value = 0, forceColor }: CurrencyValueProps) => { const theme = useTheme(); const positiveColor = theme.palette.mode === "light" ? theme.palette.success.dark : theme.palette.success.light; @@ -35,10 +35,10 @@ const CurrencyValue = React.memo(({ currency_symbol, value, forceColor }: Curren CurrencyValue.displayName = "CurrencyValue"; export function renderCurrency( - currency_symbol, + currency_symbol: string, forceColor = undefined -): (params: { value: number }) => React.ReactNode { - const component: React.FC<{ value: number }> = (params) => { +): (params: { value?: number }) => React.ReactNode { + const component: React.FC<{ value?: number }> = (params) => { return ; }; component.displayName = "CurrencyValue"; diff --git a/frontend/apps/web/src/pages/accounts/ClearingAccountList/ClearingAccountList.tsx b/frontend/apps/web/src/pages/accounts/ClearingAccountList/ClearingAccountList.tsx index afeba6f4..340a3e82 100644 --- a/frontend/apps/web/src/pages/accounts/ClearingAccountList/ClearingAccountList.tsx +++ b/frontend/apps/web/src/pages/accounts/ClearingAccountList/ClearingAccountList.tsx @@ -13,7 +13,9 @@ import { InputLabel, List, MenuItem, + Pagination, Select, + Stack, Theme, Tooltip, useMediaQuery, @@ -21,12 +23,12 @@ import { } from "@mui/material"; import React, { useState } from "react"; import { useNavigate } from "react-router-dom"; -import { TagSelector } from "../../../components/TagSelector"; -import { DeleteAccountModal } from "../../../components/accounts/DeleteAccountModal"; -import { MobilePaper } from "../../../components/style/mobile"; -import { useTitle } from "../../../core/utils"; -import { selectAccountSlice, selectGroupSlice, useAppDispatch, useAppSelector } from "../../../store"; -import { getAccountLink } from "../../../utils"; +import { TagSelector } from "@/components/TagSelector"; +import { DeleteAccountModal } from "@/components/accounts/DeleteAccountModal"; +import { MobilePaper } from "@/components/style/mobile"; +import { useTitle } from "@/core/utils"; +import { selectAccountSlice, selectGroupSlice, useAppDispatch, useAppSelector } from "@/store"; +import { getAccountLink } from "@/utils"; import { ClearingAccountListItem } from "./ClearingAccountListItem"; interface Props { @@ -34,6 +36,7 @@ interface Props { } const emptyList = []; +const MAX_ITEMS_PER_PAGE = 40; export const ClearingAccountList: React.FC = ({ groupId }) => { const dispatch = useAppDispatch(); @@ -58,6 +61,15 @@ export const ClearingAccountList: React.FC = ({ groupId }) => { }) ); + const [currentPage, setCurrentPage] = useState(0); + const shouldShowPagination = clearingAccounts.length > MAX_ITEMS_PER_PAGE; + const numPages = Math.ceil(clearingAccounts.length / MAX_ITEMS_PER_PAGE); + + const paginatedAccounts = clearingAccounts.slice( + currentPage * MAX_ITEMS_PER_PAGE, + (currentPage + 1) * MAX_ITEMS_PER_PAGE + ); + useTitle(`${group.name} - Events`); const [accountDeleteId, setAccountDeleteId] = useState(null); @@ -83,91 +95,105 @@ export const ClearingAccountList: React.FC = ({ groupId }) => { return ( <> - - - - + + + + + + + setSearchValue(e.target.value)} + placeholder="Search…" + inputProps={{ + "aria-label": "search", + }} + sx={{ pt: "16px" }} + endAdornment={ + + setSearchValue("")} + edge="end" + > + + + + } + /> + + Sort by + + + + + - setSearchValue(e.target.value)} - placeholder="Search…" - inputProps={{ - "aria-label": "search", - }} - sx={{ pt: "16px" }} - endAdornment={ - - setSearchValue("")} - edge="end" - > - + {!isSmallScreen && ( + + + + - - } - /> - - Sort by - - - - - + + + )} - {!isSmallScreen && ( - - - - - - - - )} - - - - {clearingAccounts.length === 0 ? ( - No Events - ) : ( - clearingAccounts.map((account) => ( - - )) + + + {paginatedAccounts.length === 0 ? ( + No Events + ) : ( + paginatedAccounts.map((account) => ( + + )) + )} + + {shouldShowPagination && ( + <> + + + setCurrentPage(value - 1)} + /> + + )} - + {permissions.canWrite && ( <> diff --git a/frontend/apps/web/src/pages/accounts/PersonalAccountList/PersonalAccountList.tsx b/frontend/apps/web/src/pages/accounts/PersonalAccountList/PersonalAccountList.tsx index b393c896..ce6509f0 100644 --- a/frontend/apps/web/src/pages/accounts/PersonalAccountList/PersonalAccountList.tsx +++ b/frontend/apps/web/src/pages/accounts/PersonalAccountList/PersonalAccountList.tsx @@ -23,7 +23,9 @@ import { InputLabel, List, MenuItem, + Pagination, Select, + Stack, Theme, Tooltip, useMediaQuery, @@ -38,6 +40,8 @@ interface Props { groupId: number; } +const MAX_ITEMS_PER_PAGE = 40; + export const PersonalAccountList: React.FC = ({ groupId }) => { const dispatch = useAppDispatch(); const navigate = useNavigate(); @@ -61,6 +65,15 @@ export const PersonalAccountList: React.FC = ({ groupId }) => { const permissions = useAppSelector((state) => selectCurrentUserPermissions({ state, groupId })); const currentUserId = useAppSelector((state) => selectCurrentUserId({ state: selectAuthSlice(state) })); + const [currentPage, setCurrentPage] = useState(0); + const shouldShowPagination = personalAccounts.length > MAX_ITEMS_PER_PAGE; + const numPages = Math.ceil(personalAccounts.length / MAX_ITEMS_PER_PAGE); + + const paginatedAccounts = personalAccounts.slice( + currentPage * MAX_ITEMS_PER_PAGE, + (currentPage + 1) * MAX_ITEMS_PER_PAGE + ); + useTitle(`${group.name} - Accounts`); const [accountDeleteId, setAccountDeleteId] = useState(null); @@ -87,80 +100,94 @@ export const PersonalAccountList: React.FC = ({ groupId }) => { return ( <> - - - - + + + + + + + setSearchValue(e.target.value)} + placeholder="Search…" + inputProps={{ + "aria-label": "search", + }} + sx={{ pt: "16px" }} + endAdornment={ + + setSearchValue("")} + edge="end" + > + + + + } + /> + + Sort by + + - setSearchValue(e.target.value)} - placeholder="Search…" - inputProps={{ - "aria-label": "search", - }} - sx={{ pt: "16px" }} - endAdornment={ - - setSearchValue("")} - edge="end" - > - + {!isSmallScreen && ( + + + + - - } - /> - - Sort by - - + + + )} - {!isSmallScreen && ( - - - - - - - - )} - - - - {personalAccounts.length === 0 ? ( - No Accounts - ) : ( - personalAccounts.map((account) => ( - - )) + + + {paginatedAccounts.length === 0 ? ( + No Accounts + ) : ( + paginatedAccounts.map((account) => ( + + )) + )} + + {shouldShowPagination && ( + <> + + + setCurrentPage(value - 1)} + /> + + )} - + {permissions.canWrite && ( <> diff --git a/frontend/apps/web/src/pages/accounts/PersonalAccountList/PersonalAccountListItem.tsx b/frontend/apps/web/src/pages/accounts/PersonalAccountList/PersonalAccountListItem.tsx index 009474d4..83ae248b 100644 --- a/frontend/apps/web/src/pages/accounts/PersonalAccountList/PersonalAccountListItem.tsx +++ b/frontend/apps/web/src/pages/accounts/PersonalAccountList/PersonalAccountListItem.tsx @@ -8,9 +8,9 @@ import { Delete, Edit } from "@mui/icons-material"; import { Chip, IconButton, ListItem, ListItemSecondaryAction, ListItemText } from "@mui/material"; import React from "react"; import { useNavigate } from "react-router-dom"; -import { ListItemLink } from "../../../components/style/ListItemLink"; -import { selectAccountSlice, selectGroupSlice, useAppDispatch, useAppSelector } from "../../../store"; -import { getAccountLink } from "../../../utils"; +import { ListItemLink } from "@/components/style/ListItemLink"; +import { selectAccountSlice, selectGroupSlice, useAppDispatch, useAppSelector } from "@/store"; +import { getAccountLink } from "@/utils"; interface Props { groupId: number; diff --git a/frontend/apps/web/src/pages/auth/ConfirmEmailChange.tsx b/frontend/apps/web/src/pages/auth/ConfirmEmailChange.tsx index 9f8ccb53..3aa4d977 100644 --- a/frontend/apps/web/src/pages/auth/ConfirmEmailChange.tsx +++ b/frontend/apps/web/src/pages/auth/ConfirmEmailChange.tsx @@ -2,9 +2,9 @@ import { Button, Typography } from "@mui/material"; import React, { useState } from "react"; import { useParams } from "react-router-dom"; import { toast } from "react-toastify"; -import Loading from "../../components/style/Loading"; -import { api } from "../../core/api"; -import { useTitle } from "../../core/utils"; +import { Loading } from "@/components/style/Loading"; +import { api } from "@/core/api"; +import { useTitle } from "@/core/utils"; export const ConfirmEmailChange: React.FC = () => { const [status, setStatus] = useState("idle"); diff --git a/frontend/apps/web/src/pages/auth/ConfirmPasswordRecovery.tsx b/frontend/apps/web/src/pages/auth/ConfirmPasswordRecovery.tsx index 0879bdb0..fd3ef90d 100644 --- a/frontend/apps/web/src/pages/auth/ConfirmPasswordRecovery.tsx +++ b/frontend/apps/web/src/pages/auth/ConfirmPasswordRecovery.tsx @@ -4,7 +4,7 @@ import { Form, Formik, FormikHelpers, FormikProps } from "formik"; import React, { useState } from "react"; import { Link as RouterLink, useParams } from "react-router-dom"; import { z } from "zod"; -import { api } from "../../core/api"; +import { api } from "@/core/api"; const validationSchema = z .object({ diff --git a/frontend/apps/web/src/pages/auth/ConfirmRegistration.tsx b/frontend/apps/web/src/pages/auth/ConfirmRegistration.tsx index 1f816650..f56b7203 100644 --- a/frontend/apps/web/src/pages/auth/ConfirmRegistration.tsx +++ b/frontend/apps/web/src/pages/auth/ConfirmRegistration.tsx @@ -1,10 +1,10 @@ import { Alert, Button, Container, Link, Typography } from "@mui/material"; import React, { useState } from "react"; import { Link as RouterLink, useParams } from "react-router-dom"; -import Loading from "../../components/style/Loading"; -import { MobilePaper } from "../../components/style/mobile"; -import { api } from "../../core/api"; -import { useTitle } from "../../core/utils"; +import { Loading } from "@/components/style/Loading"; +import { MobilePaper } from "@/components/style/mobile"; +import { api } from "@/core/api"; +import { useTitle } from "@/core/utils"; export const ConfirmRegistration: React.FC = () => { const [error, setError] = useState(null); diff --git a/frontend/apps/web/src/pages/auth/Login.tsx b/frontend/apps/web/src/pages/auth/Login.tsx index 2a287f98..6b7a8802 100644 --- a/frontend/apps/web/src/pages/auth/Login.tsx +++ b/frontend/apps/web/src/pages/auth/Login.tsx @@ -1,9 +1,9 @@ import React, { useEffect } from "react"; import { Link as RouterLink, useNavigate } from "react-router-dom"; -import { Form, Formik } from "formik"; -import { api } from "../../core/api"; +import { Form, Formik, FormikHelpers } from "formik"; +import { api } from "@/core/api"; import { toast } from "react-toastify"; -import { useQuery, useTitle } from "../../core/utils"; +import { useQuery, useTitle } from "@/core/utils"; import { Avatar, Box, @@ -17,15 +17,18 @@ import { Typography, } from "@mui/material"; import { LockOutlined } from "@mui/icons-material"; -import * as yup from "yup"; -import { useAppDispatch, useAppSelector, selectAuthSlice } from "../../store"; +import { z } from "zod"; +import { useAppDispatch, useAppSelector, selectAuthSlice } from "@/store"; import { selectIsAuthenticated, login } from "@abrechnung/redux"; +import { toFormikValidationSchema } from "@abrechnung/utils"; -const validationSchema = yup.object({ - username: yup.string().required("username is required"), - password: yup.string().required("password is required"), +const validationSchema = z.object({ + username: z.string({ required_error: "username is required" }), + password: z.string({ required_error: "password is required" }), }); +type FormValues = z.infer; + export const Login: React.FC = () => { const isLoggedIn = useAppSelector((state) => selectIsAuthenticated({ state: selectAuthSlice(state) })); const dispatch = useAppDispatch(); @@ -46,7 +49,7 @@ export const Login: React.FC = () => { } }, [isLoggedIn, navigate, query]); - const handleSubmit = (values: { username: string; password: string }, { setSubmitting }) => { + const handleSubmit = (values: FormValues, { setSubmitting }: FormikHelpers) => { const sessionName = navigator.appVersion + " " + navigator.userAgent + " " + navigator.appName; dispatch(login({ username: values.username, password: values.password, sessionName, api })) .unwrap() @@ -73,7 +76,7 @@ export const Login: React.FC = () => { {({ values, handleBlur, handleChange, handleSubmit, isSubmitting }) => (
diff --git a/frontend/apps/web/src/pages/auth/Logout.tsx b/frontend/apps/web/src/pages/auth/Logout.tsx index 93ffc715..31019a30 100644 --- a/frontend/apps/web/src/pages/auth/Logout.tsx +++ b/frontend/apps/web/src/pages/auth/Logout.tsx @@ -1,8 +1,8 @@ import React, { useEffect } from "react"; -import Loading from "../../components/style/Loading"; -import { useAppDispatch, useAppSelector, selectAuthSlice } from "../../store"; +import { Loading } from "@/components/style/Loading"; +import { useAppDispatch, useAppSelector, selectAuthSlice } from "@/store"; import { logout, selectIsAuthenticated } from "@abrechnung/redux"; -import { api } from "../../core/api"; +import { api } from "@/core/api"; import { Navigate } from "react-router-dom"; export const Logout: React.FC = () => { diff --git a/frontend/apps/web/src/pages/auth/Register.tsx b/frontend/apps/web/src/pages/auth/Register.tsx index aa001749..147590f3 100644 --- a/frontend/apps/web/src/pages/auth/Register.tsx +++ b/frontend/apps/web/src/pages/auth/Register.tsx @@ -16,18 +16,22 @@ import { Form, Formik } from "formik"; import React, { useEffect, useState } from "react"; import { Link as RouterLink, useNavigate } from "react-router-dom"; import { toast } from "react-toastify"; -import * as yup from "yup"; -import Loading from "../../components/style/Loading"; -import { api } from "../../core/api"; -import { useQuery, useTitle } from "../../core/utils"; -import { selectAuthSlice, useAppSelector } from "../../store"; +import { z } from "zod"; +import { Loading } from "@/components/style/Loading"; +import { api } from "@/core/api"; +import { useQuery, useTitle } from "@/core/utils"; +import { selectAuthSlice, useAppSelector } from "@/store"; +import { toFormikValidationSchema } from "@abrechnung/utils"; -const validationSchema = yup.object({ - username: yup.string().required("username is required"), - email: yup.string().required("email is required"), - password: yup.string().required("password is required"), +const validationSchema = z.object({ + username: z.string({ required_error: "username is required" }), + email: z.string({ required_error: "email is required" }), + password: z.string({ required_error: "password is required" }), + password2: z.string(), }); +type FormValues = z.infer; + export const Register: React.FC = () => { const loggedIn = useAppSelector((state) => selectIsAuthenticated({ state: selectAuthSlice(state) })); const [loading, setLoading] = useState(true); @@ -51,7 +55,7 @@ export const Register: React.FC = () => { } }, [loggedIn, navigate, query]); - const handleSubmit = (values, { setSubmitting }) => { + const handleSubmit = (values: FormValues, { setSubmitting }) => { // extract a potential invite token (which should be a uuid) from the query args let inviteToken = undefined; console.log(query.get("next")); @@ -85,7 +89,7 @@ export const Register: React.FC = () => { }); }; - const validate = (values) => { + const validate = (values: FormValues) => { const errors = {}; if (values.password !== values.password2) { errors["password2"] = "Passwords do not match"; @@ -114,7 +118,7 @@ export const Register: React.FC = () => { ; + interface Props { groupId: number; } @@ -56,7 +59,7 @@ export const GroupSettings: React.FC = ({ groupId }) => { setIsEditing(false); }; - const handleSubmit = (values, { setSubmitting }) => { + const handleSubmit = (values: FormValues, { setSubmitting }: FormikHelpers) => { dispatch( updateGroup({ group: { @@ -71,7 +74,7 @@ export const GroupSettings: React.FC = ({ groupId }) => { }) ) .unwrap() - .then((res) => { + .then(() => { setSubmitting(false); setIsEditing(false); }) @@ -84,7 +87,7 @@ export const GroupSettings: React.FC = ({ groupId }) => { const confirmLeaveGroup = () => { dispatch(leaveGroup({ groupId, api })) .unwrap() - .then((res) => { + .then(() => { navigate("/"); }) .catch((err) => { @@ -109,7 +112,7 @@ export const GroupSettings: React.FC = ({ groupId }) => { addUserAccountOnJoin: group.add_user_account_on_join, }} onSubmit={handleSubmit} - validationSchema={validationSchema} + validationSchema={toFormikValidationSchema(validationSchema)} enableReinitialize={true} > {({ values, handleBlur, handleChange, handleSubmit, isSubmitting }) => ( diff --git a/frontend/apps/web/src/pages/transactions/TransactionDetail/ImageUploadDialog.tsx b/frontend/apps/web/src/pages/transactions/TransactionDetail/ImageUploadDialog.tsx index 4e00cc08..cec6009b 100644 --- a/frontend/apps/web/src/pages/transactions/TransactionDetail/ImageUploadDialog.tsx +++ b/frontend/apps/web/src/pages/transactions/TransactionDetail/ImageUploadDialog.tsx @@ -15,7 +15,7 @@ import { } from "@mui/material"; import imageCompression from "browser-image-compression"; import React, { useState } from "react"; -import { useAppDispatch } from "../../../store"; +import { useAppDispatch } from "@/store"; import placeholderImg from "./PlaceholderImage.svg"; interface Props { @@ -143,7 +143,7 @@ export const ImageUploadDialog: React.FC = ({ groupId, transactionId, sho - diff --git a/frontend/apps/web/src/pages/transactions/TransactionDetail/TransactionActions.tsx b/frontend/apps/web/src/pages/transactions/TransactionDetail/TransactionActions.tsx index 80f5b0d4..6c0b0061 100644 --- a/frontend/apps/web/src/pages/transactions/TransactionDetail/TransactionActions.tsx +++ b/frontend/apps/web/src/pages/transactions/TransactionDetail/TransactionActions.tsx @@ -13,7 +13,7 @@ import { } from "@mui/material"; import React, { useState } from "react"; import { useNavigate } from "react-router-dom"; -import { selectTransactionSlice, useAppSelector } from "../../../store"; +import { selectTransactionSlice, useAppSelector } from "@/store"; interface Props { groupId: number; diff --git a/frontend/apps/web/src/pages/transactions/TransactionList/TransactionList.tsx b/frontend/apps/web/src/pages/transactions/TransactionList/TransactionList.tsx index a3b2cf6f..52f8542f 100644 --- a/frontend/apps/web/src/pages/transactions/TransactionList/TransactionList.tsx +++ b/frontend/apps/web/src/pages/transactions/TransactionList/TransactionList.tsx @@ -18,10 +18,12 @@ import { InputLabel, List, MenuItem, + Pagination, Select, SpeedDial, SpeedDialAction, SpeedDialIcon, + Stack, Theme, Tooltip, useMediaQuery, @@ -29,17 +31,19 @@ import { import { useTheme } from "@mui/material/styles"; import React, { useState } from "react"; import { useNavigate } from "react-router-dom"; -import { TagSelector } from "../../../components/TagSelector"; -import { PurchaseIcon, TransferIcon } from "../../../components/style/AbrechnungIcons"; -import { MobilePaper } from "../../../components/style/mobile"; -import { useTitle } from "../../../core/utils"; -import { selectGroupSlice, useAppDispatch, useAppSelector } from "../../../store"; +import { TagSelector } from "@/components/TagSelector"; +import { PurchaseIcon, TransferIcon } from "@/components/style/AbrechnungIcons"; +import { MobilePaper } from "@/components/style/mobile"; +import { useTitle } from "@/core/utils"; +import { selectGroupSlice, useAppDispatch, useAppSelector } from "@/store"; import { TransactionListItem } from "./TransactionListItem"; interface Props { groupId: number; } + const emptyList = []; +const MAX_ITEMS_PER_PAGE = 40; export const TransactionList: React.FC = ({ groupId }) => { const theme: Theme = useTheme(); @@ -64,6 +68,15 @@ export const TransactionList: React.FC = ({ groupId }) => { selectSortedTransactions({ state, groupId, searchTerm: searchValue, sortMode, tags: tagFilter }) ); + const [currentPage, setCurrentPage] = useState(0); + const shouldShowPagination = transactions.length > MAX_ITEMS_PER_PAGE; + const numPages = Math.ceil(transactions.length / MAX_ITEMS_PER_PAGE); + + const paginatedTransactions = transactions.slice( + currentPage * MAX_ITEMS_PER_PAGE, + (currentPage + 1) * MAX_ITEMS_PER_PAGE + ); + useTitle(`${group.name} - Transactions`); const onCreatePurchase = () => { @@ -86,98 +99,112 @@ export const TransactionList: React.FC = ({ groupId }) => { return ( <> - - - - + + + + + + + setSearchValue(e.target.value)} + placeholder="Search…" + inputProps={{ + "aria-label": "search", + }} + sx={{ pt: "16px" }} + endAdornment={ + + setSearchValue("")} + edge="end" + > + + + + } + /> + + Sort by + + + + + - setSearchValue(e.target.value)} - placeholder="Search…" - inputProps={{ - "aria-label": "search", - }} - sx={{ pt: "16px" }} - endAdornment={ - - setSearchValue("")} - edge="end" - > - + {!isSmallScreen && permissions.canWrite && ( + +
+ +
+ + + -
- } - /> - - Sort by - - - - - + + + + + + +
+ )}
- {!isSmallScreen && permissions.canWrite && ( - -
- -
- - - - - - - - - - -
- )} -
- - - {transactions.length === 0 ? ( - No Transactions - ) : ( - transactions.map((transaction) => ( - - )) + + + {paginatedTransactions.length === 0 ? ( + No Transactions + ) : ( + paginatedTransactions.map((transaction) => ( + + )) + )} + + {shouldShowPagination && ( + <> + + + setCurrentPage(value - 1)} + /> + + )} - +
{permissions.canWrite && ( ; - error?: string; -} +export type Config = z.infer; export const config = selector({ key: "config", @@ -46,7 +41,7 @@ export const config = selector({ headers: { "Content-Type": "application/json" }, }); try { - return (await configSchema.validate(await resp.data)) as Config; + return await configSchema.parseAsync(await resp.data); } catch (e) { console.log(e); return { diff --git a/frontend/libs/redux/src/lib/selectors.ts b/frontend/libs/redux/src/lib/selectors.ts index 7fa2ced0..1fa3b95b 100644 --- a/frontend/libs/redux/src/lib/selectors.ts +++ b/frontend/libs/redux/src/lib/selectors.ts @@ -14,7 +14,7 @@ import { selectClearingAccountsInternal, selectGroupAccountsInternal } from "./a import { selectGroupTransactionsWithoutWipInternal, selectGroupTransactionsWithWipInternal, - selectTransactionBalanceEffectsInternal, + selectTransactionBalanceEffects, } from "./transactions"; import { AccountSliceState, AccountState, IRootState } from "./types"; import { getGroupScopedState } from "./utils"; @@ -40,7 +40,7 @@ export const selectAccountBalanceHistory = memoize( groupId, }); const balances = selectAccountBalancesInternal({ state, groupId }); - const balanceEffects = selectTransactionBalanceEffectsInternal({ state: state.transactions, groupId }); + const balanceEffects = selectTransactionBalanceEffects({ state: state.transactions, groupId }); return computeAccountBalanceHistory(accountId, clearingAccounts, balances, transactions, balanceEffects); } ); @@ -96,7 +96,7 @@ export const selectSortedTransactions = memoize( }): Transaction[] => { const { state, groupId, sortMode, searchTerm, tags = [] } = args; const s = getGroupScopedState(state.accounts, groupId); - const balanceEffects = selectTransactionBalanceEffectsInternal({ state: state.transactions, groupId }); + const balanceEffects = selectTransactionBalanceEffects({ state: state.transactions, groupId }); const transactions = selectGroupTransactionsWithWipInternal({ state: state.transactions, groupId }); const compareFunction = getTransactionSortFunc(sortMode); // TODO: this has optimization potential diff --git a/frontend/libs/redux/src/lib/transactions/transactionSlice.ts b/frontend/libs/redux/src/lib/transactions/transactionSlice.ts index cf1e5bcb..b6403fad 100644 --- a/frontend/libs/redux/src/lib/transactions/transactionSlice.ts +++ b/frontend/libs/redux/src/lib/transactions/transactionSlice.ts @@ -1,12 +1,12 @@ import { Api, - Transaction as BackendTransaction, - TransactionPosition as BackendTransactionPosition, NewFile, NewTransactionPosition, + Transaction as BackendTransaction, + TransactionPosition as BackendTransactionPosition, UpdateFile, } from "@abrechnung/api"; -import { TransactionSortMode, computeTransactionBalanceEffect, getTransactionSortFunc } from "@abrechnung/core"; +import { computeTransactionBalanceEffect, getTransactionSortFunc, TransactionSortMode } from "@abrechnung/core"; import { FileAttachment, Transaction, @@ -15,7 +15,7 @@ import { TransactionType, } from "@abrechnung/types"; import { toISODateString } from "@abrechnung/utils"; -import { Draft, PayloadAction, createAsyncThunk, createSlice } from "@reduxjs/toolkit"; +import { createAsyncThunk, createSlice, Draft, PayloadAction } from "@reduxjs/toolkit"; import memoize from "proxy-memoize"; import { leaveGroup } from "../groups"; import { IRootState, ITransactionRootState, StateStatus, TransactionSliceState, TransactionState } from "../types"; @@ -29,10 +29,12 @@ export const initializeGroupState = (state: Draft, groupI state.byGroupId[groupId] = { transactions: { byId: {}, + balanceEffects: {}, ids: [], }, wipTransactions: { byId: {}, + balanceEffects: {}, ids: [], }, status: "loading", @@ -174,42 +176,32 @@ export const selectTransactionHasFiles = memoize( } ); -export const selectTransactionBalanceEffect = memoize( - (args: { state: TransactionSliceState; groupId: number; transactionId: number }): TransactionBalanceEffect => { - const { state, groupId, transactionId } = args; - const s = getGroupScopedState(state, groupId); - const transaction = s.wipTransactions.byId[transactionId] ?? s.transactions.byId[transactionId]; - return computeTransactionBalanceEffect(transaction); - } -); +export const selectTransactionBalanceEffect = (args: { + state: TransactionSliceState; + groupId: number; + transactionId: number; +}): TransactionBalanceEffect => { + const { state, groupId, transactionId } = args; + const s = getGroupScopedState(state, groupId); + return s.wipTransactions.balanceEffects[transactionId] ?? s.transactions.balanceEffects[transactionId]; +}; -export const selectTransactionBalanceEffectsInternal = (args: { +export const selectTransactionBalanceEffects = (args: { state: TransactionSliceState; groupId: number; }): { [k: number]: TransactionBalanceEffect } => { const { state, groupId } = args; const s = getGroupScopedState(state, groupId); - const transactionIds = s.transactions.ids; - const res = transactionIds.reduce<{ [k: number]: TransactionBalanceEffect }>((map, transactionId) => { - const transaction = s.transactions.byId[transactionId]; - const balanceEffect = computeTransactionBalanceEffect(transaction); - if (balanceEffect) { - map[transactionId] = balanceEffect; - } - return map; - }, {}); - return res; + return s.transactions.balanceEffects; }; -export const selectTransactionBalanceEffects = memoize(selectTransactionBalanceEffectsInternal); - const selectTransactionIdsInvolvingAccountInternal = (args: { state: TransactionSliceState; groupId: number; accountId: number; }): number[] => { const { state, groupId, accountId } = args; - const balanceEffects = selectTransactionBalanceEffectsInternal({ state, groupId }); + const balanceEffects = selectTransactionBalanceEffects({ state, groupId }); return Object.entries(balanceEffects) .filter(([transactionId, balanceEffect]) => { return balanceEffect[accountId] !== undefined; @@ -487,6 +479,10 @@ const updateTransactionLastChanged = (s: Draft, transactionId: wipTransaction.last_changed = new Date().toISOString(); }; +const updateTransactionBalanceEffect = (s: Draft, transactionId: number) => { + s.balanceEffects[transactionId] = computeTransactionBalanceEffect(s.byId[transactionId]); +}; + const transactionSlice = createSlice({ name: "transactions", initialState, @@ -596,6 +592,7 @@ const transactionSlice = createSlice({ is_wip: true, last_changed: new Date().toISOString(), }; + updateTransactionBalanceEffect(s.wipTransactions, transaction.id); }, wipPositionAdded: ( state, @@ -622,6 +619,7 @@ const transactionSlice = createSlice({ only_local: true, is_changed: true, }; + updateTransactionBalanceEffect(s.wipTransactions, wipTransaction.id); }, wipPositionUpdated: ( state, @@ -634,6 +632,15 @@ const transactionSlice = createSlice({ return; } if (position.id === state.nextLocalPositionId) { + if ( + position.name === "" && + position.price === 0 && + position.communist_shares === 0 && + Object.keys(position.usages).length === 0 + ) { + // in case nothing changed we do nothing + return; + } // we updated the empty position in the list state.nextLocalPositionId = state.nextLocalPositionId - 1; } @@ -651,6 +658,7 @@ const transactionSlice = createSlice({ }; } updateTransactionLastChanged(s, transactionId); + updateTransactionBalanceEffect(s.wipTransactions, wipTransaction.id); }, positionDeleted: ( state, @@ -677,11 +685,13 @@ const transactionSlice = createSlice({ }; } updateTransactionLastChanged(s, transactionId); + updateTransactionBalanceEffect(s.wipTransactions, wipTransaction.id); }, discardTransactionChange: (state, action: PayloadAction<{ groupId: number; transactionId: number }>) => { const { groupId, transactionId } = action.payload; const s = getGroupScopedState(state, groupId); removeEntity(s.wipTransactions, transactionId); + delete s.wipTransactions.balanceEffects[transactionId]; }, }, extraReducers: (builder) => { @@ -702,11 +712,16 @@ const transactionSlice = createSlice({ const groupId = action.meta.arg.groupId; const s = getGroupScopedState(state, groupId); // TODO: optimize such that we maybe only update those who have actually changed?? - const transactionsById = transactions.reduce<{ [k: number]: Transaction }>((byId, transaction) => { + s.transactions.byId = transactions.reduce<{ [k: number]: Transaction }>((byId, transaction) => { byId[transaction.id] = backendTransactionToTransaction(transaction); return byId; }, {}); - s.transactions.byId = transactionsById; + s.transactions.balanceEffects = Object.values(s.transactions.byId).reduce<{ + [id: number]: TransactionBalanceEffect; + }>((balanceEffects, transaction) => { + balanceEffects[transaction.id] = computeTransactionBalanceEffect(transaction); + return balanceEffects; + }, {}); s.transactions.ids = transactions.map((t) => t.id); s.status = "initialized"; @@ -716,19 +731,23 @@ const transactionSlice = createSlice({ const groupId = transaction.group_id; const s = getGroupScopedState(state, groupId); addEntity(s.transactions, backendTransactionToTransaction(transaction)); + updateTransactionBalanceEffect(s.transactions, transaction.id); }); builder.addCase(saveTransaction.fulfilled, (state, action) => { const { oldTransactionId, transaction } = action.payload; const groupId = transaction.group_id; const s = getGroupScopedState(state, groupId); addEntity(s.transactions, backendTransactionToTransaction(transaction)); + updateTransactionBalanceEffect(s.transactions, transaction.id); removeEntity(s.wipTransactions, oldTransactionId); + delete s.wipTransactions.balanceEffects[oldTransactionId]; }); builder.addCase(createTransaction.fulfilled, (state, action) => { const { transaction } = action.payload; const { groupId } = action.meta.arg; const s = getGroupScopedState(state, groupId); addEntity(s.wipTransactions, { ...transaction, is_wip: true }); + updateTransactionBalanceEffect(s.wipTransactions, transaction.id); }); builder.addCase(deleteTransaction.fulfilled, (state, action) => { const { transaction } = action.payload; @@ -739,6 +758,7 @@ const transactionSlice = createSlice({ addEntity(s.transactions, backendTransactionToTransaction(transaction)); } removeEntity(s.wipTransactions, transactionId); + delete s.wipTransactions.balanceEffects[transactionId]; }); builder.addCase(leaveGroup.fulfilled, (state, action) => { const { groupId } = action.meta.arg; diff --git a/frontend/libs/redux/src/lib/types.ts b/frontend/libs/redux/src/lib/types.ts index 7b69e7a8..41ec9c68 100644 --- a/frontend/libs/redux/src/lib/types.ts +++ b/frontend/libs/redux/src/lib/types.ts @@ -1,5 +1,5 @@ import { Group, GroupInvite, GroupLog, GroupMember, User } from "@abrechnung/api"; -import { Account, Transaction } from "@abrechnung/types"; +import { Account, Transaction, TransactionBalanceEffect } from "@abrechnung/types"; export const ENABLE_OFFLINE_MODE = false; @@ -97,11 +97,13 @@ export type AccountSliceState = AbrechnungInstanceAwareState & export interface TransactionState { transactions: { - byId: { [k: number]: Transaction }; + byId: { [transactionId: number]: Transaction }; + balanceEffects: { [transactionId: number]: TransactionBalanceEffect }; ids: number[]; }; wipTransactions: { - byId: { [k: number]: Transaction }; + byId: { [transactionId: number]: Transaction }; + balanceEffects: { [transactionId: number]: TransactionBalanceEffect }; ids: number[]; }; status: StateStatus; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b9325469..41143abc 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -68,7 +68,6 @@ "tslib": "^2.6.2", "typeface-roboto": "^1.1.13", "uuid": "^9.0.1", - "yup": "^1.3.2", "zod": "^3.22.4" }, "devDependencies": { @@ -32004,11 +32003,6 @@ "signal-exit": "^3.0.2" } }, - "node_modules/property-expr": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.5.tgz", - "integrity": "sha512-IJUkICM5dP5znhCckHSv30Q4b5/JA5enCtkRHYaOVOAocnH/1BQEYTC5NMfT3AVl/iXKdr3aqQbQn9DxyWknwA==" - }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -36306,11 +36300,6 @@ "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", "dev": true }, - "node_modules/tiny-case": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz", - "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==" - }, "node_modules/tiny-invariant": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.1.tgz", @@ -36421,11 +36410,6 @@ "url": "https://github.com/sponsors/Borewit" } }, - "node_modules/toposort": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", - "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==" - }, "node_modules/tough-cookie": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz", @@ -38440,28 +38424,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/yup": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/yup/-/yup-1.3.2.tgz", - "integrity": "sha512-6KCM971iQtJ+/KUaHdrhVr2LDkfhBtFPRnsG1P8F4q3uUVQ2RfEM9xekpha9aA4GXWJevjM10eDcPQ1FfWlmaQ==", - "dependencies": { - "property-expr": "^2.0.5", - "tiny-case": "^1.0.3", - "toposort": "^2.0.2", - "type-fest": "^2.19.0" - } - }, - "node_modules/yup/node_modules/type-fest": { - "version": "2.19.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", - "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", - "engines": { - "node": ">=12.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/zod": { "version": "3.22.4", "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index 0664c2a2..0ed1d51b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -68,7 +68,6 @@ "tslib": "^2.6.2", "typeface-roboto": "^1.1.13", "uuid": "^9.0.1", - "yup": "^1.3.2", "zod": "^3.22.4" }, "devDependencies": { diff --git a/tools/generate_dummy_data.py b/tools/generate_dummy_data.py index 55a6ce45..70c81713 100644 --- a/tools/generate_dummy_data.py +++ b/tools/generate_dummy_data.py @@ -11,7 +11,7 @@ from abrechnung.application.users import UserService from abrechnung.config import read_config from abrechnung.domain.accounts import AccountType, NewAccount -from abrechnung.domain.transactions import NewTransaction, TransactionType +from abrechnung.domain.transactions import NewTransaction, TransactionType, NewTransactionPosition from abrechnung.framework.database import create_db_pool @@ -94,16 +94,30 @@ async def main( max(2, people_per_transaction - 4), min(n_accounts + n_events, people_per_transaction + 4), ) + value = random.random() * 100 debitors = random.choices(account_ids, k=n_involved) debitor_shares = {k: 1.0 for k in debitors} creditor = random.choice(account_ids) creditor_shares = {creditor: 1.0} + positions = [] + n_positions = random.randint(0, 30) + for pos_i in range(n_positions): + max_position_price = value / n_positions + pos_participants = random.choices(account_ids, k=random.randint(1, n_involved)) + positions.append( + NewTransactionPosition( + name=f"Position {pos_i}", + communist_shares=0, + usages={k: 1.0 for k in pos_participants}, + price=random.random() * max_position_price, + ) + ) transaction_id = await transaction_service.create_transaction( user=user, group_id=group_id, transaction=NewTransaction( type=TransactionType.purchase, - value=random.random() * 100, + value=value, name=f"Purchase {i}", description="", billed_at=random_date(), @@ -112,6 +126,7 @@ async def main( tags=[], creditor_shares=creditor_shares, debitor_shares=debitor_shares, + new_positions=positions, ), ) transaction_ids.append(transaction_id)