From cd92f22e490397b6774e26b09da1d01810e27aa4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Loipf=C3=BChrer?= Date: Mon, 12 Aug 2024 21:18:45 +0200 Subject: [PATCH 1/2] fix(web): improve position table account sorting --- .../apps/web/src/components/NumericInput.tsx | 2 +- frontend/apps/web/src/main.tsx | 22 +- .../TransactionDetail/TransactionDetail.tsx | 3 +- .../purchase/PositionTableRow.tsx | 142 ++++++++++++ .../purchase/TransactionPositions.tsx | 212 ++++-------------- .../TransactionDetail/purchase/types.ts | 7 + 6 files changed, 208 insertions(+), 180 deletions(-) create mode 100644 frontend/apps/web/src/pages/transactions/TransactionDetail/purchase/PositionTableRow.tsx create mode 100644 frontend/apps/web/src/pages/transactions/TransactionDetail/purchase/types.ts diff --git a/frontend/apps/web/src/components/NumericInput.tsx b/frontend/apps/web/src/components/NumericInput.tsx index d6554e16..c55dbc46 100644 --- a/frontend/apps/web/src/components/NumericInput.tsx +++ b/frontend/apps/web/src/components/NumericInput.tsx @@ -46,7 +46,7 @@ export const NumericInput: React.FC = ({ value, isCurrency, o onBlur={onInternalBlur} onKeyUp={onKeyUp} variant="standard" - inputProps={{ inputmode: "numeric" }} + inputProps={{ inputMode: "numeric" }} {...props} /> ); diff --git a/frontend/apps/web/src/main.tsx b/frontend/apps/web/src/main.tsx index 8f27db15..160dc5d5 100644 --- a/frontend/apps/web/src/main.tsx +++ b/frontend/apps/web/src/main.tsx @@ -10,15 +10,15 @@ import { ConfigProvider } from "./core/config"; const root = ReactDOM.createRoot(document.getElementById("root") as HTMLElement); root.render( - - - } persistor={persistor}> - }> - - - - - - - + // + + } persistor={persistor}> + }> + + + + + + + // ); diff --git a/frontend/apps/web/src/pages/transactions/TransactionDetail/TransactionDetail.tsx b/frontend/apps/web/src/pages/transactions/TransactionDetail/TransactionDetail.tsx index 75d8e2ae..be44e605 100644 --- a/frontend/apps/web/src/pages/transactions/TransactionDetail/TransactionDetail.tsx +++ b/frontend/apps/web/src/pages/transactions/TransactionDetail/TransactionDetail.tsx @@ -21,7 +21,8 @@ import { toast } from "react-toastify"; import { typeToFlattenedError, z } from "zod"; import { TransactionActions } from "./TransactionActions"; import { TransactionMetadata } from "./TransactionMetadata"; -import { ValidationErrors as PositionValidationErrors, TransactionPositions } from "./purchase/TransactionPositions"; +import { TransactionPositions } from "./purchase/TransactionPositions"; +import { ValidationErrors as PositionValidationErrors } from "./purchase/types"; import { useTranslation } from "react-i18next"; interface Props { diff --git a/frontend/apps/web/src/pages/transactions/TransactionDetail/purchase/PositionTableRow.tsx b/frontend/apps/web/src/pages/transactions/TransactionDetail/purchase/PositionTableRow.tsx new file mode 100644 index 00000000..7caf99b6 --- /dev/null +++ b/frontend/apps/web/src/pages/transactions/TransactionDetail/purchase/PositionTableRow.tsx @@ -0,0 +1,142 @@ +import { NumericInput } from "@/components/NumericInput"; +import { TextInput } from "@/components/TextInput"; +import { Account, TransactionPosition } from "@abrechnung/types"; +import { ContentCopy, Delete } from "@mui/icons-material"; +import { Checkbox, FormHelperText, IconButton, InputAdornment, TableCell, TableRow, useTheme } from "@mui/material"; +import React from "react"; +import { PositionValidationError } from "./types"; + +interface PositionTableRowProps { + currencySymbol: string; + position: TransactionPosition; + updatePosition: ( + position: TransactionPosition, + newName: string, + newPrice: number, + newcommunist_shares: number + ) => void; + shownAccounts: Account[]; + showAdvanced: boolean; + copyPosition: (position: TransactionPosition) => void; + updatePositionUsage: (position: TransactionPosition, accountID: number, usages: number) => void; + showAccountSelect: boolean; + showAddAccount: boolean; + deletePosition: (position: TransactionPosition) => void; + validationError?: PositionValidationError; +} + +export const PositionTableRow: React.FC = ({ + position, + updatePosition, + shownAccounts, + showAdvanced, + copyPosition, + updatePositionUsage, + showAccountSelect, + showAddAccount, + deletePosition, + validationError, + currencySymbol, +}) => { + const theme = useTheme(); + + const error = validationError !== undefined; + + return ( + + + {validationError && validationError.formErrors && ( + + {validationError.formErrors} + + )} + {validationError && validationError.fieldErrors["communist_shares"] && ( + + {validationError.fieldErrors["communist_shares"]} + + )} + {validationError && validationError.fieldErrors["usages"] && ( + + {validationError.fieldErrors["usages"]} + + )} + updatePosition(position, value, position.price, position.communist_shares)} + /> + + + updatePosition(position, position.name, value, position.communist_shares)} + InputProps={{ endAdornment: {currencySymbol} }} + /> + + {shownAccounts.map((account) => ( + + {showAdvanced ? ( + updatePositionUsage(position, account.id, value)} + inputProps={{ tabIndex: -1 }} + /> + ) : ( + + updatePositionUsage(position, account.id, event.target.checked ? 1 : 0) + } + inputProps={{ tabIndex: -1 }} + /> + )} + + ))} + {showAccountSelect && } + {showAddAccount && } + + {showAdvanced ? ( + updatePosition(position, position.name, position.price, value)} + error={validationError && !!validationError.fieldErrors["communist_shares"]} + inputProps={{ tabIndex: -1 }} + /> + ) : ( + + updatePosition(position, position.name, position.price, event.target.checked ? 1 : 0) + } + inputProps={{ tabIndex: -1 }} + /> + )} + + + copyPosition(position)} tabIndex={-1}> + + + deletePosition(position)} tabIndex={-1}> + + + + + ); +}; diff --git a/frontend/apps/web/src/pages/transactions/TransactionDetail/purchase/TransactionPositions.tsx b/frontend/apps/web/src/pages/transactions/TransactionDetail/purchase/TransactionPositions.tsx index af9161ba..537d626c 100644 --- a/frontend/apps/web/src/pages/transactions/TransactionDetail/purchase/TransactionPositions.tsx +++ b/frontend/apps/web/src/pages/transactions/TransactionDetail/purchase/TransactionPositions.tsx @@ -1,11 +1,11 @@ import { AccountSelect } from "@/components/AccountSelect"; -import { NumericInput } from "@/components/NumericInput"; -import { TextInput } from "@/components/TextInput"; import { MobilePaper } from "@/components/style/mobile"; import { useFormatCurrency } from "@/hooks"; import { RootState, selectAccountSlice, selectTransactionSlice, useAppDispatch, useAppSelector } from "@/store"; +import { getAccountSortFunc } from "@abrechnung/core"; import { positionDeleted, + selectAccountIdToAccountMap, selectGroupAccounts, selectTransactionBalanceEffect, selectTransactionById, @@ -13,12 +13,11 @@ import { wipPositionAdded, wipPositionUpdated, } from "@abrechnung/redux"; -import { Account, PositionValidator, TransactionPosition } from "@abrechnung/types"; -import { Add, ContentCopy, Delete } from "@mui/icons-material"; +import { Account, TransactionPosition } from "@abrechnung/types"; +import { Add } from "@mui/icons-material"; import { Checkbox, FormControlLabel, - FormHelperText, Grid, IconButton, Table, @@ -28,147 +27,12 @@ import { TableHead, TableRow, Typography, - useTheme, } from "@mui/material"; import memoize from "proxy-memoize"; import React, { useState } from "react"; import { useTranslation } from "react-i18next"; -import { typeToFlattenedError, z } from "zod"; - -interface PositionTableRowProps { - position: TransactionPosition; - updatePosition: ( - position: TransactionPosition, - newName: string, - newPrice: number, - newcommunist_shares: number - ) => void; - transactionAccounts: number[]; - showAdvanced: boolean; - copyPosition: (position: TransactionPosition) => void; - updatePositionUsage: (position: TransactionPosition, accountID: number, usages: number) => void; - showAccountSelect: boolean; - showAddAccount: boolean; - deletePosition: (position: TransactionPosition) => void; - validationError?: PositionValidationError; -} - -const PositionTableRow: React.FC = ({ - position, - updatePosition, - transactionAccounts, - showAdvanced, - copyPosition, - updatePositionUsage, - showAccountSelect, - showAddAccount, - deletePosition, - validationError, -}) => { - const theme = useTheme(); - - const error = validationError !== undefined; - - return ( - - - {validationError && validationError.formErrors && ( - - {validationError.formErrors} - - )} - {validationError && validationError.fieldErrors.communist_shares && ( - - {validationError.fieldErrors.communist_shares} - - )} - {validationError && validationError.fieldErrors.usages && ( - - {validationError.fieldErrors.usages} - - )} - updatePosition(position, value, position.price, position.communist_shares)} - /> - - - updatePosition(position, position.name, value, position.communist_shares)} - /> - - {transactionAccounts.map((accountID) => ( - - {showAdvanced ? ( - updatePositionUsage(position, accountID, value)} - inputProps={{ tabIndex: -1 }} - /> - ) : ( - updatePositionUsage(position, accountID, event.target.checked ? 1 : 0)} - inputProps={{ tabIndex: -1 }} - /> - )} - - ))} - {showAccountSelect && } - {showAddAccount && } - - {showAdvanced ? ( - updatePosition(position, position.name, position.price, value)} - error={validationError && !!validationError.fieldErrors.communist_shares} - inputProps={{ tabIndex: -1 }} - /> - ) : ( - - updatePosition(position, position.name, position.price, event.target.checked ? 1 : 0) - } - inputProps={{ tabIndex: -1 }} - /> - )} - - - copyPosition(position)} tabIndex={-1}> - - - deletePosition(position)} tabIndex={-1}> - - - - - ); -}; - -type PositionValidationError = typeToFlattenedError>; -export type ValidationErrors = { - [positionId: number]: PositionValidationError; -}; +import { ValidationErrors } from "./types"; +import { PositionTableRow } from "./PositionTableRow"; interface TransactionPositionsProps { groupId: number; @@ -202,6 +66,9 @@ export const TransactionPositions: React.FC = ({ const { t } = useTranslation(); const formatCurrency = useFormatCurrency(); const accounts = useAppSelector((state) => selectGroupAccounts({ state: selectAccountSlice(state), groupId })); + const accountIDMap = useAppSelector((state) => + selectAccountIdToAccountMap({ state: selectAccountSlice(state), groupId }) + ); const transaction = useAppSelector((state) => selectTransactionById({ state: selectTransactionSlice(state), groupId, transactionId }) ); @@ -217,26 +84,36 @@ export const TransactionPositions: React.FC = ({ // find all accounts that take part in the transaction, either via debitor shares or purchase items // TODO: should we add creditor accounts as well? - const positionAccounts: number[] = Array.from( - new Set( - positions - .map((item) => Object.keys(item.usages)) - .flat() - .map((id) => parseInt(id)) - ) - ); const [additionalPurchaseItemAccounts, setAdditionalPurchaseItemAccounts] = useState([]); - const transactionAccounts: number[] = Array.from( - new Set( - Object.keys(transaction.debitor_shares) - .map((id) => parseInt(id)) - .concat(positionAccounts) - .concat(additionalPurchaseItemAccounts) - ) - ); - const showAddAccount = transactionAccounts.length < accounts.length; + const { shownAccounts, shownAccountIDs } = React.useMemo(() => { + let accountIDsToShow: number[] = Array.from( + new Set( + positions + .map((item) => Object.keys(item.usages)) + .flat() + .map((id) => parseInt(id)) + ) + ); + if (transaction.is_wip) { + accountIDsToShow = Array.from( + new Set( + Object.keys(transaction.debitor_shares) + .map((id) => parseInt(id)) + .concat(accountIDsToShow) + .concat(additionalPurchaseItemAccounts) + ) + ); + } + + return { + shownAccounts: accountIDsToShow.map((id) => accountIDMap[id]).sort(getAccountSortFunc("name")), + shownAccountIDs: accountIDsToShow, + }; + }, [transaction, positions, additionalPurchaseItemAccounts, accountIDMap]); + + const showAddAccount = shownAccounts.length < accounts.length; const [showAccountSelect, setShowAccountSelect] = useState(false); @@ -297,9 +174,9 @@ export const TransactionPositions: React.FC = ({ {t("common.name")} {t("common.price")} - {(transaction.is_wip ? transactionAccounts : positionAccounts).map((accountID) => ( - - {accounts.find((account) => account.id === accountID).name} + {shownAccounts.map((acc) => ( + + {acc.name} ))} {transaction.is_wip && ( @@ -308,7 +185,7 @@ export const TransactionPositions: React.FC = ({ @@ -332,8 +209,9 @@ export const TransactionPositions: React.FC = ({ = ({ {formatCurrency(position.price, transaction.currency_symbol)} - {positionAccounts.map((accountID) => ( + {shownAccountIDs.map((accountID) => ( {positionsHaveComplexShares ? ( position.usages[accountID] !== undefined ? ( @@ -381,7 +259,7 @@ export const TransactionPositions: React.FC = ({ {formatCurrency(totalPositionValue, transaction.currency_symbol)} - {(transaction.is_wip ? transactionAccounts : positionAccounts).map((accountID) => ( + {shownAccountIDs.map((accountID) => ( {formatCurrency(purchaseItemSumForAccount(accountID), transaction.currency_symbol)} @@ -407,7 +285,7 @@ export const TransactionPositions: React.FC = ({ {formatCurrency(sharedTransactionValue, transaction.currency_symbol)} - {(transaction.is_wip ? transactionAccounts : positionAccounts).map((accountID) => ( + {shownAccountIDs.map((accountID) => ( ))} diff --git a/frontend/apps/web/src/pages/transactions/TransactionDetail/purchase/types.ts b/frontend/apps/web/src/pages/transactions/TransactionDetail/purchase/types.ts new file mode 100644 index 00000000..0f859dee --- /dev/null +++ b/frontend/apps/web/src/pages/transactions/TransactionDetail/purchase/types.ts @@ -0,0 +1,7 @@ +import { PositionValidator } from "@abrechnung/types"; +import { typeToFlattenedError, z } from "zod"; + +export type PositionValidationError = typeToFlattenedError>; +export type ValidationErrors = { + [positionId: number]: PositionValidationError; +}; From acab7745465125f6a06ec64fa861027dbe24871d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Loipf=C3=BChrer?= Date: Wed, 14 Aug 2024 21:53:19 +0200 Subject: [PATCH 2/2] refactor(web): enable typescript strict: true compiler option --- frontend/apps/web/src/app/Router.tsx | 4 ++ .../AuthenticatedLayout.tsx | 6 +-- .../authenticated-layout/SidebarGroupList.tsx | 4 +- .../apps/web/src/components/AccountSelect.tsx | 2 +- .../web/src/components/AddNewTagDialog.tsx | 4 +- .../apps/web/src/components/DateInput.tsx | 7 ++-- .../apps/web/src/components/NumericInput.tsx | 4 +- .../apps/web/src/components/ShareSelect.tsx | 26 ++++++++----- .../apps/web/src/components/TagSelector.tsx | 8 ++-- .../apps/web/src/components/TextInput.tsx | 2 +- .../accounts/AccountClearingListEntry.tsx | 15 +++----- .../accounts/AccountTransactionList.tsx | 4 +- .../accounts/AccountTransactionListEntry.tsx | 12 +++--- .../accounts/BalanceHistoryGraph.tsx | 5 ++- .../src/components/accounts/BalanceTable.tsx | 5 +++ .../accounts/ClearingAccountDetail.tsx | 15 ++++---- .../accounts/DeleteAccountModal.tsx | 14 +++---- .../components/groups/GroupCreateModal.tsx | 23 ++++++------ .../components/groups/GroupDeleteModal.tsx | 2 +- .../components/groups/GroupMemberSelect.tsx | 2 +- .../components/groups/InviteLinkCreate.tsx | 13 +++++-- .../apps/web/src/components/style/Banner.tsx | 16 +++----- .../src/components/style/EditableField.tsx | 4 +- .../style/datagrid/renderCurrency.tsx | 4 +- frontend/apps/web/src/core/config.tsx | 4 +- .../accounts/AccountDetail/AccountDetail.tsx | 11 +++--- .../accounts/AccountDetail/AccountInfo.tsx | 22 +++++------ .../apps/web/src/pages/accounts/Balances.tsx | 27 +++++++++++--- .../ClearingAccountList.tsx | 26 +++++++------ .../ClearingAccountListItem.tsx | 19 ++++------ .../PersonalAccountList.tsx | 24 ++++++------ .../PersonalAccountListItem.tsx | 29 +++++++-------- .../pages/accounts/SettlementPlanDisplay.tsx | 10 +++-- .../web/src/pages/auth/ConfirmEmailChange.tsx | 5 ++- .../pages/auth/ConfirmPasswordRecovery.tsx | 4 ++ .../src/pages/auth/ConfirmRegistration.tsx | 6 ++- frontend/apps/web/src/pages/auth/Login.tsx | 7 +--- frontend/apps/web/src/pages/auth/Register.tsx | 13 +++---- frontend/apps/web/src/pages/groups/Group.tsx | 1 + .../apps/web/src/pages/groups/GroupInvite.tsx | 9 ++++- .../web/src/pages/groups/GroupInvites.tsx | 21 +++++++---- .../apps/web/src/pages/groups/GroupList.tsx | 27 ++++++++------ .../apps/web/src/pages/groups/GroupLog.tsx | 6 +-- .../web/src/pages/groups/GroupMemberList.tsx | 21 ++++++++--- .../web/src/pages/groups/GroupSettings.tsx | 19 +++++++--- .../web/src/pages/profile/SessionList.tsx | 37 +++++++++++-------- .../apps/web/src/pages/profile/Settings.tsx | 3 +- .../TransactionDetail/FileGallery.tsx | 30 +++++++++------ .../TransactionDetail/ImageUploadDialog.tsx | 24 +++++++++--- .../TransactionDetail/TransactionActions.tsx | 17 +++++---- .../TransactionDetail/TransactionDetail.tsx | 22 +++++++---- .../TransactionDetail/TransactionMetadata.tsx | 24 +++++------- .../purchase/TransactionPositions.tsx | 19 ++++++---- .../TransactionList/TransactionList.tsx | 10 +++-- frontend/apps/web/tsconfig.app.json | 7 +++- frontend/libs/api/src/lib/websocket.ts | 12 ++---- 56 files changed, 411 insertions(+), 306 deletions(-) diff --git a/frontend/apps/web/src/app/Router.tsx b/frontend/apps/web/src/app/Router.tsx index 87d8a02b..419893da 100644 --- a/frontend/apps/web/src/app/Router.tsx +++ b/frontend/apps/web/src/app/Router.tsx @@ -91,6 +91,10 @@ const router = createBrowserRouter([ }, ], }, + { + path: "404", + element: , + }, { path: "*", element: , diff --git a/frontend/apps/web/src/app/authenticated-layout/AuthenticatedLayout.tsx b/frontend/apps/web/src/app/authenticated-layout/AuthenticatedLayout.tsx index 95c8e30d..ffcab152 100644 --- a/frontend/apps/web/src/app/authenticated-layout/AuthenticatedLayout.tsx +++ b/frontend/apps/web/src/app/authenticated-layout/AuthenticatedLayout.tsx @@ -56,7 +56,7 @@ export const AuthenticatedLayout: React.FC = () => { const location = useLocation(); const params = useParams(); const groupId = params["groupId"] ? Number(params["groupId"]) : undefined; - const [anchorEl, setAnchorEl] = React.useState(null); + const [anchorEl, setAnchorEl] = React.useState(null); const theme: Theme = useTheme(); const dotsMenuOpen = Boolean(anchorEl); const cfg = useConfig(); @@ -67,11 +67,11 @@ export const AuthenticatedLayout: React.FC = () => { return ; } - const handleProfileMenuOpen = (event) => { + const handleProfileMenuOpen = (event: React.MouseEvent) => { setAnchorEl(event.currentTarget); }; - const handleDotsMenuClose = (event) => { + const handleDotsMenuClose = () => { setAnchorEl(null); }; diff --git a/frontend/apps/web/src/app/authenticated-layout/SidebarGroupList.tsx b/frontend/apps/web/src/app/authenticated-layout/SidebarGroupList.tsx index ad92d0ab..87be76b8 100644 --- a/frontend/apps/web/src/app/authenticated-layout/SidebarGroupList.tsx +++ b/frontend/apps/web/src/app/authenticated-layout/SidebarGroupList.tsx @@ -21,7 +21,7 @@ export const SidebarGroupList: React.FC = ({ activeGroupId }) => { setShowGroupCreationModal(true); }; - const closeGroupCreateModal = (evt, reason) => { + const closeGroupCreateModal = (reason: string) => { if (reason !== "backdropClick") { setShowGroupCreationModal(false); } @@ -38,7 +38,7 @@ export const SidebarGroupList: React.FC = ({ activeGroupId }) => { diff --git a/frontend/apps/web/src/components/AccountSelect.tsx b/frontend/apps/web/src/components/AccountSelect.tsx index eea1845f..e419ef35 100644 --- a/frontend/apps/web/src/components/AccountSelect.tsx +++ b/frontend/apps/web/src/components/AccountSelect.tsx @@ -46,7 +46,7 @@ export const AccountSelect: React.FC = ({ options={filteredAccounts} getOptionLabel={(acc: Account) => acc.name} multiple={false} - value={value !== undefined ? (accounts.find((acc) => acc.id === value) ?? null) : null} + value={value !== undefined ? (accounts.find((acc) => acc.id === value) ?? undefined) : undefined} disabled={disabled} openOnFocus fullWidth diff --git a/frontend/apps/web/src/components/AddNewTagDialog.tsx b/frontend/apps/web/src/components/AddNewTagDialog.tsx index 10cc1f0b..2878fabe 100644 --- a/frontend/apps/web/src/components/AddNewTagDialog.tsx +++ b/frontend/apps/web/src/components/AddNewTagDialog.tsx @@ -29,13 +29,13 @@ export const AddNewTagDialog: React.FC = ({ open, onCreate, onClose }) => setError(false); }; - const onKeyUp = (key) => { + const onKeyUp = (key: React.KeyboardEvent) => { if (key.keyCode === 13) { handleSave(); } }; - const handleChange = (evt) => { + const handleChange = (evt: React.ChangeEvent) => { const newValue = evt.target.value; if (newValue !== null && newValue !== "") { setError(false); diff --git a/frontend/apps/web/src/components/DateInput.tsx b/frontend/apps/web/src/components/DateInput.tsx index a1571395..b7f3a4e3 100644 --- a/frontend/apps/web/src/components/DateInput.tsx +++ b/frontend/apps/web/src/components/DateInput.tsx @@ -14,9 +14,10 @@ interface Props { export const DateInput: React.FC = ({ value, onChange, helperText, error, disabled = false }) => { const { t } = useTranslation(); - const handleChange = (value: DateTime) => { - if (value.toISODate()) { - onChange(value.toISODate()); + const handleChange = (value: DateTime | null) => { + const stringified = value?.toISODate(); + if (stringified) { + onChange(stringified); } }; diff --git a/frontend/apps/web/src/components/NumericInput.tsx b/frontend/apps/web/src/components/NumericInput.tsx index c55dbc46..5befe0a3 100644 --- a/frontend/apps/web/src/components/NumericInput.tsx +++ b/frontend/apps/web/src/components/NumericInput.tsx @@ -13,10 +13,10 @@ export const NumericInput: React.FC = ({ value, isCurrency, o const [internalValue, setInternalValue] = React.useState(""); React.useEffect(() => { - setInternalValue(isCurrency ? value.toFixed(2) : String(value)); + setInternalValue(isCurrency ? (value?.toFixed(2) ?? "") : String(value)); }, [value, setInternalValue, isCurrency]); - const onInternalChange = (event) => { + const onInternalChange = (event: React.ChangeEvent) => { setInternalValue(event.target.value); // TODO: validate input }; diff --git a/frontend/apps/web/src/components/ShareSelect.tsx b/frontend/apps/web/src/components/ShareSelect.tsx index b9e3835e..ec8addb8 100644 --- a/frontend/apps/web/src/components/ShareSelect.tsx +++ b/frontend/apps/web/src/components/ShareSelect.tsx @@ -53,7 +53,7 @@ const ShareSelectRow: React.FC = ({ onChange(account.id, newValue); }; - const handleToggleShare = (event) => { + const handleToggleShare = (event: React.ChangeEvent) => { if (event.target.checked) { onChange(account.id, 1); } else { @@ -234,19 +234,27 @@ export const ShareSelect: React.FC = ({ {editable && ( } - checked={showEvents} - onChange={(event: React.ChangeEvent) => - setShowEvents(event.target.checked) + control={ + ) => + setShowEvents(event.target.checked) + } + /> } + checked={showEvents} label={t("shareSelect.showEvents")} /> } - checked={showAdvanced} - onChange={(event: React.ChangeEvent) => - setShowAdvanced(event.target.checked) + control={ + ) => + setShowAdvanced(event.target.checked) + } + /> } + checked={showAdvanced} label={t("common.advanced")} /> diff --git a/frontend/apps/web/src/components/TagSelector.tsx b/frontend/apps/web/src/components/TagSelector.tsx index ebe2b5a5..f6012191 100644 --- a/frontend/apps/web/src/components/TagSelector.tsx +++ b/frontend/apps/web/src/components/TagSelector.tsx @@ -32,11 +32,11 @@ export const TagSelector: React.FC = ({ const possibleTags = useAppSelector((state) => selectTagsInGroup({ state, groupId })); - const handleChange = (event) => { + const handleChange = (event: React.ChangeEvent) => { if (!editable) { return; } - const newTags = event.target.value; + const newTags = event.target.value as unknown as string[]; if (newTags.indexOf(CREATE_TAG) > -1) { openAddTagDialog(); return; @@ -64,9 +64,9 @@ export const TagSelector: React.FC = ({ value={value} SelectProps={{ multiple: true, - renderValue: (selected: string[]) => ( + renderValue: (selected: unknown) => ( - {selected.map((value) => ( + {(selected as string[]).map((value) => ( ))} diff --git a/frontend/apps/web/src/components/TextInput.tsx b/frontend/apps/web/src/components/TextInput.tsx index 680ba405..7326ba75 100644 --- a/frontend/apps/web/src/components/TextInput.tsx +++ b/frontend/apps/web/src/components/TextInput.tsx @@ -14,7 +14,7 @@ export const TextInput: React.FC = ({ value, onChange, ...props setInternalValue(String(value)); }, [value, setInternalValue]); - const onInternalChange = (event) => { + const onInternalChange = (event: React.ChangeEvent) => { setInternalValue(event.target.value); }; diff --git a/frontend/apps/web/src/components/accounts/AccountClearingListEntry.tsx b/frontend/apps/web/src/components/accounts/AccountClearingListEntry.tsx index 4024a604..93df5479 100644 --- a/frontend/apps/web/src/components/accounts/AccountClearingListEntry.tsx +++ b/frontend/apps/web/src/components/accounts/AccountClearingListEntry.tsx @@ -1,33 +1,30 @@ -import { selectAccountBalances, selectAccountById, selectGroupCurrencySymbol } from "@abrechnung/redux"; +import { selectAccountBalances, selectGroupCurrencySymbol } from "@abrechnung/redux"; 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 { selectGroupSlice, useAppSelector } from "@/store"; import { getAccountLink } from "@/utils"; import { ClearingAccountIcon } from "../style/AbrechnungIcons"; import ListItemLink from "../style/ListItemLink"; import { useTranslation } from "react-i18next"; import { useFormatCurrency } from "@/hooks"; +import { ClearingAccount } from "@abrechnung/types"; interface Props { groupId: number; accountId: number; - clearingAccountId: number; + clearingAccount: ClearingAccount; } -export const AccountClearingListEntry: React.FC = ({ groupId, accountId, clearingAccountId }) => { +export const AccountClearingListEntry: React.FC = ({ groupId, accountId, clearingAccount }) => { const { t } = useTranslation(); const formatCurrency = useFormatCurrency(); const balances = useAppSelector((state) => selectAccountBalances({ state, groupId })); const currency_symbol = useAppSelector((state) => selectGroupCurrencySymbol({ state: selectGroupSlice(state), groupId }) ); - const clearingAccount = useAppSelector((state) => - selectAccountById({ state: selectAccountSlice(state), groupId, accountId: clearingAccountId }) - ); - if (clearingAccount.type !== "clearing") { - console.error("expected a clearing account but received a personal account"); + if (!currency_symbol) { return null; } diff --git a/frontend/apps/web/src/components/accounts/AccountTransactionList.tsx b/frontend/apps/web/src/components/accounts/AccountTransactionList.tsx index e0031b47..7d0a4305 100644 --- a/frontend/apps/web/src/components/accounts/AccountTransactionList.tsx +++ b/frontend/apps/web/src/components/accounts/AccountTransactionList.tsx @@ -83,7 +83,7 @@ export const AccountTransactionList: React.FC = ({ groupId, account }) => key={`clearing-${entry.id}`} accountId={account.id} groupId={groupId} - clearingAccountId={entry.id} + clearingAccount={entry} /> ); } @@ -98,7 +98,7 @@ export const AccountTransactionList: React.FC = ({ groupId, account }) => key={`transaction-${entry.id}`} accountId={account.id} groupId={groupId} - transactionId={entry.id} + transaction={entry} /> ); })} diff --git a/frontend/apps/web/src/components/accounts/AccountTransactionListEntry.tsx b/frontend/apps/web/src/components/accounts/AccountTransactionListEntry.tsx index ff9eb146..a4815810 100644 --- a/frontend/apps/web/src/components/accounts/AccountTransactionListEntry.tsx +++ b/frontend/apps/web/src/components/accounts/AccountTransactionListEntry.tsx @@ -1,4 +1,4 @@ -import { selectGroupCurrencySymbol, selectTransactionBalanceEffect, selectTransactionById } from "@abrechnung/redux"; +import { selectGroupCurrencySymbol, selectTransactionBalanceEffect } from "@abrechnung/redux"; import { HelpOutline } from "@mui/icons-material"; import { Chip, ListItemAvatar, ListItemText, Tooltip, Typography } from "@mui/material"; import { DateTime } from "luxon"; @@ -8,20 +8,18 @@ import { selectGroupSlice, selectTransactionSlice, useAppSelector } from "@/stor import { PurchaseIcon, TransferIcon } from "../style/AbrechnungIcons"; import ListItemLink from "../style/ListItemLink"; import { useTranslation } from "react-i18next"; +import { Transaction } from "@abrechnung/types"; interface Props { groupId: number; - transactionId: number; + transaction: Transaction; accountId: number; } -export const AccountTransactionListEntry: React.FC = ({ groupId, transactionId, accountId }) => { +export const AccountTransactionListEntry: React.FC = ({ groupId, transaction, accountId }) => { const { t } = useTranslation(); const balanceEffect = useAppSelector((state) => - selectTransactionBalanceEffect({ state: selectTransactionSlice(state), groupId, transactionId }) - ); - const transaction = useAppSelector((state) => - selectTransactionById({ state: selectTransactionSlice(state), groupId, transactionId }) + selectTransactionBalanceEffect({ state: selectTransactionSlice(state), groupId, transactionId: transaction.id }) ); const currency_symbol = useAppSelector((state) => selectGroupCurrencySymbol({ state: selectGroupSlice(state), groupId }) diff --git a/frontend/apps/web/src/components/accounts/BalanceHistoryGraph.tsx b/frontend/apps/web/src/components/accounts/BalanceHistoryGraph.tsx index 3be2e342..6311c40f 100644 --- a/frontend/apps/web/src/components/accounts/BalanceHistoryGraph.tsx +++ b/frontend/apps/web/src/components/accounts/BalanceHistoryGraph.tsx @@ -63,7 +63,10 @@ export const BalanceHistoryGraph: React.FC = ({ groupId, accountId }) => const graphData: Serie[] = []; let lastPoint = balanceHistory[0]; - const makeSerie = () => { + const makeSerie = (): { + id: string; + data: Array<{ x: Date; y: number; changeOrigin: BalanceChangeOrigin }>; + } => { return { id: `serie-${graphData.length}`, data: [], diff --git a/frontend/apps/web/src/components/accounts/BalanceTable.tsx b/frontend/apps/web/src/components/accounts/BalanceTable.tsx index 3ccfadbf..bcb19497 100644 --- a/frontend/apps/web/src/components/accounts/BalanceTable.tsx +++ b/frontend/apps/web/src/components/accounts/BalanceTable.tsx @@ -4,6 +4,7 @@ import { DataGrid, GridColDef, GridToolbar } from "@mui/x-data-grid"; import React from "react"; import { renderCurrency } from "../style/datagrid/renderCurrency"; import { useTranslation } from "react-i18next"; +import { Navigate } from "react-router-dom"; interface Props { groupId: number; @@ -17,6 +18,10 @@ export const BalanceTable: React.FC = ({ groupId }) => { const group = useAppSelector((state) => selectGroupById({ state: selectGroupSlice(state), groupId })); const balances = useAppSelector((state) => selectAccountBalances({ state, groupId })); + if (!group) { + return ; + } + const tableData = personalAccounts.map((acc) => { return { id: acc.id, diff --git a/frontend/apps/web/src/components/accounts/ClearingAccountDetail.tsx b/frontend/apps/web/src/components/accounts/ClearingAccountDetail.tsx index 06c0d833..b725be03 100644 --- a/frontend/apps/web/src/components/accounts/ClearingAccountDetail.tsx +++ b/frontend/apps/web/src/components/accounts/ClearingAccountDetail.tsx @@ -1,26 +1,27 @@ -import { selectAccountBalances, selectAccountById, selectGroupCurrencySymbol } from "@abrechnung/redux"; +import { selectAccountBalances, selectGroupCurrencySymbol } from "@abrechnung/redux"; import { TableCell, Typography } from "@mui/material"; import React from "react"; -import { selectAccountSlice, selectGroupSlice, useAppSelector } from "@/store"; +import { selectGroupSlice, useAppSelector } from "@/store"; import { ShareSelect } from "../ShareSelect"; import { useTranslation } from "react-i18next"; import { useFormatCurrency } from "@/hooks"; +import { Account } from "@abrechnung/types"; interface Props { groupId: number; - accountId: number; + account: Account; } -export const ClearingAccountDetail: React.FC = ({ groupId, accountId }) => { +export const ClearingAccountDetail: React.FC = ({ groupId, account }) => { const { t } = useTranslation(); const formatCurrency = useFormatCurrency(); - const account = useAppSelector((state) => - selectAccountById({ state: selectAccountSlice(state), groupId, accountId }) - ); const currency_symbol = useAppSelector((state) => selectGroupCurrencySymbol({ state: selectGroupSlice(state), groupId }) ); const balances = useAppSelector((state) => selectAccountBalances({ state, groupId })); + if (!currency_symbol) { + return null; + } if (account.type !== "clearing") { throw new Error("expected a clearing account to render ClearingAccountDetail, but got a personal account"); } diff --git a/frontend/apps/web/src/components/accounts/DeleteAccountModal.tsx b/frontend/apps/web/src/components/accounts/DeleteAccountModal.tsx index 43c8f0b7..f1f5a2c7 100644 --- a/frontend/apps/web/src/components/accounts/DeleteAccountModal.tsx +++ b/frontend/apps/web/src/components/accounts/DeleteAccountModal.tsx @@ -1,24 +1,22 @@ import React from "react"; import { api } from "@/core/api"; import { Dialog, DialogTitle, DialogActions, DialogContent, Button, LinearProgress } from "@mui/material"; -import { selectAccountSlice, useAppDispatch, useAppSelector } from "@/store"; -import { deleteAccount, selectAccountById } from "@abrechnung/redux"; +import { useAppDispatch } from "@/store"; +import { deleteAccount } from "@abrechnung/redux"; import { toast } from "react-toastify"; import { useTranslation } from "react-i18next"; +import { Account } from "@abrechnung/types"; interface Props { show: boolean; onClose: () => void; onAccountDeleted?: () => void; groupId: number; - accountId: number | null; + account: Account | null; } -export const DeleteAccountModal: React.FC = ({ show, onClose, groupId, accountId, onAccountDeleted }) => { +export const DeleteAccountModal: React.FC = ({ show, onClose, groupId, account, onAccountDeleted }) => { const { t } = useTranslation(); - const account = useAppSelector((state) => - selectAccountById({ state: selectAccountSlice(state), groupId, accountId }) - ); const dispatch = useAppDispatch(); const [showProgress, setShowProgress] = React.useState(false); @@ -30,7 +28,7 @@ export const DeleteAccountModal: React.FC = ({ show, onClose, groupId, ac return; } setShowProgress(true); - dispatch(deleteAccount({ groupId, accountId, api })) + dispatch(deleteAccount({ groupId, accountId: account.id, api })) .unwrap() .then(() => { if (onAccountDeleted) { diff --git a/frontend/apps/web/src/components/groups/GroupCreateModal.tsx b/frontend/apps/web/src/components/groups/GroupCreateModal.tsx index a3e7d2bf..600e1663 100644 --- a/frontend/apps/web/src/components/groups/GroupCreateModal.tsx +++ b/frontend/apps/web/src/components/groups/GroupCreateModal.tsx @@ -26,12 +26,15 @@ const validationSchema = z.object({ type FormValues = z.infer; +const initialValues: FormValues = { + name: "", + description: "", + addUserAccountOnJoin: false, +}; + interface Props { show: boolean; - onClose: ( - event: Record, - reason: "escapeKeyDown" | "backdropClick" | "completed" | "closeButton" - ) => void; + onClose: (reason: "escapeKeyDown" | "backdropClick" | "completed" | "closeButton") => void; } export const GroupCreateModal: React.FC = ({ show, onClose }) => { @@ -53,7 +56,7 @@ export const GroupCreateModal: React.FC = ({ show, onClose }) => { .unwrap() .then(() => { setSubmitting(false); - onClose({}, "completed"); + onClose("completed"); }) .catch((err) => { toast.error(err); @@ -62,15 +65,11 @@ export const GroupCreateModal: React.FC = ({ show, onClose }) => { }; return ( - + onClose(reason)}> Create Group @@ -127,7 +126,7 @@ export const GroupCreateModal: React.FC = ({ show, onClose }) => { - diff --git a/frontend/apps/web/src/components/groups/GroupDeleteModal.tsx b/frontend/apps/web/src/components/groups/GroupDeleteModal.tsx index 65b58b21..28388cc4 100644 --- a/frontend/apps/web/src/components/groups/GroupDeleteModal.tsx +++ b/frontend/apps/web/src/components/groups/GroupDeleteModal.tsx @@ -1,8 +1,8 @@ -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 { Group } from "@abrechnung/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 83aba919..93107c05 100644 --- a/frontend/apps/web/src/components/groups/GroupMemberSelect.tsx +++ b/frontend/apps/web/src/components/groups/GroupMemberSelect.tsx @@ -24,7 +24,7 @@ export const GroupMemberSelect: React.FC = ({ value = null, disabled = false, noDisabledStyling = false, - className = null, + className, ...props }) => { const memberIds = useAppSelector((state) => selectGroupMemberIds({ state: selectGroupSlice(state), groupId })); diff --git a/frontend/apps/web/src/components/groups/InviteLinkCreate.tsx b/frontend/apps/web/src/components/groups/InviteLinkCreate.tsx index 20967e3f..3b28453f 100644 --- a/frontend/apps/web/src/components/groups/InviteLinkCreate.tsx +++ b/frontend/apps/web/src/components/groups/InviteLinkCreate.tsx @@ -11,7 +11,7 @@ import { TextField, } from "@mui/material"; import { DateTimePicker } from "@mui/x-date-pickers"; -import { Form, Formik } from "formik"; +import { Form, Formik, FormikHelpers } from "formik"; import { DateTime } from "luxon"; import React from "react"; import { toast } from "react-toastify"; @@ -26,14 +26,21 @@ interface Props { ) => void; } +type FormValues = { + description: string; + validUntil: DateTime; + singleUse: boolean; + joinAsEditor: boolean; +}; + export const InviteLinkCreate: React.FC = ({ show, onClose, group }) => { - const handleSubmit = (values, { setSubmitting }) => { + const handleSubmit = (values: FormValues, { setSubmitting }: FormikHelpers) => { api.client.groups .createInvite({ groupId: group.id, requestBody: { description: values.description, - valid_until: values.validUntil, + valid_until: values.validUntil.toISO()!, single_use: values.singleUse, join_as_editor: values.joinAsEditor, }, diff --git a/frontend/apps/web/src/components/style/Banner.tsx b/frontend/apps/web/src/components/style/Banner.tsx index f2748ff3..333c643e 100644 --- a/frontend/apps/web/src/components/style/Banner.tsx +++ b/frontend/apps/web/src/components/style/Banner.tsx @@ -11,14 +11,10 @@ export const Banner: React.FC = () => { ); } - return ( - <> - {cfg.messages.map((message, idx) => ( - - {message.title && {message.title}} - {message.body} - - ))} - - ); + return cfg.messages?.map((message, idx) => ( + + {message.title && {message.title}} + {message.body} + + )); }; diff --git a/frontend/apps/web/src/components/style/EditableField.tsx b/frontend/apps/web/src/components/style/EditableField.tsx index eb54b381..b4c2e40a 100644 --- a/frontend/apps/web/src/components/style/EditableField.tsx +++ b/frontend/apps/web/src/components/style/EditableField.tsx @@ -50,14 +50,14 @@ export const EditableField: React.FC = ({ } }; - const onValueChange = (event) => { + const onValueChange = (event: React.ChangeEvent) => { setValue(event.target.value); if (validate) { setError(!validate(event.target.value)); } }; - const onKeyUp = (key) => { + const onKeyUp = (key: React.KeyboardEvent) => { if (key.keyCode === 13) { onSave(); } diff --git a/frontend/apps/web/src/components/style/datagrid/renderCurrency.tsx b/frontend/apps/web/src/components/style/datagrid/renderCurrency.tsx index 74f580a8..bd6968a8 100644 --- a/frontend/apps/web/src/components/style/datagrid/renderCurrency.tsx +++ b/frontend/apps/web/src/components/style/datagrid/renderCurrency.tsx @@ -8,7 +8,7 @@ function pnlFormatter(value: number, currency_symbol: string) { interface CurrencyValueProps { currency_symbol: string; value?: number; - forceColor: string; + forceColor?: string; } const CurrencyValue = React.memo(({ currency_symbol, value = 0, forceColor }: CurrencyValueProps) => { @@ -36,7 +36,7 @@ CurrencyValue.displayName = "CurrencyValue"; export function renderCurrency( currency_symbol: string, - forceColor = undefined + forceColor?: string ): (params: { value?: number }) => React.ReactNode { const component: React.FC<{ value?: number }> = (params) => { return ; diff --git a/frontend/apps/web/src/core/config.tsx b/frontend/apps/web/src/core/config.tsx index 234d2a41..b90f28ac 100644 --- a/frontend/apps/web/src/core/config.tsx +++ b/frontend/apps/web/src/core/config.tsx @@ -13,7 +13,7 @@ const configSchema = z.object({ .array( z.object({ type: z.union([z.literal("info"), z.literal("error"), z.literal("warning"), z.literal("success")]), - title: z.string().default(null).nullable(), + title: z.string().nullable().default(null), body: z.string(), }) ) @@ -29,7 +29,7 @@ export interface StatusMessage { export type Config = z.infer; -const ConfigContext = React.createContext(null as Config); +const ConfigContext = React.createContext(null as unknown as Config); export type ConfigProviderProps = { children: React.ReactNode; diff --git a/frontend/apps/web/src/pages/accounts/AccountDetail/AccountDetail.tsx b/frontend/apps/web/src/pages/accounts/AccountDetail/AccountDetail.tsx index 2b0ddae2..45caf11d 100644 --- a/frontend/apps/web/src/pages/accounts/AccountDetail/AccountDetail.tsx +++ b/frontend/apps/web/src/pages/accounts/AccountDetail/AccountDetail.tsx @@ -11,17 +11,18 @@ import * as React from "react"; import { Navigate, useParams } from "react-router-dom"; import { AccountInfo } from "./AccountInfo"; import { useTranslation } from "react-i18next"; +import { Account } from "@abrechnung/types"; interface Props { groupId: number; } -const AccountEdit: React.FC<{ groupId: number; accountId: number }> = ({ groupId, accountId }) => { +const AccountEdit: React.FC<{ groupId: number; account: Account }> = ({ groupId, account }) => { return ( - + @@ -55,14 +56,14 @@ export const AccountDetail: React.FC = ({ groupId }) => { } if (account.is_wip) { - return ; + return ; } return ( - + {account.type === "personal" && ( @@ -76,7 +77,7 @@ export const AccountDetail: React.FC = ({ groupId }) => { {account.type === "clearing" && ( - + )} diff --git a/frontend/apps/web/src/pages/accounts/AccountDetail/AccountInfo.tsx b/frontend/apps/web/src/pages/accounts/AccountDetail/AccountInfo.tsx index 1dc5523c..3abadaa3 100644 --- a/frontend/apps/web/src/pages/accounts/AccountDetail/AccountInfo.tsx +++ b/frontend/apps/web/src/pages/accounts/AccountDetail/AccountInfo.tsx @@ -5,14 +5,13 @@ import { TextInput } from "@/components/TextInput"; import { DeleteAccountModal } from "@/components/accounts/DeleteAccountModal"; import { api } from "@/core/api"; import { useFormatCurrency } from "@/hooks"; -import { selectAccountSlice, selectGroupSlice, useAppDispatch, useAppSelector } from "@/store"; +import { selectGroupSlice, useAppDispatch, useAppSelector } from "@/store"; import { getAccountLink, getAccountListLink } from "@/utils"; import { accountEditStarted, discardAccountChange, saveAccount, selectAccountBalances, - selectAccountById, selectCurrentUserPermissions, selectGroupCurrencySymbol, wipAccountUpdated, @@ -22,27 +21,24 @@ import { ChevronLeft, Delete, Edit } from "@mui/icons-material"; import { Button, Chip, Divider, Grid, IconButton, LinearProgress, TableCell } from "@mui/material"; import * as React from "react"; import { useTranslation } from "react-i18next"; -import { useNavigate } from "react-router-dom"; +import { Navigate, useNavigate } from "react-router-dom"; import { toast } from "react-toastify"; import { typeToFlattenedError, z } from "zod"; interface Props { groupId: number; - accountId: number; + account: Account; } const emptyErrors = { fieldErrors: {}, formErrors: [] }; -export const AccountInfo: React.FC = ({ groupId, accountId }) => { +export const AccountInfo: React.FC = ({ groupId, account }) => { const { t } = useTranslation(); const formatCurrency = useFormatCurrency(); const dispatch = useAppDispatch(); const navigate = useNavigate(); const permissions = useAppSelector((state) => selectCurrentUserPermissions({ state: state, groupId })); - const account = useAppSelector((state) => - selectAccountById({ state: selectAccountSlice(state), groupId, accountId }) - ); const currencySymbol = useAppSelector((state) => selectGroupCurrencySymbol({ state: selectGroupSlice(state), groupId }) ); @@ -68,7 +64,7 @@ export const AccountInfo: React.FC = ({ groupId, accountId }) => { const edit = () => { if (!account.is_wip) { - dispatch(accountEditStarted({ groupId, accountId })); + dispatch(accountEditStarted({ groupId, accountId: account.id })); } }; @@ -112,11 +108,15 @@ export const AccountInfo: React.FC = ({ groupId, accountId }) => { return; } setShowProgress(true); - dispatch(discardAccountChange({ groupId, accountId })); + dispatch(discardAccountChange({ groupId, accountId: account.id })); setShowProgress(false); navigate(`/groups/${groupId}/${account.type === "clearing" ? "events" : "accounts"}`); }; + if (!permissions || !currencySymbol) { + return ; + } + return ( <> @@ -237,7 +237,7 @@ export const AccountInfo: React.FC = ({ groupId, accountId }) => { diff --git a/frontend/apps/web/src/pages/accounts/Balances.tsx b/frontend/apps/web/src/pages/accounts/Balances.tsx index df549e0f..594255dd 100644 --- a/frontend/apps/web/src/pages/accounts/Balances.tsx +++ b/frontend/apps/web/src/pages/accounts/Balances.tsx @@ -27,13 +27,22 @@ import { import { useTheme } from "@mui/material/styles"; import React, { useState } from "react"; import { useTranslation } from "react-i18next"; -import { Link as RouterLink, useNavigate } from "react-router-dom"; +import { Navigate, Link as RouterLink, useNavigate } from "react-router-dom"; import { Bar, BarChart, Cell, LabelList, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; +import { CategoricalChartFunc } from "recharts/types/chart/generateCategoricalChart"; interface Props { groupId: number; } +type Data = { + name: string; + id: number; + balance: number; + totalPaid: number; + totalConsumed: number; +}; + export const Balances: React.FC = ({ groupId }) => { const { t } = useTranslation(); const formatCurrency = useFormatCurrency(); @@ -57,11 +66,15 @@ export const Balances: React.FC = ({ groupId }) => { const colorGreenInverted = theme.palette.mode === "dark" ? theme.palette.success.light : theme.palette.success.dark; const colorRedInverted = theme.palette.mode === "dark" ? theme.palette.error.light : theme.palette.error.dark; - useTitle(t("accounts.balances.tabTitle", "", { groupName: group.name })); + useTitle(t("accounts.balances.tabTitle", "", { groupName: group?.name })); + + if (!group) { + return ; + } const roundTwoDecimals = (val: number) => +val.toFixed(2); - const chartData = personalAccounts.map((account) => { + const chartData: Data[] = personalAccounts.map((account) => { const balance = balances[account.id]; return { name: account.name, @@ -89,8 +102,8 @@ export const Balances: React.FC = ({ groupId }) => { ? Math.max(Math.max(...personalAccounts.map((account) => account.name.length)), 20) : Math.max(...personalAccounts.map((account) => account.name.length)) * 7 + 5; - const handleBarClick = (data, event) => { - const id = data.activePayload[0].payload.id; + const handleBarClick: CategoricalChartFunc = (data) => { + const id = data.activePayload?.[0].payload.id; navigate(`/groups/${group.id}/accounts/${id}`); }; @@ -198,7 +211,9 @@ export const Balances: React.FC = ({ groupId }) => { ); })} formatCurrency(entry["balance"], group.currency_symbol)} + dataKey={(entry) => + formatCurrency((entry as Data).balance, group.currency_symbol) + } position="insideLeft" fill={theme.palette.text.primary} /> diff --git a/frontend/apps/web/src/pages/accounts/ClearingAccountList/ClearingAccountList.tsx b/frontend/apps/web/src/pages/accounts/ClearingAccountList/ClearingAccountList.tsx index 4a3edf9a..c9ee8d19 100644 --- a/frontend/apps/web/src/pages/accounts/ClearingAccountList/ClearingAccountList.tsx +++ b/frontend/apps/web/src/pages/accounts/ClearingAccountList/ClearingAccountList.tsx @@ -22,7 +22,7 @@ import { useTheme, } from "@mui/material"; import React, { useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { Navigate, useNavigate } from "react-router-dom"; import { TagSelector } from "@/components/TagSelector"; import { DeleteAccountModal } from "@/components/accounts/DeleteAccountModal"; import { MobilePaper } from "@/components/style/mobile"; @@ -31,12 +31,13 @@ import { selectAccountSlice, selectGroupSlice, useAppDispatch, useAppSelector } import { getAccountLink } from "@/utils"; import { ClearingAccountListItem } from "./ClearingAccountListItem"; import { useTranslation } from "react-i18next"; +import { Account } from "@abrechnung/types"; interface Props { groupId: number; } -const emptyList = []; +const emptyList: string[] = []; const MAX_ITEMS_PER_PAGE = 40; export const ClearingAccountList: React.FC = ({ groupId }) => { @@ -72,16 +73,13 @@ export const ClearingAccountList: React.FC = ({ groupId }) => { (currentPage + 1) * MAX_ITEMS_PER_PAGE ); - useTitle(t("events.list.tabTitle", "", { groupName: group.name })); + useTitle(t("events.list.tabTitle", "", { groupName: group?.name })); - const [accountDeleteId, setAccountDeleteId] = useState(null); - const showDeleteModal = accountDeleteId !== null; + const [accountDelete, setAccountDelete] = useState(null); + const showDeleteModal = accountDelete !== null; - const onShowDeleteModal = (accountId: number) => { - setAccountDeleteId(accountId); - }; const onCloseDeleteModal = () => { - setAccountDeleteId(null); + setAccountDelete(null); }; const handleChangeTagFilter = (newTags: string[]) => setTagFilter(newTags); @@ -94,6 +92,10 @@ export const ClearingAccountList: React.FC = ({ groupId }) => { }); }; + if (!permissions) { + return ; + } + return ( <> @@ -177,8 +179,8 @@ export const ClearingAccountList: React.FC = ({ groupId }) => { )) )} @@ -204,7 +206,7 @@ export const ClearingAccountList: React.FC = ({ groupId }) => { show={showDeleteModal} onAccountDeleted={onCloseDeleteModal} onClose={onCloseDeleteModal} - accountId={accountDeleteId} + account={accountDelete} /> diff --git a/frontend/apps/web/src/pages/accounts/ClearingAccountList/ClearingAccountListItem.tsx b/frontend/apps/web/src/pages/accounts/ClearingAccountList/ClearingAccountListItem.tsx index cb3728ea..51b46975 100644 --- a/frontend/apps/web/src/pages/accounts/ClearingAccountList/ClearingAccountListItem.tsx +++ b/frontend/apps/web/src/pages/accounts/ClearingAccountList/ClearingAccountListItem.tsx @@ -4,10 +4,10 @@ import { getAccountLink } from "@/utils"; import { accountEditStarted, copyAccount, - selectAccountById, selectAccountIdToAccountMap, selectCurrentUserPermissions, } from "@abrechnung/redux"; +import { Account } from "@abrechnung/types"; import { ContentCopy, Delete, Edit } from "@mui/icons-material"; import { Chip, IconButton, ListItem, ListItemSecondaryAction, ListItemText, Typography } from "@mui/material"; import { DateTime } from "luxon"; @@ -16,11 +16,11 @@ import { useNavigate } from "react-router-dom"; interface Props { groupId: number; - accountId: number; - setAccountToDelete: (accountID: number) => void; + account: Account; + setAccountToDelete: (account: Account) => void; } -export const ClearingAccountListItem: React.FC = ({ groupId, accountId, setAccountToDelete }) => { +export const ClearingAccountListItem: React.FC = ({ groupId, account, setAccountToDelete }) => { const dispatch = useAppDispatch(); const navigate = useNavigate(); @@ -28,11 +28,8 @@ export const ClearingAccountListItem: React.FC = ({ groupId, accountId, s const accounts = useAppSelector((state) => selectAccountIdToAccountMap({ state: selectAccountSlice(state), groupId }) ); - const account = useAppSelector((state) => - selectAccountById({ state: selectAccountSlice(state), groupId, accountId }) - ); - if (account.type !== "clearing") { + if (!permissions || account.type !== "clearing") { return null; } @@ -40,13 +37,13 @@ export const ClearingAccountListItem: React.FC = ({ groupId, accountId, s const edit = () => { if (!account.is_wip) { - dispatch(accountEditStarted({ groupId, accountId })); + dispatch(accountEditStarted({ groupId, accountId: account.id })); } navigate(getAccountLink(groupId, account.type, account.id)); }; const copy = () => { - dispatch(copyAccount({ groupId, accountId })); + dispatch(copyAccount({ groupId, accountId: account.id })); }; return ( @@ -88,7 +85,7 @@ export const ClearingAccountListItem: React.FC = ({ groupId, accountId, s - setAccountToDelete(account.id)}> + setAccountToDelete(account)}> diff --git a/frontend/apps/web/src/pages/accounts/PersonalAccountList/PersonalAccountList.tsx b/frontend/apps/web/src/pages/accounts/PersonalAccountList/PersonalAccountList.tsx index 28784391..738cc164 100644 --- a/frontend/apps/web/src/pages/accounts/PersonalAccountList/PersonalAccountList.tsx +++ b/frontend/apps/web/src/pages/accounts/PersonalAccountList/PersonalAccountList.tsx @@ -32,10 +32,11 @@ import { useTheme, } from "@mui/material"; import React, { useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { Navigate, useNavigate } from "react-router-dom"; import { toast } from "react-toastify"; import { PersonalAccountListItem } from "./PersonalAccountListItem"; import { useTranslation } from "react-i18next"; +import { Account } from "@abrechnung/types"; interface Props { groupId: number; @@ -76,16 +77,13 @@ export const PersonalAccountList: React.FC = ({ groupId }) => { (currentPage + 1) * MAX_ITEMS_PER_PAGE ); - useTitle(t("accounts.list.tabTitle", "", { groupName: group.name })); + useTitle(t("accounts.list.tabTitle", "", { groupName: group?.name })); - const [accountDeleteId, setAccountDeleteId] = useState(null); - const showDeleteModal = accountDeleteId !== null; + const [accountDelete, setAccountDelete] = useState(null); + const showDeleteModal = accountDelete !== null; - const onShowDeleteModal = (accountId: number) => { - setAccountDeleteId(accountId); - }; const onCloseDeleteModal = () => { - setAccountDeleteId(null); + setAccountDelete(null); }; const onCreateEvent = () => { @@ -99,6 +97,10 @@ export const PersonalAccountList: React.FC = ({ groupId }) => { }); }; + if (!group || !permissions || currentUserId == null) { + return ; + } + return ( <> @@ -170,9 +172,9 @@ export const PersonalAccountList: React.FC = ({ groupId }) => { )) )} @@ -197,7 +199,7 @@ export const PersonalAccountList: React.FC = ({ groupId }) => { groupId={groupId} show={showDeleteModal} onClose={onCloseDeleteModal} - accountId={accountDeleteId} + account={accountDelete} /> diff --git a/frontend/apps/web/src/pages/accounts/PersonalAccountList/PersonalAccountListItem.tsx b/frontend/apps/web/src/pages/accounts/PersonalAccountList/PersonalAccountListItem.tsx index 7afced0e..2418944b 100644 --- a/frontend/apps/web/src/pages/accounts/PersonalAccountList/PersonalAccountListItem.tsx +++ b/frontend/apps/web/src/pages/accounts/PersonalAccountList/PersonalAccountListItem.tsx @@ -1,39 +1,36 @@ -import { - accountEditStarted, - selectAccountById, - selectCurrentUserPermissions, - selectGroupMemberIdToUsername, -} from "@abrechnung/redux"; +import { accountEditStarted, selectCurrentUserPermissions, selectGroupMemberIdToUsername } from "@abrechnung/redux"; 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 { Navigate, useNavigate } from "react-router-dom"; import { ListItemLink } from "@/components/style/ListItemLink"; -import { selectAccountSlice, selectGroupSlice, useAppDispatch, useAppSelector } from "@/store"; +import { selectGroupSlice, useAppDispatch, useAppSelector } from "@/store"; import { getAccountLink } from "@/utils"; +import { Account } from "@abrechnung/types"; interface Props { groupId: number; currentUserId: number; - accountId: number; - setAccountToDelete: (accountId: number) => void; + account: Account; + setAccountToDelete: (account: Account) => void; } -export const PersonalAccountListItem: React.FC = ({ groupId, currentUserId, accountId, setAccountToDelete }) => { +export const PersonalAccountListItem: React.FC = ({ groupId, currentUserId, account, setAccountToDelete }) => { const dispatch = useAppDispatch(); const navigate = useNavigate(); - const account = useAppSelector((state) => - selectAccountById({ state: selectAccountSlice(state), groupId, accountId }) - ); const permissions = useAppSelector((state) => selectCurrentUserPermissions({ state: state, groupId })); const memberIDToUsername = useAppSelector((state) => selectGroupMemberIdToUsername({ state: selectGroupSlice(state), groupId }) ); + if (!permissions || !account) { + return ; + } + const edit = () => { if (!account.is_wip) { - dispatch(accountEditStarted({ groupId, accountId })); + dispatch(accountEditStarted({ groupId, accountId: account.id })); } navigate(getAccountLink(groupId, account.type, account.id)); }; @@ -83,7 +80,7 @@ export const PersonalAccountListItem: React.FC = ({ groupId, currentUserI - setAccountToDelete(account.id)}> + setAccountToDelete(account)}> diff --git a/frontend/apps/web/src/pages/accounts/SettlementPlanDisplay.tsx b/frontend/apps/web/src/pages/accounts/SettlementPlanDisplay.tsx index 3c7f6a16..6e8c46ad 100644 --- a/frontend/apps/web/src/pages/accounts/SettlementPlanDisplay.tsx +++ b/frontend/apps/web/src/pages/accounts/SettlementPlanDisplay.tsx @@ -9,7 +9,7 @@ import { } from "@abrechnung/redux"; import { Button, List, ListItem, ListItemSecondaryAction, ListItemText, Typography } from "@mui/material"; import * as React from "react"; -import { useNavigate } from "react-router-dom"; +import { Navigate, useNavigate } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { useFormatCurrency } from "@/hooks"; @@ -23,13 +23,17 @@ export const SettlementPlanDisplay: React.FC = ({ groupId }) => { const dispatch = useAppDispatch(); const navigate = useNavigate(); const settlementPlan = useAppSelector((state) => selectSettlementPlan({ state, groupId })); - const currency_symbol = useAppSelector((state) => + const currencySymbol = useAppSelector((state) => selectGroupCurrencySymbol({ state: selectGroupSlice(state), groupId }) ); const accountMap = useAppSelector((state) => selectAccountIdToAccountMap({ state: selectAccountSlice(state), groupId }) ); + if (!currencySymbol) { + return ; + } + const onSettleClicked = (planItem: SettlementPlanItem) => { dispatch( createTransaction({ @@ -61,7 +65,7 @@ export const SettlementPlanDisplay: React.FC = ({ groupId }) => { {t("accounts.settlement.whoPaysWhom", "", { from: accountMap[planItem.creditorId].name, to: accountMap[planItem.debitorId].name, - money: formatCurrency(planItem.paymentAmount, currency_symbol), + money: formatCurrency(planItem.paymentAmount, currencySymbol), })} } diff --git a/frontend/apps/web/src/pages/auth/ConfirmEmailChange.tsx b/frontend/apps/web/src/pages/auth/ConfirmEmailChange.tsx index 0ee68a3e..b62b3892 100644 --- a/frontend/apps/web/src/pages/auth/ConfirmEmailChange.tsx +++ b/frontend/apps/web/src/pages/auth/ConfirmEmailChange.tsx @@ -14,7 +14,10 @@ export const ConfirmEmailChange: React.FC = () => { useTitle(t("auth.confirmEmailChange.tabTitle")); - const confirmEmail = (e) => { + const confirmEmail = (e: React.MouseEvent) => { + if (!token) { + return; + } e.preventDefault(); setStatus("loading"); api.client.auth diff --git a/frontend/apps/web/src/pages/auth/ConfirmPasswordRecovery.tsx b/frontend/apps/web/src/pages/auth/ConfirmPasswordRecovery.tsx index f1c45d80..e00dd51e 100644 --- a/frontend/apps/web/src/pages/auth/ConfirmPasswordRecovery.tsx +++ b/frontend/apps/web/src/pages/auth/ConfirmPasswordRecovery.tsx @@ -29,6 +29,10 @@ export const ConfirmPasswordRecovery: React.FC = () => { useTitle(t("auth.confirmPasswordRecovery.tabTitle")); const handleSubmit = (values: FormSchema, { setSubmitting, resetForm }: FormikHelpers) => { + if (!token) { + return; + } + api.client.auth .confirmPasswordRecovery({ requestBody: { new_password: values.password, token } }) .then(() => { diff --git a/frontend/apps/web/src/pages/auth/ConfirmRegistration.tsx b/frontend/apps/web/src/pages/auth/ConfirmRegistration.tsx index 06b1766e..01676562 100644 --- a/frontend/apps/web/src/pages/auth/ConfirmRegistration.tsx +++ b/frontend/apps/web/src/pages/auth/ConfirmRegistration.tsx @@ -15,7 +15,11 @@ export const ConfirmRegistration: React.FC = () => { useTitle(t("auth.confirmRegistration.tabTitle")); - const confirmEmail = (e) => { + const confirmEmail = (e: React.MouseEvent) => { + if (!token) { + return; + } + e.preventDefault(); setStatus("loading"); api.client.auth diff --git a/frontend/apps/web/src/pages/auth/Login.tsx b/frontend/apps/web/src/pages/auth/Login.tsx index 15ffc58f..8bb016e4 100644 --- a/frontend/apps/web/src/pages/auth/Login.tsx +++ b/frontend/apps/web/src/pages/auth/Login.tsx @@ -43,11 +43,8 @@ export const Login: React.FC = () => { useEffect(() => { if (isLoggedIn) { - if (query.get("next") !== null && query.get("next") !== undefined) { - navigate(query.get("next")); - } else { - navigate("/"); - } + const next = query.get("next"); + navigate(next ?? "/"); } }, [isLoggedIn, navigate, query]); diff --git a/frontend/apps/web/src/pages/auth/Register.tsx b/frontend/apps/web/src/pages/auth/Register.tsx index 6a191315..ff0a7013 100644 --- a/frontend/apps/web/src/pages/auth/Register.tsx +++ b/frontend/apps/web/src/pages/auth/Register.tsx @@ -12,7 +12,7 @@ import { TextField, Typography, } from "@mui/material"; -import { Form, Formik } from "formik"; +import { Form, Formik, FormikHelpers } from "formik"; import React, { useEffect, useState } from "react"; import { Link as RouterLink, useNavigate } from "react-router-dom"; import { toast } from "react-toastify"; @@ -53,23 +53,20 @@ export const Register: React.FC = () => { useEffect(() => { if (loggedIn) { setLoading(false); - if (query.get("next") !== null && query.get("next") !== undefined) { - navigate(query.get("next")); - } else { - navigate("/"); - } + const next = query.get("next"); + navigate(next ?? "/"); } else { setLoading(false); } }, [loggedIn, navigate, query]); - const handleSubmit = (values: FormValues, { setSubmitting }) => { + const handleSubmit = (values: FormValues, { setSubmitting }: FormikHelpers) => { // extract a potential invite token (which should be a uuid) from the query args let inviteToken = undefined; console.log(query.get("next")); if (query.get("next") !== null && query.get("next") !== undefined) { const re = /\/invite\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/; - const m = query.get("next").match(re); + const m = query.get("next")?.match(re); if (m != null) { inviteToken = m[1]; } diff --git a/frontend/apps/web/src/pages/groups/Group.tsx b/frontend/apps/web/src/pages/groups/Group.tsx index 1bfdbd38..bfa12436 100644 --- a/frontend/apps/web/src/pages/groups/Group.tsx +++ b/frontend/apps/web/src/pages/groups/Group.tsx @@ -78,6 +78,7 @@ export const Group: React.FC = () => { } if ( + group === undefined || accountStatus === undefined || transactionStatus === undefined || groupMemberStatus === undefined || diff --git a/frontend/apps/web/src/pages/groups/GroupInvite.tsx b/frontend/apps/web/src/pages/groups/GroupInvite.tsx index 9d50e246..5bdae786 100644 --- a/frontend/apps/web/src/pages/groups/GroupInvite.tsx +++ b/frontend/apps/web/src/pages/groups/GroupInvite.tsx @@ -12,13 +12,15 @@ export const GroupInvite: React.FC = () => { const { t } = useTranslation(); const [group, setGroup] = useState(null); const [error, setError] = useState(null); - const params = useParams(); + const { inviteToken } = useParams(); const navigate = useNavigate(); - const inviteToken = params["inviteToken"]; useTitle(t("groups.join.tabTitle")); useEffect(() => { + if (!inviteToken) { + return; + } api.client.groups .previewGroup({ requestBody: { invite_token: inviteToken } }) .then((res) => { @@ -32,6 +34,9 @@ export const GroupInvite: React.FC = () => { }, [setGroup, setError, inviteToken]); const join = () => { + if (!inviteToken) { + return; + } api.client.groups .joinGroup({ requestBody: { invite_token: inviteToken } }) .then(() => { diff --git a/frontend/apps/web/src/pages/groups/GroupInvites.tsx b/frontend/apps/web/src/pages/groups/GroupInvites.tsx index fea04bbf..3bce4683 100644 --- a/frontend/apps/web/src/pages/groups/GroupInvites.tsx +++ b/frontend/apps/web/src/pages/groups/GroupInvites.tsx @@ -31,6 +31,7 @@ import { api, ws } from "@/core/api"; import { useTitle } from "@/core/utils"; import { selectAuthSlice, selectGroupSlice, useAppDispatch, useAppSelector } from "@/store"; import { useTranslation } from "react-i18next"; +import { Navigate } from "react-router-dom"; interface Props { groupId: number; @@ -50,7 +51,7 @@ export const GroupInvites: React.FC = ({ groupId }) => { const isGuest = useAppSelector((state) => selectIsGuestUser({ state: selectAuthSlice(state) })); - useTitle(t("groups.invites.tabTitle", "", { groupName: group.name })); + useTitle(t("groups.invites.tabTitle", "", { groupName: group?.name })); useEffect(() => { dispatch(fetchGroupInvites({ groupId, api })); @@ -60,7 +61,7 @@ export const GroupInvites: React.FC = ({ groupId }) => { }; }, [dispatch, groupId]); - const deleteToken = (id) => { + const deleteToken = (id: number) => { dispatch(deleteGroupInvite({ groupId, inviteId: id, api })) .unwrap() .catch((err) => { @@ -68,7 +69,7 @@ export const GroupInvites: React.FC = ({ groupId }) => { }); }; - const getMemberUsername = (memberID) => { + const getMemberUsername = (memberID: number) => { const member = members.find((member) => member.user_id === memberID); if (member === undefined) { return "unknown"; @@ -76,20 +77,24 @@ export const GroupInvites: React.FC = ({ groupId }) => { return member.username; }; - const selectLink = (event) => { + const selectLink = (event: React.MouseEvent) => { const node = event.target; const selection = window.getSelection(); const range = document.createRange(); - range.selectNodeContents(node); - selection.removeAllRanges(); - selection.addRange(range); + range.selectNodeContents(node as HTMLElement); + selection?.removeAllRanges(); + selection?.addRange(range); }; - const copyToClipboard = (content) => { + const copyToClipboard = (content: string) => { navigator.clipboard.writeText(content); toast.info("Link copied to clipboard!"); }; + if (!permissions || !group) { + return ; + } + return ( diff --git a/frontend/apps/web/src/pages/groups/GroupList.tsx b/frontend/apps/web/src/pages/groups/GroupList.tsx index d55d5e4a..fd3712ea 100644 --- a/frontend/apps/web/src/pages/groups/GroupList.tsx +++ b/frontend/apps/web/src/pages/groups/GroupList.tsx @@ -18,23 +18,24 @@ import { selectIsGuestUser, selectGroups } from "@abrechnung/redux"; import { useAppSelector, selectGroupSlice, selectAuthSlice } from "@/store"; import { useTitle } from "@/core/utils"; import { useTranslation } from "react-i18next"; +import { Group } from "@abrechnung/api"; export const GroupList: React.FC = () => { const { t } = useTranslation(); useTitle(t("groups.list.tabTitle")); const [showGroupCreationModal, setShowGroupCreationModal] = useState(false); - const [showGroupDeletionModal, setShowGroupDeletionModal] = useState(false); - const [groupToDelete, setGroupToDelete] = useState(null); + const [groupToDelete, setGroupToDelete] = useState(null); const groups = useAppSelector((state) => selectGroups({ state: selectGroupSlice(state) })); const isGuest = useAppSelector((state) => selectIsGuestUser({ state: selectAuthSlice(state) })); - const openGroupDeletionModal = (groupID) => { - setGroupToDelete(groups.find((group) => group.id === groupID)); - setShowGroupDeletionModal(true); + const openGroupDeletionModal = (groupID: number) => { + const g = groups.find((group) => group.id === groupID); + if (g) { + setGroupToDelete(g); + } }; const closeGroupDeletionModal = () => { - setShowGroupDeletionModal(false); setGroupToDelete(null); }; @@ -42,7 +43,7 @@ export const GroupList: React.FC = () => { setShowGroupCreationModal(true); }; - const closeGroupCreateModal = (evt, reason) => { + const closeGroupCreateModal = (reason: string) => { if (reason !== "backdropClick") { setShowGroupCreationModal(false); } @@ -90,11 +91,13 @@ export const GroupList: React.FC = () => { )} - + {groupToDelete != null && ( + + )} ); }; diff --git a/frontend/apps/web/src/pages/groups/GroupLog.tsx b/frontend/apps/web/src/pages/groups/GroupLog.tsx index b2abb505..a6b13ac0 100644 --- a/frontend/apps/web/src/pages/groups/GroupLog.tsx +++ b/frontend/apps/web/src/pages/groups/GroupLog.tsx @@ -45,7 +45,7 @@ export const GroupLog: React.FC = ({ groupId }) => { const [showAllLogs, setShowAllLogs] = useState(false); const [message, setMessage] = useState(""); - useTitle(t("groups.log.tabTitle", "", { groupName: group.name })); + useTitle(t("groups.log.tabTitle", "", { groupName: group?.name })); useEffect(() => { dispatch(fetchGroupLog({ groupId, api })); @@ -68,7 +68,7 @@ export const GroupLog: React.FC = ({ groupId }) => { }); }; - const getMemberUsername = (member_id) => { + const getMemberUsername = (member_id: number) => { const member = members.find((member) => member.user_id === member_id); if (member === undefined) { return "unknown"; @@ -76,7 +76,7 @@ export const GroupLog: React.FC = ({ groupId }) => { return member.username; }; - const onKeyUp = (key) => { + const onKeyUp = (key: React.KeyboardEvent) => { key.preventDefault(); if (key.keyCode === 13) { sendMessage(); diff --git a/frontend/apps/web/src/pages/groups/GroupMemberList.tsx b/frontend/apps/web/src/pages/groups/GroupMemberList.tsx index 5bcae6d4..0ca98c6e 100644 --- a/frontend/apps/web/src/pages/groups/GroupMemberList.tsx +++ b/frontend/apps/web/src/pages/groups/GroupMemberList.tsx @@ -23,7 +23,7 @@ import { ListItemSecondaryAction, ListItemText, } from "@mui/material"; -import { Form, Formik } from "formik"; +import { Form, Formik, FormikHelpers } from "formik"; import { DateTime } from "luxon"; import React, { useState } from "react"; import { toast } from "react-toastify"; @@ -32,11 +32,18 @@ import { api } from "@/core/api"; import { useTitle } from "@/core/utils"; import { selectAuthSlice, selectGroupSlice, useAppDispatch, useAppSelector } from "@/store"; import { useTranslation } from "react-i18next"; +import { Navigate } from "react-router-dom"; interface Props { groupId: number; } +type FormValues = { + userId: number; + isOwner: boolean; + canWrite: boolean; +}; + export const GroupMemberList: React.FC = ({ groupId }) => { const { t } = useTranslation(); const dispatch = useAppDispatch(); @@ -47,9 +54,9 @@ export const GroupMemberList: React.FC = ({ groupId }) => { const [memberToEdit, setMemberToEdit] = useState(undefined); - useTitle(t("groups.memberList.tabTitle", "", { groupName: group.name })); + useTitle(t("groups.memberList.tabTitle", "", { groupName: group?.name })); - const handleEditMemberSubmit = (values, { setSubmitting }) => { + const handleEditMemberSubmit = (values: FormValues, { setSubmitting }: FormikHelpers) => { dispatch( updateGroupMemberPrivileges({ groupId, @@ -70,7 +77,7 @@ export const GroupMemberList: React.FC = ({ groupId }) => { }); }; - const getMemberUsername = (member_id) => { + const getMemberUsername = (member_id: number) => { const member = members.find((member) => member.user_id === member_id); if (member === undefined) { return "unknown"; @@ -82,12 +89,16 @@ export const GroupMemberList: React.FC = ({ groupId }) => { setMemberToEdit(undefined); }; - const openEditMemberModal = (userID) => { + const openEditMemberModal = (userID: number) => { const user = members.find((member) => member.user_id === userID); // TODO: maybe deal with disappearing users in the list setMemberToEdit(user); }; + if (!permissions) { + return ; + } + return ( diff --git a/frontend/apps/web/src/pages/groups/GroupSettings.tsx b/frontend/apps/web/src/pages/groups/GroupSettings.tsx index 5401f2a3..c94a2f67 100644 --- a/frontend/apps/web/src/pages/groups/GroupSettings.tsx +++ b/frontend/apps/web/src/pages/groups/GroupSettings.tsx @@ -15,7 +15,7 @@ import { } from "@mui/material"; import { Form, Formik, FormikHelpers } from "formik"; import React, { useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { Navigate, useNavigate } from "react-router-dom"; import { toast } from "react-toastify"; import { z } from "zod"; import { DisabledFormControlLabel, DisabledTextField } from "@/components/style/DisabledTextField"; @@ -28,10 +28,10 @@ import { useTranslation } from "react-i18next"; const validationSchema = z.object({ name: z.string({ required_error: "group name is required" }), - description: z.string().optional(), - terms: z.string().optional(), - currency_symbol: z.string().optional(), - addUserAccountOnJoin: z.boolean().optional(), + description: z.string(), + terms: z.string(), + currency_symbol: z.string(), + addUserAccountOnJoin: z.boolean(), }); type FormValues = z.infer; @@ -51,7 +51,7 @@ export const GroupSettings: React.FC = ({ groupId }) => { const [isEditing, setIsEditing] = useState(false); - useTitle(t("groups.settings.tabTitle", "", { groupName: group.name })); + useTitle(t("groups.settings.tabTitle", "", { groupName: group?.name })); const startEdit = () => { setIsEditing(true); @@ -62,6 +62,9 @@ export const GroupSettings: React.FC = ({ groupId }) => { }; const handleSubmit = (values: FormValues, { setSubmitting }: FormikHelpers) => { + if (!group) { + return; + } dispatch( updateGroup({ group: { @@ -97,6 +100,10 @@ export const GroupSettings: React.FC = ({ groupId }) => { }); }; + if (!permissions || !group) { + return ; + } + return ( {permissions.isOwner ? ( diff --git a/frontend/apps/web/src/pages/profile/SessionList.tsx b/frontend/apps/web/src/pages/profile/SessionList.tsx index 092578d1..901d1301 100644 --- a/frontend/apps/web/src/pages/profile/SessionList.tsx +++ b/frontend/apps/web/src/pages/profile/SessionList.tsx @@ -28,8 +28,8 @@ import { useTranslation } from "react-i18next"; export const SessionList: React.FC = () => { const { t } = useTranslation(); // TODO: fix editing functions - const [editedSessions, setEditedSessions] = useState({}); - const [sessionToDelete, setSessionToDelete] = useState({ + const [editedSessions, setEditedSessions] = useState>({}); + const [sessionToDelete, setSessionToDelete] = useState<{ show: boolean; toDelete: number | null }>({ show: false, toDelete: null, }); @@ -37,17 +37,20 @@ export const SessionList: React.FC = () => { useTitle(t("profile.sessions.tabTitle")); - const editSession = (id) => { + const editSession = (id: number) => { if (editedSessions[id] === undefined) { + const sessionName = profile?.sessions.find((session) => session.id === id)?.name; const newSessions = { ...editedSessions, - [id]: profile?.sessions.find((session) => session.id === id)?.name, }; + if (sessionName) { + newSessions[id] = sessionName; + } setEditedSessions(newSessions); } }; - const stopEditSession = (id) => { + const stopEditSession = (id: number) => { if (editedSessions[id] !== undefined) { const newEditedSessions = { ...editedSessions }; delete newEditedSessions[id]; @@ -59,7 +62,7 @@ export const SessionList: React.FC = () => { setSessionToDelete({ show: false, toDelete: null }); }; - const performRename = (id) => { + const performRename = (id: number) => { if (editedSessions[id] !== undefined) { api.client.auth .renameSession({ requestBody: { session_id: id, name: editedSessions[id] } }) @@ -70,7 +73,7 @@ export const SessionList: React.FC = () => { } }; - const openDeleteSessionModal = (id) => { + const openDeleteSessionModal = (id: number) => { setSessionToDelete({ show: true, toDelete: id }); }; @@ -83,12 +86,12 @@ export const SessionList: React.FC = () => { } }; - const handleEditChange = (id, value) => { + const handleEditChange = (id: number, value: string) => { const newEditedSessions = { ...editedSessions, [id]: value }; setEditedSessions(newEditedSessions); }; - const onKeyUp = (id) => (key) => { + const onKeyUp = (id: number) => (key: React.KeyboardEvent) => { if (key.keyCode === 13) { performRename(id); } @@ -132,13 +135,15 @@ export const SessionList: React.FC = () => { primary={session.name} secondary={ <> - - Valid until{" "} - {DateTime.fromISO(session.valid_until).toLocaleString( - DateTime.DATETIME_FULL - ) && "indefinitely"} - ,{" "} - + {session.valid_until != null && ( + + Valid until{" "} + {DateTime.fromISO(session.valid_until).toLocaleString( + DateTime.DATETIME_FULL + ) && "indefinitely"} + ,{" "} + + )} Last seen on{" "} {DateTime.fromISO(session.last_seen).toLocaleString( diff --git a/frontend/apps/web/src/pages/profile/Settings.tsx b/frontend/apps/web/src/pages/profile/Settings.tsx index 4b27ca7c..81e03c2b 100644 --- a/frontend/apps/web/src/pages/profile/Settings.tsx +++ b/frontend/apps/web/src/pages/profile/Settings.tsx @@ -23,6 +23,7 @@ import { FormLabel, MenuItem, Select, + SelectChangeEvent, Stack, Typography, } from "@mui/material"; @@ -57,7 +58,7 @@ export const Settings: React.FC = () => { .catch((err) => toast.error(`Error while clearing cache: ${err}`)); }; - const handleDarkModeChange = (event) => { + const handleDarkModeChange = (event: SelectChangeEvent) => { const val = event.target.value; dispatch(themeChanged(val as ThemeMode)); }; diff --git a/frontend/apps/web/src/pages/transactions/TransactionDetail/FileGallery.tsx b/frontend/apps/web/src/pages/transactions/TransactionDetail/FileGallery.tsx index 1655c041..2f1bc4c3 100644 --- a/frontend/apps/web/src/pages/transactions/TransactionDetail/FileGallery.tsx +++ b/frontend/apps/web/src/pages/transactions/TransactionDetail/FileGallery.tsx @@ -2,8 +2,8 @@ import Loading from "@/components/style/Loading"; import { api } from "@/core/api"; import { selectTransactionSlice, useAppDispatch, useAppSelector } from "@/store"; import { FileAttachment as BackendFileAttachment, NewFile } from "@abrechnung/api"; -import { selectTransactionById, selectTransactionFiles, wipFileDeleted } from "@abrechnung/redux"; -import { FileAttachment, UpdatedFileAttachment } from "@abrechnung/types"; +import { selectTransactionFiles, wipFileDeleted } from "@abrechnung/redux"; +import { FileAttachment, Transaction, UpdatedFileAttachment } from "@abrechnung/types"; import { AddCircle, ChevronLeft, ChevronRight, Delete } from "@mui/icons-material"; import { Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, Grid, IconButton } from "@mui/material"; import React, { useEffect, useState } from "react"; @@ -24,7 +24,9 @@ const transitionStyles = { entering: { opacity: 0, display: "none" }, entered: { opacity: 1, display: "block" }, exited: { opacity: 0, display: "none" }, -}; + exiting: {}, + unmounted: {}, +} as const; interface ImageDisplayProps { isActive: boolean; @@ -83,17 +85,14 @@ const ImageDisplay: React.FC = ({ file, isActive, onShowImage export interface FileGalleryProps { groupId: number; - transactionId: number; + transaction: Transaction; } -export const FileGallery: React.FC = ({ groupId, transactionId }) => { +export const FileGallery: React.FC = ({ groupId, transaction }) => { const { t } = useTranslation(); const dispatch = useAppDispatch(); - const transaction = useAppSelector((state) => - selectTransactionById({ state: selectTransactionSlice(state), groupId, transactionId }) - ); const attachments = useAppSelector((state) => - selectTransactionFiles({ state: selectTransactionSlice(state), groupId, transactionId }) + selectTransactionFiles({ state: selectTransactionSlice(state), groupId, transactionId: transaction.id }) ); // map of file id to blob object url const [objectUrls, setObjectUrls] = useState>({}); @@ -109,6 +108,9 @@ export const FileGallery: React.FC = ({ groupId, transactionId )[]; Promise.all( backendAttachments.map((attachment) => { + if (attachment.blob_id == null) { + return null; + } return api.fetchFile(attachment.id, attachment.blob_id).then((objectUrl) => { return { fileId: attachment.id, @@ -119,7 +121,11 @@ export const FileGallery: React.FC = ({ groupId, transactionId }) ) .then((loadedBlobs) => { - const urlMap = Object.fromEntries(loadedBlobs.map((objInfo) => [objInfo.fileId, objInfo.objectUrl])); + const urlMap = Object.fromEntries( + loadedBlobs + .filter((objInfo) => objInfo != null) + .map((objInfo) => [objInfo.fileId, objInfo.objectUrl]) + ); setObjectUrls(urlMap); }) .catch((err) => { @@ -147,7 +153,7 @@ export const FileGallery: React.FC = ({ groupId, transactionId const deleteSelectedFile = () => { if (active < attachments.length) { - dispatch(wipFileDeleted({ groupId, transactionId, fileId: attachments[active].id })); + dispatch(wipFileDeleted({ groupId, transactionId: transaction.id, fileId: attachments[active].id })); setShowImage(false); } }; @@ -207,7 +213,7 @@ export const FileGallery: React.FC = ({ groupId, transactionId setShowUploadDialog(false)} /> diff --git a/frontend/apps/web/src/pages/transactions/TransactionDetail/ImageUploadDialog.tsx b/frontend/apps/web/src/pages/transactions/TransactionDetail/ImageUploadDialog.tsx index 1ae6d383..072ef2e4 100644 --- a/frontend/apps/web/src/pages/transactions/TransactionDetail/ImageUploadDialog.tsx +++ b/frontend/apps/web/src/pages/transactions/TransactionDetail/ImageUploadDialog.tsx @@ -49,16 +49,22 @@ export const ImageUploadDialog: React.FC = ({ groupId, transactionId, sho return compressedImage; } catch (error) { setCompressionProgress(undefined); - setError(`Failed to compress image! ${error.message}`); + setError(`Failed to compress image! ${(error as any).message}`); return undefined; } }; - const selectFile = async (event) => { - const file: File = event.target.files[0]; - const strippedFilename = event.target.files[0].name.split(".")[0]; + const selectFile: React.ChangeEventHandler = async (event) => { + const file = event.target.files?.[0]; + if (!file) { + return; + } + const strippedFilename = file.name.split(".")[0]; const renamedFile = new File([file], strippedFilename, { type: file.type }); const compressedFile = await compressImage(renamedFile); + if (!compressedFile) { + return; + } try { const imageAsBase64 = await toBase64(compressedFile); setSelectedFile({ @@ -67,7 +73,7 @@ export const ImageUploadDialog: React.FC = ({ groupId, transactionId, sho mime_type: compressedFile.type, }); } catch (e) { - setError(`Error during image upload: ${e.message}`); + setError(`Error during image upload: ${(e as any).message}`); setSelectedFile(undefined); } }; @@ -76,7 +82,13 @@ export const ImageUploadDialog: React.FC = ({ groupId, transactionId, sho if (event.target.value == null) { return; } - setSelectedFile((prev) => ({ ...prev, filename: event.target.value })); + setSelectedFile((prev) => { + if (!prev) { + return prev; + } + + return { ...prev, filename: event.target.value }; + }); }; const upload = () => { diff --git a/frontend/apps/web/src/pages/transactions/TransactionDetail/TransactionActions.tsx b/frontend/apps/web/src/pages/transactions/TransactionDetail/TransactionActions.tsx index 00d1e9d8..9a7542e4 100644 --- a/frontend/apps/web/src/pages/transactions/TransactionDetail/TransactionActions.tsx +++ b/frontend/apps/web/src/pages/transactions/TransactionDetail/TransactionActions.tsx @@ -1,4 +1,4 @@ -import { selectCurrentUserPermissions, selectTransactionById } from "@abrechnung/redux"; +import { selectCurrentUserPermissions } from "@abrechnung/redux"; import { ChevronLeft, Delete, Edit } from "@mui/icons-material"; import { Button, @@ -12,13 +12,14 @@ import { LinearProgress, } from "@mui/material"; import React, { useState } from "react"; -import { useNavigate } from "react-router-dom"; -import { selectTransactionSlice, useAppSelector } from "@/store"; +import { Navigate, useNavigate } from "react-router-dom"; +import { useAppSelector } from "@/store"; import { useTranslation } from "react-i18next"; +import { Transaction } from "@abrechnung/types"; interface Props { groupId: number; - transactionId: number; + transaction: Transaction; showProgress?: boolean | undefined; onDelete: () => void; onStartEdit: () => void; @@ -28,7 +29,7 @@ interface Props { export const TransactionActions: React.FC = ({ groupId, - transactionId, + transaction, onDelete, onStartEdit, onCommitEdit, @@ -41,9 +42,9 @@ export const TransactionActions: React.FC = ({ const permissions = useAppSelector((state) => selectCurrentUserPermissions({ state: state, groupId })); - const transaction = useAppSelector((state) => - selectTransactionById({ state: selectTransactionSlice(state), groupId, transactionId }) - ); + if (!permissions) { + return ; + } const transactionTypeLabel = transaction.type === "purchase" ? "purchase" : "transfer"; diff --git a/frontend/apps/web/src/pages/transactions/TransactionDetail/TransactionDetail.tsx b/frontend/apps/web/src/pages/transactions/TransactionDetail/TransactionDetail.tsx index be44e605..2e8f80b7 100644 --- a/frontend/apps/web/src/pages/transactions/TransactionDetail/TransactionDetail.tsx +++ b/frontend/apps/web/src/pages/transactions/TransactionDetail/TransactionDetail.tsx @@ -45,7 +45,7 @@ export const TransactionDetail: React.FC = ({ groupId }) => { const transaction: Transaction | undefined = useAppSelector((state) => selectTransactionById({ state: selectTransactionSlice(state), groupId, transactionId }) ); - useTitle(`${group.name} - ${transaction?.name}`); + useTitle(`${group?.name} - ${transaction?.name}`); const hasPositions = useAppSelector((state) => selectTransactionHasPositions({ state: selectTransactionSlice(state), groupId, transactionId }) @@ -64,12 +64,19 @@ export const TransactionDetail: React.FC = ({ groupId }) => { }, [transaction, setValidationErrors]); const edit = React.useCallback(() => { + if (!transaction) { + return; + } + if (!transaction.is_wip) { dispatch(transactionEditStarted({ groupId, transactionId })); } }, [transaction, dispatch, groupId, transactionId]); const abortEdit = React.useCallback(() => { + if (!transaction) { + return; + } if (!transaction.is_wip) { toast.error("Cannot save as there are not changes made"); return; @@ -95,12 +102,15 @@ export const TransactionDetail: React.FC = ({ groupId }) => { }, [setShowProgress, dispatch, navigate, groupId, transactionId]); const save = React.useCallback(() => { + if (!transaction) { + return; + } if (!transaction.is_wip) { toast.error("Cannot cancel editing as there are not changes made"); return; } const validated = TransactionValidator.safeParse(transaction); - const positionErrors = {}; + const positionErrors: PositionValidationErrors = {}; for (const position of Object.values(transaction.positions)) { const v = PositionValidator.safeParse(position); if (!v.success) { @@ -148,7 +158,7 @@ export const TransactionDetail: React.FC = ({ groupId }) => { = ({ groupId }) => { showProgress={showProgress} /> - + {transaction.type === "purchase" && !showPositions && transaction.is_wip && !hasPositions ? ( diff --git a/frontend/apps/web/src/pages/transactions/TransactionDetail/TransactionMetadata.tsx b/frontend/apps/web/src/pages/transactions/TransactionDetail/TransactionMetadata.tsx index 8334e82c..2198ecb4 100644 --- a/frontend/apps/web/src/pages/transactions/TransactionDetail/TransactionMetadata.tsx +++ b/frontend/apps/web/src/pages/transactions/TransactionDetail/TransactionMetadata.tsx @@ -7,12 +7,11 @@ import { TextInput } from "@/components/TextInput"; import { selectTransactionSlice, useAppDispatch, useAppSelector } from "@/store"; import { selectTransactionBalanceEffect, - selectTransactionById, selectTransactionHasFiles, selectTransactionHasPositions, wipTransactionUpdated, } from "@abrechnung/redux"; -import { Transaction, TransactionShare, TransactionValidator } from "@abrechnung/types"; +import { Account, Transaction, TransactionShare, TransactionValidator } from "@abrechnung/types"; import { Grid, InputAdornment, TableCell } from "@mui/material"; import * as React from "react"; import { typeToFlattenedError, z } from "zod"; @@ -22,35 +21,32 @@ import { useFormatCurrency } from "@/hooks"; interface Props { groupId: number; - transactionId: number; + transaction: Transaction; validationErrors: typeToFlattenedError>; showPositions?: boolean | undefined; } export const TransactionMetadata: React.FC = ({ groupId, - transactionId, + transaction, validationErrors, showPositions = false, }) => { const { t } = useTranslation(); const formatCurrency = useFormatCurrency(); const dispatch = useAppDispatch(); - const transaction = useAppSelector((state) => - selectTransactionById({ state: selectTransactionSlice(state), groupId, transactionId }) - ); const hasAttachments = useAppSelector((state) => - selectTransactionHasFiles({ state: selectTransactionSlice(state), groupId, transactionId }) + selectTransactionHasFiles({ state: selectTransactionSlice(state), groupId, transactionId: transaction.id }) ); const hasPositions = useAppSelector((state) => - selectTransactionHasPositions({ state: selectTransactionSlice(state), groupId, transactionId }) + selectTransactionHasPositions({ state: selectTransactionSlice(state), groupId, transactionId: transaction.id }) ); const balanceEffect = useAppSelector((state) => - selectTransactionBalanceEffect({ state: selectTransactionSlice(state), groupId, transactionId }) + selectTransactionBalanceEffect({ state: selectTransactionSlice(state), groupId, transactionId: transaction.id }) ); const renderShareInfo = React.useCallback( - ({ account }) => + ({ account }: { account: Account }) => showPositions || hasPositions ? ( <> @@ -182,7 +178,7 @@ export const TransactionMetadata: React.FC = ({ } value={ Object.keys(transaction.creditor_shares).length === 0 - ? null + ? undefined : Number(Object.keys(transaction.creditor_shares)[0]) } onChange={(newValue) => pushChanges({ creditor_shares: { [newValue.id]: 1.0 } })} @@ -199,7 +195,7 @@ export const TransactionMetadata: React.FC = ({ label={t("transactions.transferredTo")} value={ Object.keys(transaction.debitor_shares).length === 0 - ? null + ? undefined : Number(Object.keys(transaction.debitor_shares)[0]) } onChange={(newValue) => pushChanges({ debitor_shares: { [newValue.id]: 1.0 } })} @@ -213,7 +209,7 @@ export const TransactionMetadata: React.FC = ({ {(transaction.is_wip || hasAttachments) && ( - + )} {transaction.type === "purchase" && ( diff --git a/frontend/apps/web/src/pages/transactions/TransactionDetail/purchase/TransactionPositions.tsx b/frontend/apps/web/src/pages/transactions/TransactionDetail/purchase/TransactionPositions.tsx index 537d626c..8aac4dfc 100644 --- a/frontend/apps/web/src/pages/transactions/TransactionDetail/purchase/TransactionPositions.tsx +++ b/frontend/apps/web/src/pages/transactions/TransactionDetail/purchase/TransactionPositions.tsx @@ -69,9 +69,10 @@ export const TransactionPositions: React.FC = ({ const accountIDMap = useAppSelector((state) => selectAccountIdToAccountMap({ state: selectAccountSlice(state), groupId }) ); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const transaction = useAppSelector((state) => selectTransactionById({ state: selectTransactionSlice(state), groupId, transactionId }) - ); + )!; const { positions, positionsHaveComplexShares } = useAppSelector((state) => selectPositions({ state, groupId, transactionId }) ); @@ -85,7 +86,7 @@ export const TransactionPositions: React.FC = ({ // find all accounts that take part in the transaction, either via debitor shares or purchase items // TODO: should we add creditor accounts as well? - const [additionalPurchaseItemAccounts, setAdditionalPurchaseItemAccounts] = useState([]); + const [additionalPurchaseItemAccounts, setAdditionalPurchaseItemAccounts] = useState([]); const { shownAccounts, shownAccountIDs } = React.useMemo(() => { let accountIDsToShow: number[] = Array.from( @@ -120,7 +121,7 @@ export const TransactionPositions: React.FC = ({ const totalPositionValue = positions.reduce((acc, curr) => acc + curr.price, 0); const sharedTransactionValue = transaction.value - totalPositionValue; - const purchaseItemSumForAccount = (accountID) => { + const purchaseItemSumForAccount = (accountID: number) => { return transactionBalanceEffect[accountID] !== undefined ? transactionBalanceEffect[accountID].positions : 0; }; @@ -150,7 +151,7 @@ export const TransactionPositions: React.FC = ({ const addPurchaseItemAccount = (account: Account) => { setShowAccountSelect(false); - setAdditionalPurchaseItemAccounts((currAdditionalAccounts) => + setAdditionalPurchaseItemAccounts((currAdditionalAccounts: number[]) => Array.from(new Set([...currAdditionalAccounts, account.id])) ); }; @@ -161,9 +162,13 @@ export const TransactionPositions: React.FC = ({ {t("transactions.positions.positions")} {transaction.is_wip && ( } + control={ + setShowAdvanced(event.target.checked)} + /> + } checked={showAdvanced} - onChange={(event: React.ChangeEvent) => setShowAdvanced(event.target.checked)} label={t("common.advanced")} /> )} @@ -218,7 +223,7 @@ export const TransactionPositions: React.FC = ({ showAdvanced={showAdvanced} showAccountSelect={showAccountSelect} showAddAccount={showAddAccount} - validationError={validationErrors[position.id]} + validationError={validationErrors?.[position.id]} /> )) : positions.map((position) => ( diff --git a/frontend/apps/web/src/pages/transactions/TransactionList/TransactionList.tsx b/frontend/apps/web/src/pages/transactions/TransactionList/TransactionList.tsx index 544642ed..a1ffa85a 100644 --- a/frontend/apps/web/src/pages/transactions/TransactionList/TransactionList.tsx +++ b/frontend/apps/web/src/pages/transactions/TransactionList/TransactionList.tsx @@ -33,7 +33,7 @@ import { import { useTheme } from "@mui/material/styles"; import { SaveAlt } from "@mui/icons-material"; import React, { useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { Navigate, useNavigate } from "react-router-dom"; import { TagSelector } from "@/components/TagSelector"; import { PurchaseIcon, TransferIcon } from "@/components/style/AbrechnungIcons"; import { MobilePaper } from "@/components/style/mobile"; @@ -47,7 +47,7 @@ interface Props { groupId: number; } -const emptyList = []; +const emptyList: string[] = []; const MAX_ITEMS_PER_PAGE = 40; const downloadFile = (content: string, filename: string, mimetype: string) => { @@ -106,7 +106,7 @@ export const TransactionList: React.FC = ({ groupId }) => { (currentPage + 1) * MAX_ITEMS_PER_PAGE ); - useTitle(t("transactions.list.tabTitle", "", { groupName: group.name })); + useTitle(t("transactions.list.tabTitle", "", { groupName: group?.name })); const onCreatePurchase = () => { dispatch(createTransaction({ groupId, type: "purchase" })) @@ -127,6 +127,10 @@ export const TransactionList: React.FC = ({ groupId }) => { const downloadCsv = useDownloadCsv(groupId, transactions); + if (!permissions) { + return ; + } + return ( <> diff --git a/frontend/apps/web/tsconfig.app.json b/frontend/apps/web/tsconfig.app.json index cc0bce60..c96b075c 100644 --- a/frontend/apps/web/tsconfig.app.json +++ b/frontend/apps/web/tsconfig.app.json @@ -2,8 +2,11 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "../../dist/out-tsc", - "types": ["node"], - "strict": false + "types": [ + "node", + "../../node_modules/@nx/react/typings/cssmodule.d.ts", + "../../node_modules/@nx/react/typings/image.d.ts" + ] }, "files": ["../../node_modules/@nx/react/typings/cssmodule.d.ts", "../../node_modules/@nx/react/typings/image.d.ts"], "exclude": [ diff --git a/frontend/libs/api/src/lib/websocket.ts b/frontend/libs/api/src/lib/websocket.ts index 87b77fa4..3b227e15 100644 --- a/frontend/libs/api/src/lib/websocket.ts +++ b/frontend/libs/api/src/lib/websocket.ts @@ -107,7 +107,7 @@ const parseNotificationPayload = ( }; export class AbrechnungWebSocket { - private ws: WebSocket; + private ws?: WebSocket; private msgQueue: object[] = []; // subscription type -> element id -> callback @@ -121,13 +121,7 @@ export class AbrechnungWebSocket { constructor( private url: string, private api: Api - ) { - // cannot reuse init() here as otherwise ws will be uninitialized - // this.ws = new WebSocket(this.url); - // this.ws.onopen = this.onopen; - // this.ws.onclose = this.onclose; - // this.ws.onmessage = this.onmessage; - } + ) {} public setUrl = (url: string) => { this.url = url; @@ -206,7 +200,7 @@ export class AbrechnungWebSocket { }; private send = (msg: object) => { - if (this.ws.readyState !== 1) { + if (this.ws === undefined || this.ws.readyState !== 1) { this.msgQueue.push(msg); } else { // console.log("WS Sent message with args: ", msg);